hecks 3.14.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 (419) hide show
  1. checksums.yaml +4 -4
  2. data/examples/pizzas/adapters/fake_processor.rb +34 -0
  3. data/examples/pizzas/adapters/payment_signature.rb +66 -0
  4. data/examples/pizzas/adapters/payment_webhook.rb +87 -0
  5. data/examples/pizzas/bluebook/pizzas.behaviors +33 -0
  6. data/examples/pizzas/bluebook/pizzas.hecksagon +5 -3
  7. data/examples/pizzas/config.ru +22 -0
  8. data/lib/hecks/adapters/driven/prism.rb +0 -2
  9. data/lib/hecks/adapters/driven.rb +0 -2
  10. data/lib/hecks/adapters/driving/json.rb +1 -7
  11. data/lib/hecks/bluebook/dsl/bootstrap_table.rb +2 -2
  12. data/lib/hecks/bluebook/dsl/derivation_words.rb +58 -0
  13. data/lib/hecks/bluebook/dsl/translation_builder.rb +6 -37
  14. data/lib/hecks/bluebook/expression/ast_json.rb +0 -9
  15. data/lib/hecks/bluebook/hecksagon.rb +1 -1
  16. data/lib/hecks/bluebook/meta_validator/translation_judge/rules.rb +7 -10
  17. data/lib/hecks/bluebook/meta_validator/translation_judge.rb +4 -8
  18. data/lib/hecks/bluebook/meta_validator.rb +0 -11
  19. data/lib/hecks/bluebook/translation.rb +18 -10
  20. data/lib/hecks/bluebook/translation_expression/evaluator.rb +63 -0
  21. data/lib/hecks/bluebook/translation_expression/parser.rb +127 -0
  22. data/lib/hecks/bluebook/translation_expression.rb +39 -0
  23. data/lib/hecks/bluebook/translation_plan.rb +70 -0
  24. data/lib/hecks/bluebook.rb +2 -0
  25. data/lib/hecks/chapters.rb +3 -3
  26. data/lib/hecks/composer/adapters/claude_composer.adapter +3 -0
  27. data/lib/hecks/composer/adapters/claude_composer.rb +83 -0
  28. data/lib/hecks/composer/adapters/fake_agent.adapter +3 -0
  29. data/lib/hecks/composer/adapters/fake_agent.rb +55 -0
  30. data/lib/hecks/composer/adapters/jev.adapter +3 -0
  31. data/lib/hecks/composer/adapters/jev.rb +66 -0
  32. data/lib/hecks/composer/agent.rb +113 -0
  33. data/lib/hecks/composer/assistant.rb +110 -0
  34. data/lib/hecks/composer/behavior_run.rb +53 -0
  35. data/lib/hecks/composer/bluebook/composer.bluebook +178 -0
  36. data/lib/hecks/composer/bluebook/composer.hecksagon +16 -0
  37. data/lib/hecks/composer/board.rb +65 -0
  38. data/lib/hecks/composer/board_edit/command_text.rb +39 -0
  39. data/lib/hecks/composer/board_edit/source.rb +84 -0
  40. data/lib/hecks/composer/board_edit.rb +144 -0
  41. data/lib/hecks/composer/check.rb +89 -0
  42. data/lib/hecks/composer/domains.rb +81 -0
  43. data/lib/hecks/composer/jobs.rb +134 -0
  44. data/lib/hecks/composer/layout.rb +37 -0
  45. data/lib/hecks/composer/live.rb +150 -0
  46. data/lib/hecks/composer/outline.rb +48 -0
  47. data/lib/hecks/composer/page.html +989 -0
  48. data/lib/hecks/composer/panels.rb +142 -0
  49. data/lib/hecks/composer/parse.rb +83 -0
  50. data/lib/hecks/composer/plain.rb +28 -0
  51. data/lib/hecks/composer/ports/composer_agent.port +7 -0
  52. data/lib/hecks/composer/session.rb +141 -0
  53. data/lib/hecks/composer/toolbox.rb +166 -0
  54. data/lib/hecks/composer/web.rb +156 -0
  55. data/lib/hecks/composer/workbench.rb +51 -0
  56. data/lib/hecks/composer/workspace.rb +81 -0
  57. data/lib/hecks/composer.rb +17 -0
  58. data/lib/hecks/corpus/adapters.rb +22 -0
  59. data/lib/hecks/corpus/rust.rb +1 -1
  60. data/lib/hecks/corpus/worlds.rb +19 -0
  61. data/lib/hecks/corpus.rb +5 -0
  62. data/lib/hecks/forms/app/commands.rb +2 -3
  63. data/lib/hecks/forms/app/queries.rb +2 -3
  64. data/lib/hecks/forms/app/records.rb +17 -5
  65. data/lib/hecks/forms/command_form_renderer.rb +42 -16
  66. data/lib/hecks/forms/field_controls.rb +2 -2
  67. data/lib/hecks/forms/field_renderer.rb +1 -1
  68. data/lib/hecks/forms/field_shape.rb +36 -17
  69. data/lib/hecks/forms/index_renderer.rb +2 -1
  70. data/lib/hecks/forms/page.rb +1 -1
  71. data/lib/hecks/forms/params.rb +0 -13
  72. data/lib/hecks/forms/query_form_renderer.rb +7 -6
  73. data/lib/hecks/forms/record_renderer.rb +11 -7
  74. data/lib/hecks/forms/rule_words.rb +100 -0
  75. data/lib/hecks/forms/value_object_shape.rb +2 -1
  76. data/lib/hecks/fuzzing/bounded_exhaustive_expressions.rb +0 -3
  77. data/lib/hecks/fuzzing/isolated_boot/worlds.rb +3 -6
  78. data/lib/hecks/fuzzing/replay/filters.rb +2 -6
  79. data/lib/hecks/gate/stages.yml +25 -0
  80. data/lib/hecks/grammar/evolve/seeds.rb +0 -6
  81. data/lib/hecks/grammar/evolve.rb +0 -3
  82. data/lib/hecks/grammar/translation.bluebook +1 -1
  83. data/lib/hecks/hecks/adapters/codebase/composing.rb +56 -0
  84. data/lib/hecks/hecks/adapters/codebase/source_tree.rb +3 -1
  85. data/lib/hecks/hecks/adapters/journal_store/changes.rb +0 -26
  86. data/lib/hecks/hecks/adapters/journal_store/examination.rb +2 -23
  87. data/lib/hecks/hecks/adapters/journal_store/readings.rb +3 -11
  88. data/lib/hecks/hecks/adapters/journal_store.rb +0 -11
  89. data/lib/hecks/hecks/adapters/rehearsal/inquiry.rb +4 -11
  90. data/lib/hecks/hecks/adapters/rehearsal/memory_store.rb +0 -9
  91. data/lib/hecks/hecks/adapters/rehearsal/postgres_reading.rb +0 -4
  92. data/lib/hecks/hecks/codebase.behaviors +6 -0
  93. data/lib/hecks/hecks/codebase.bluebook +21 -0
  94. data/lib/hecks/hecks/composer.behaviors +68 -0
  95. data/lib/hecks/hecks/context_map.hecksagon +1 -1
  96. data/lib/hecks/hecks/custodian.behaviors +0 -6
  97. data/lib/hecks/hecks/custodian.bluebook +3 -80
  98. data/lib/hecks/hecks/hecks.hecksagon +2 -2
  99. data/lib/hecks/hecks/hecks.world +1 -1
  100. data/lib/hecks/hecks/rehearsal.bluebook +7 -13
  101. data/lib/hecks/kernel_capabilities/templates.rb +4 -4
  102. data/lib/hecks/kernel_capabilities.rb +1 -1
  103. data/lib/hecks/language/bluebook/vocabulary.bluebook +2 -3
  104. data/lib/hecks/language/translation/translation_aggregate.bluebook +21 -24
  105. data/lib/hecks/ports/access_control.rb +2 -2
  106. data/lib/hecks/ports/persistence/plugins/era/era_check.rb +3 -41
  107. data/lib/hecks/ports/persistence/plugins/era/lineage/coverage.rb +7 -7
  108. data/lib/hecks/ports/persistence/plugins/era/lineage/rewrite.rb +25 -2
  109. data/lib/hecks/ports/persistence/plugins/era/lineage/rules.rb +4 -4
  110. data/lib/hecks/ports/persistence/plugins/era/lineage.rb +17 -25
  111. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/base_sql.rb +0 -12
  112. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/domain_rename.rb +1 -1
  113. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/era_store.rb +2 -22
  114. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/provisioning.rb +0 -1
  115. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage.rb +9 -3
  116. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/minter.rb +3 -29
  117. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/refusals.rb +2 -26
  118. data/lib/hecks/ports/persistence/plugins/era/translation/audit/layer_two.rb +6 -33
  119. data/lib/hecks/ports/persistence/plugins/era/translation/audit/unfed_report.rb +3 -3
  120. data/lib/hecks/ports/persistence/plugins/era/translation/audit.rb +2 -4
  121. data/lib/hecks/ports/persistence/plugins/era/translation/expression_sql.rb +52 -0
  122. data/lib/hecks/ports/persistence/plugins/era/translation/rule_compiler.rb +23 -29
  123. data/lib/hecks/ports/persistence/plugins/era/translation/scaffold.rb +1 -1
  124. data/lib/hecks/ports/persistence/plugins/era/translation.rb +0 -1
  125. data/lib/hecks/ports/persistence/plugins/era.rb +3 -6
  126. data/lib/hecks/ports.rb +0 -1
  127. data/lib/hecks/projection_files.rb +3 -1
  128. data/lib/hecks/projections/deploy/templates/lambda/nat_comment_oauth.tmpl +1 -1
  129. data/lib/hecks/projections/glossary/entries.rb +10 -4
  130. data/lib/hecks/projections/glossary/groups.rb +42 -0
  131. data/lib/hecks/projections/glossary/html/page.rb +7 -2
  132. data/lib/hecks/projections/glossary/html/parser.rb +1 -1
  133. data/lib/hecks/projections/glossary/html/sections.rb +31 -11
  134. data/lib/hecks/projections/glossary/html.rb +1 -2
  135. data/lib/hecks/projections/glossary/markdown.rb +31 -28
  136. data/lib/hecks/projections/glossary/mermaid.rb +3 -0
  137. data/lib/hecks/projections/glossary/page.css +143 -153
  138. data/lib/hecks/projections/glossary/page.js +63 -3
  139. data/lib/hecks/projections/glossary/sentences/actions.rb +88 -0
  140. data/lib/hecks/projections/glossary/sentences/paths.rb +77 -0
  141. data/lib/hecks/projections/glossary/sentences/queries.rb +90 -0
  142. data/lib/hecks/projections/glossary/sentences/value_objects.rb +32 -5
  143. data/lib/hecks/projections/glossary/sentences.rb +13 -3
  144. data/lib/hecks/projections/glossary/slugs.rb +4 -1
  145. data/lib/hecks/projections/glossary.rb +2 -1
  146. data/lib/hecks/projections/plain_english.rb +97 -0
  147. data/lib/hecks/projections/rust_vocabulary/arguments.rb +2 -2
  148. data/lib/hecks/projections/rust_vocabulary/enum_body.rb +20 -25
  149. data/lib/hecks/projections/rust_vocabulary/tests.rb +2 -2
  150. data/lib/hecks/projections/rust_vocabulary.rb +14 -6
  151. data/lib/hecks/projections/site/cms_editor/color.rb +0 -6
  152. data/lib/hecks/projections/statements/actions.rb +99 -0
  153. data/lib/hecks/projections/statements/relationships.rb +86 -0
  154. data/lib/hecks/projections/statements.rb +15 -24
  155. data/lib/hecks/projector/exporter/adapters.rb +112 -0
  156. data/lib/hecks/projector/exporter/translations.rb +18 -35
  157. data/lib/hecks/projector/exporter/worlds.rb +103 -0
  158. data/lib/hecks/projector/exporter.rb +5 -1
  159. data/lib/hecks/quality_control/adapters/agent.rb +6 -7
  160. data/lib/hecks/quality_control/adapters/agent_profile.rb +16 -114
  161. data/lib/hecks/quality_control/cli/qa_mine_combinations.rb +1 -1
  162. data/lib/hecks/runtime/loader/boot_steps.rb +0 -20
  163. data/lib/hecks/runtime/outbox.rb +2 -9
  164. data/lib/hecks/runtime/port_operation_interpreter.rb +1 -9
  165. data/lib/hecks/runtime/remote_dispatcher.rb +2 -2
  166. data/lib/hecks/runtime/value.rb +13 -0
  167. data/lib/hecks/rust_build/project_rust/target_ir.rb +23 -4
  168. data/lib/hecks/rust_build/wasm.rb +0 -12
  169. data/lib/hecks/rust_build/write_if_changed.rb +12 -12
  170. data/lib/hecks/rust_format.rb +77 -0
  171. data/lib/hecks/tools/fuzz_sweep/findings.rb +3 -9
  172. data/lib/hecks/tools/fuzz_sweep.rb +1 -3
  173. data/lib/hecks/tools/translation_audit/verdicts.rb +3 -28
  174. data/lib/hecks/tools/translation_audit.rb +8 -10
  175. data/lib/hecks/tools/translation_scaffold/reporting.rb +1 -1
  176. data/lib/hecks/version.rb +1 -1
  177. data/lib/hecks/vocabulary.rb +2 -1
  178. data/lib/hecks.rb +3 -0
  179. data/prebuilt/{a883a7e568667aa5ac904c82daff356bdf9e996252a36af6def4a4a43efb967e.marshal → 02c09c73b272c1fb5a9ae6d87bb5e5e529208e00f6b4b597dc2c0fd70a9c0964.marshal} +0 -0
  180. data/prebuilt/{1a6f596be124228be93eba1d86dbc9d2933190fa389b284331241cb9f27115a0.marshal → bb84a835cf6a15136bad5ca03aa5bf93a5b92f5f72444618d3ef2412b987fb32.marshal} +0 -0
  181. data/prebuilt/{verdicts-872993b1aa26d73fe48f28251374b8585fed145d02aab42be0f00de386b6ff80.json → verdicts-f1f10da14c8e8f01b4c9b7e3e2d74be8cd2bf4676364ed17fa16975d2a076d75.json} +1 -1
  182. data/rust/Cargo.toml +40 -0
  183. data/rust/build/Cargo.toml +36 -0
  184. data/rust/build/clippy.toml +4 -0
  185. data/rust/build/src/adapters_pass.rs +196 -0
  186. data/rust/build/src/binding_pass.rs +54 -13
  187. data/rust/build/src/build_artifact.rs +23 -5
  188. data/rust/build/src/cargo_sync.rs +69 -26
  189. data/rust/build/src/lineage_pass.rs +101 -87
  190. data/rust/build/src/main.rs +11 -2
  191. data/rust/build/src/pipeline.rs +80 -19
  192. data/rust/build/src/reserved_names.rs +6 -50
  193. data/rust/build/src/resolve.rs +82 -35
  194. data/rust/build/src/rustfmt.rs +100 -0
  195. data/rust/build/src/seams_pass.rs +85 -16
  196. data/rust/build/src/subprocess.rs +30 -6
  197. data/rust/build/src/tmp.rs +5 -2
  198. data/rust/build/src/translation_pass.rs +72 -22
  199. data/rust/build/src/world_pass.rs +109 -0
  200. data/rust/clippy.toml +4 -0
  201. data/rust/codegen/Cargo.toml +36 -0
  202. data/rust/codegen/clippy.toml +4 -0
  203. data/rust/codegen/src/asks.rs +40 -14
  204. data/rust/codegen/src/attr.rs +2 -2
  205. data/rust/codegen/src/bridging.rs +676 -117
  206. data/rust/codegen/src/commands.rs +979 -373
  207. data/rust/codegen/src/constraints.rs +88 -25
  208. data/rust/codegen/src/dependency_planning.rs +128 -34
  209. data/rust/codegen/src/domain_generator.rs +300 -170
  210. data/rust/codegen/src/exemplar.rs +87 -32
  211. data/rust/codegen/src/expr_emitter.rs +54 -6
  212. data/rust/codegen/src/fielded.rs +193 -41
  213. data/rust/codegen/src/hecks_naming.rs +124 -16
  214. data/rust/codegen/src/hecks_naming_tests.rs +81 -0
  215. data/rust/codegen/src/json.rs +80 -44
  216. data/rust/codegen/src/json_codec.rs +585 -149
  217. data/rust/codegen/src/literal.rs +104 -138
  218. data/rust/codegen/src/main.rs +145 -52
  219. data/rust/codegen/src/manifest.rs +76 -14
  220. data/rust/codegen/src/mutations.rs +1212 -239
  221. data/rust/codegen/src/naming.rs +111 -60
  222. data/rust/codegen/src/optional_pass.rs +90 -28
  223. data/rust/codegen/src/ports.rs +5 -4
  224. data/rust/codegen/src/prelude.rs +139 -49
  225. data/rust/codegen/src/queries.rs +601 -226
  226. data/rust/codegen/src/reactions.rs +268 -74
  227. data/rust/codegen/src/read_models.rs +606 -205
  228. data/rust/codegen/src/reference_specs.rs +16 -3
  229. data/rust/codegen/src/registry.rs +128 -79
  230. data/rust/codegen/src/reserved_names.rs +6 -50
  231. data/rust/codegen/src/shared.rs +71 -22
  232. data/rust/codegen/src/sidecars.rs +15 -5
  233. data/rust/codegen/src/skip_reason.rs +8 -2
  234. data/rust/codegen/src/types.rs +306 -108
  235. data/rust/host/Cargo.lock +1 -0
  236. data/rust/host/Cargo.toml +40 -4
  237. data/rust/host/HECKS_RELEASE +1 -1
  238. data/rust/host/clippy.toml +4 -0
  239. data/rust/host/src/api.rs +698 -218
  240. data/rust/host/src/auth.rs +491 -214
  241. data/rust/host/src/b64.rs +91 -0
  242. data/rust/host/src/bin/expr_harness.rs +6 -2
  243. data/rust/host/src/bin/lineage_harness.rs +47 -11
  244. data/rust/host/src/bin/mint_harness.rs +70 -37
  245. data/rust/host/src/bin/validate_harness.rs +16 -8
  246. data/rust/host/src/boot.rs +144 -68
  247. data/rust/host/src/boundary_fuzz/api.rs +130 -32
  248. data/rust/host/src/boundary_fuzz/auth.rs +108 -23
  249. data/rust/host/src/boundary_fuzz/expr_json.rs +85 -11
  250. data/rust/host/src/boundary_fuzz/ir.rs +164 -12
  251. data/rust/host/src/boundary_fuzz/server.rs +283 -36
  252. data/rust/host/src/boundary_fuzz/web.rs +56 -8
  253. data/rust/host/src/checkout.rs +78 -32
  254. data/rust/host/src/clock.rs +8 -0
  255. data/rust/host/src/commerce_ir.rs +333 -95
  256. data/rust/host/src/crypto.rs +33 -0
  257. data/rust/host/src/dispatch.rs +990 -331
  258. data/rust/host/src/expr_json.rs +1021 -273
  259. data/rust/host/src/extension.rs +24 -4
  260. data/rust/host/src/field_hints.rs +11 -7
  261. data/rust/host/src/fuzz_support.rs +85 -13
  262. data/rust/host/src/host.rs +100 -0
  263. data/rust/host/src/http.rs +22 -0
  264. data/rust/host/src/ir.rs +326 -16
  265. data/rust/host/src/journal.rs +277 -176
  266. data/rust/host/src/lambda_client.rs +219 -53
  267. data/rust/host/src/lib.rs +16 -4
  268. data/rust/host/src/log.rs +62 -11
  269. data/rust/host/src/mint.rs +1153 -326
  270. data/rust/host/src/needs.rs +185 -44
  271. data/rust/host/src/payments/keystore.rs +197 -45
  272. data/rust/host/src/payments/roles.rs +39 -7
  273. data/rust/host/src/payments/tests.rs +1427 -282
  274. data/rust/host/src/payments.rs +391 -112
  275. data/rust/host/src/platform_signin/attack_tests.rs +5 -5
  276. data/rust/host/src/platform_signin/config.rs +11 -11
  277. data/rust/host/src/platform_signin/cookie.rs +2 -2
  278. data/rust/host/src/platform_signin/extension.rs +2 -1
  279. data/rust/host/src/platform_signin/handler.rs +9 -8
  280. data/rust/host/src/platform_signin/jti.rs +4 -2
  281. data/rust/host/src/platform_signin/jwks.rs +2 -1
  282. data/rust/host/src/platform_signin/jws.rs +2 -37
  283. data/rust/host/src/platform_signin/known_answer_tests.rs +5 -1
  284. data/rust/host/src/platform_signin/mode_tests.rs +5 -3
  285. data/rust/host/src/platform_signin/pages.rs +20 -20
  286. data/rust/host/src/platform_signin/session.rs +8 -1
  287. data/rust/host/src/platform_signin/settings.rs +6 -6
  288. data/rust/host/src/platform_signin/strict_json.rs +12 -9
  289. data/rust/host/src/platform_signin/verify.rs +41 -41
  290. data/rust/host/src/platform_signin/wiring_support.rs +13 -18
  291. data/rust/host/src/presentation.rs +207 -63
  292. data/rust/host/src/presentation_write.rs +502 -187
  293. data/rust/host/src/query_step.rs +69 -18
  294. data/rust/host/src/rate_limit.rs +541 -142
  295. data/rust/host/src/reference_transform.rs +77 -25
  296. data/rust/host/src/reference_validate.rs +443 -104
  297. data/rust/host/src/resend.rs +190 -55
  298. data/rust/host/src/secrets.rs +116 -21
  299. data/rust/host/src/server.rs +483 -104
  300. data/rust/host/src/storage_shape.rs +88 -21
  301. data/rust/host/src/test_pg.rs +17 -7
  302. data/rust/host/src/ui_schema.rs +495 -207
  303. data/rust/host/src/wasm_runner.rs +21 -6
  304. data/rust/host/src/web/commerce.rs +101 -23
  305. data/rust/host/src/web/newsletter/cooldown.rs +10 -3
  306. data/rust/host/src/web/newsletter/template.rs +26 -7
  307. data/rust/host/src/web/newsletter/tests.rs +112 -23
  308. data/rust/host/src/web/newsletter.rs +209 -59
  309. data/rust/host/src/web/newsletter_send.rs +251 -79
  310. data/rust/host/src/web/registration_receipt/tests.rs +16 -4
  311. data/rust/host/src/web/registration_receipt.rs +100 -24
  312. data/rust/host/src/web/registrations.rs +1384 -332
  313. data/rust/host/src/web/resend_webhook.rs +138 -80
  314. data/rust/host/src/web.rs +2416 -508
  315. data/rust/lsp/Cargo.toml +36 -0
  316. data/rust/lsp/clippy.toml +4 -0
  317. data/rust/lsp/src/json.rs +29 -22
  318. data/rust/lsp/src/main.rs +81 -31
  319. data/rust/lsp/src/outline.rs +23 -7
  320. data/rust/lsp/src/rpc.rs +17 -4
  321. data/rust/parser/Cargo.toml +36 -0
  322. data/rust/parser/clippy.toml +4 -0
  323. data/rust/parser/src/build/closed_sets.rs +6 -1
  324. data/rust/parser/src/build/identity.rs +4 -3
  325. data/rust/parser/src/build/mod.rs +1 -1
  326. data/rust/parser/src/build/named_pattern.rs +4 -1
  327. data/rust/parser/src/build/naming.rs +2 -80
  328. data/rust/parser/src/build/pattern_subset.rs +6 -8
  329. data/rust/parser/src/build/query_derive.rs +3 -1
  330. data/rust/parser/src/build/query_inference.rs +14 -5
  331. data/rust/parser/src/build/read_model.rs +4 -1
  332. data/rust/parser/src/build/references.rs +4 -3
  333. data/rust/parser/src/canonical.rs +34 -15
  334. data/rust/parser/src/emit.rs +187 -105
  335. data/rust/parser/src/expr/ast_json.rs +134 -42
  336. data/rust/parser/src/expr/evaluator.rs +27 -9
  337. data/rust/parser/src/expr/mod.rs +54 -13
  338. data/rust/parser/src/expr/resolver.rs +299 -92
  339. data/rust/parser/src/ir.rs +35 -3
  340. data/rust/parser/src/keywords.rs +7168 -516
  341. data/rust/parser/src/lex.rs +11 -25
  342. data/rust/parser/src/lib.rs +1 -0
  343. data/rust/parser/src/main.rs +65 -6
  344. data/rust/parser/src/parse/adapter.rs +157 -0
  345. data/rust/parser/src/parse/aggregate.rs +19 -10
  346. data/rust/parser/src/parse/chapter.rs +16 -17
  347. data/rust/parser/src/parse/command.rs +35 -16
  348. data/rust/parser/src/parse/domain_port.rs +4 -7
  349. data/rust/parser/src/parse/entity.rs +21 -11
  350. data/rust/parser/src/parse/hecksagon.rs +10 -4
  351. data/rust/parser/src/parse/lifecycle.rs +9 -5
  352. data/rust/parser/src/parse/mod.rs +22 -91
  353. data/rust/parser/src/parse/needs.rs +21 -12
  354. data/rust/parser/src/parse/policy.rs +2 -2
  355. data/rust/parser/src/parse/process_manager.rs +9 -11
  356. data/rust/parser/src/parse/query.rs +8 -4
  357. data/rust/parser/src/parse/read_model.rs +57 -16
  358. data/rust/parser/src/parse/value_object.rs +7 -2
  359. data/rust/parser/src/parse/world.rs +322 -0
  360. data/rust/parser/src/ruby_value.rs +7 -6
  361. data/rust/src/exemplar/json.rs +4 -4
  362. data/rust/src/exemplar/mutations.rs +18 -0
  363. data/rust/src/exemplar/read_models.rs +1 -10
  364. data/rust/src/kernel/attribute_shapes/composite.rs +54 -14
  365. data/rust/src/kernel/attribute_shapes/list.rs +2 -2
  366. data/rust/src/kernel/attribute_shapes/mod.rs +8 -8
  367. data/rust/src/kernel/attribute_shapes/scalar.rs +2 -2
  368. data/rust/src/kernel/cli.rs +229 -112
  369. data/rust/src/kernel/dispatch.rs +323 -115
  370. data/rust/src/kernel/dispatch_guard_tests.rs +327 -65
  371. data/rust/src/kernel/expr.rs +203 -86
  372. data/rust/src/kernel/expression_operators/arithmetic.rs +138 -25
  373. data/rust/src/kernel/expression_operators/comparison.rs +87 -2
  374. data/rust/src/kernel/expression_operators/enumeration.rs +106 -22
  375. data/rust/src/kernel/expression_operators/logical.rs +9 -3
  376. data/rust/src/kernel/expression_operators/membership.rs +3 -1
  377. data/rust/src/kernel/expression_operators/mod.rs +34 -21
  378. data/rust/src/kernel/expression_operators/pattern_match.rs +46 -11
  379. data/rust/src/kernel/expression_operators/positional.rs +84 -30
  380. data/rust/src/kernel/expression_operators/presence.rs +52 -13
  381. data/rust/src/kernel/expression_operators/sign_test.rs +3 -1
  382. data/rust/src/kernel/expression_operators/sized.rs +24 -8
  383. data/rust/src/kernel/expression_operators/text.rs +120 -27
  384. data/rust/src/kernel/expression_operators/to_string.rs +10 -4
  385. data/rust/src/kernel/json.rs +230 -148
  386. data/rust/src/kernel/mod.rs +46 -33
  387. data/rust/src/kernel/named_query.rs +63 -15
  388. data/rust/src/kernel/needs.rs +112 -25
  389. data/rust/src/kernel/orchestrate.rs +980 -581
  390. data/rust/src/kernel/orchestrate_guard_tests.rs +538 -140
  391. data/rust/src/kernel/pattern.rs +107 -34
  392. data/rust/src/kernel/query_comparators.rs +118 -41
  393. data/rust/src/kernel/query_ordering.rs +13 -5
  394. data/rust/src/kernel/read_model.rs +479 -181
  395. data/rust/src/kernel/reference_lookup.rs +122 -26
  396. data/rust/src/kernel/repository.rs +293 -66
  397. data/rust/src/kernel/routing.rs +58 -42
  398. data/rust/src/kernel/ruby_float.rs +139 -0
  399. data/rust/src/kernel/vocab/aggregate_dispatch_order.rs +57 -57
  400. data/rust/src/kernel/vocab/entity_dispatch_order.rs +54 -54
  401. data/rust/src/kernel/vocab/field_hint.rs +19 -25
  402. data/rust/src/kernel/vocab/mod.rs +6 -6
  403. data/rust/src/kernel/vocab/query_comparator.rs +23 -24
  404. data/rust/src/kernel/vocab/refusal_template.rs +501 -297
  405. data/rust/src/lib.rs +50 -0
  406. data/rust/src/main.rs +4 -1
  407. data/rust/web/Cargo.toml +36 -0
  408. data/rust/web/clippy.toml +4 -0
  409. metadata +82 -15
  410. data/examples/pizzas/bluebook/hecksagon/mock_stripe_payment_adapter.rb +0 -36
  411. data/lib/hecks/adapters/driven/google_authentication.adapter +0 -3
  412. data/lib/hecks/adapters/driven/google_authentication.rb +0 -99
  413. data/lib/hecks/adapters/driven/mock_stripe_adapter.adapter +0 -3
  414. data/lib/hecks/adapters/driven/mock_stripe_adapter.rb +0 -32
  415. data/lib/hecks/ports/authentication.port +0 -6
  416. data/lib/hecks/ports/authentication.rb +0 -67
  417. data/lib/hecks/ports/persistence/plugins/era/translation/approval_file.rb +0 -255
  418. data/lib/hecks/ports/persistence/plugins/era/translation/audit/approval_digest.rb +0 -22
  419. data/rust/host/src/approval.rs +0 -652
@@ -1,7 +1,10 @@
1
1
  //! Google sign-in and stateless sessions: OAuth, ID-token verification
2
2
  //! against Google's real JWKS, and HMAC-signed session/account cookies.
3
3
 
4
+ use crate::b64;
5
+ use crate::crypto::{constant_time_eq, hex_encode};
4
6
  use crate::dispatch;
7
+ use crate::host::Host;
5
8
  use crate::journal;
6
9
  use crate::journal::LineageConfig;
7
10
  use crate::lambda_client::LambdaInvoker;
@@ -31,7 +34,8 @@ pub struct Session {
31
34
  }
32
35
 
33
36
  pub fn authorization_url(redirect_uri: &str, secret: &str) -> Result<String, String> {
34
- let client_id = std::env::var("GOOGLE_CLIENT_ID").map_err(|_| "GOOGLE_CLIENT_ID not set".to_string())?;
37
+ let client_id = crate::secrets::var("GOOGLE_CLIENT_ID")
38
+ .map_err(|_| "GOOGLE_CLIENT_ID not set".to_string())?;
35
39
  let payload = now_secs().to_string();
36
40
  let state = format!("{payload}.{}", sign(secret, &payload));
37
41
  Ok(format!(
@@ -55,11 +59,12 @@ pub fn verify_state(state: &str, secret: &str) -> Result<(), String> {
55
59
  }
56
60
 
57
61
  pub async fn verify(code: &str, redirect_uri: &str) -> Result<Claims, String> {
58
- let client_id = std::env::var("GOOGLE_CLIENT_ID").map_err(|_| "GOOGLE_CLIENT_ID not set".to_string())?;
59
- let client_secret =
60
- std::env::var("GOOGLE_CLIENT_SECRET").map_err(|_| "GOOGLE_CLIENT_SECRET not set".to_string())?;
62
+ let client_id = crate::secrets::var("GOOGLE_CLIENT_ID")
63
+ .map_err(|_| "GOOGLE_CLIENT_ID not set".to_string())?;
64
+ let client_secret = crate::secrets::var("GOOGLE_CLIENT_SECRET")
65
+ .map_err(|_| "GOOGLE_CLIENT_SECRET not set".to_string())?;
61
66
 
62
- let http = reqwest::Client::new();
67
+ let http = crate::http::client();
63
68
  let resp = http
64
69
  .post("https://oauth2.googleapis.com/token")
65
70
  .form(&[
@@ -77,7 +82,10 @@ pub async fn verify(code: &str, redirect_uri: &str) -> Result<Claims, String> {
77
82
  let body = resp.text().await.unwrap_or_default();
78
83
  return Err(format!("token exchange failed: {body}"));
79
84
  }
80
- let body: Value = resp.json().await.map_err(|e| format!("token response: {e}"))?;
85
+ let body: Value = resp
86
+ .json()
87
+ .await
88
+ .map_err(|e| format!("token response: {e}"))?;
81
89
  let id_token = body
82
90
  .get("id_token")
83
91
  .and_then(|v| v.as_str())
@@ -90,10 +98,13 @@ pub async fn verify(code: &str, redirect_uri: &str) -> Result<Claims, String> {
90
98
  // and missing Google's own key rotation would be worse than one extra
91
99
  // HTTPS round trip per login.
92
100
  async fn verify_id_token(id_token: &str, client_id: &str) -> Result<Claims, String> {
93
- let header = jsonwebtoken::decode_header(id_token).map_err(|e| format!("bad id_token header: {e}"))?;
94
- let kid = header.kid.ok_or_else(|| "id_token header has no kid".to_string())?;
101
+ let header =
102
+ jsonwebtoken::decode_header(id_token).map_err(|e| format!("bad id_token header: {e}"))?;
103
+ let kid = header
104
+ .kid
105
+ .ok_or_else(|| "id_token header has no kid".to_string())?;
95
106
 
96
- let http = reqwest::Client::new();
107
+ let http = crate::http::client();
97
108
  let jwks: Value = http
98
109
  .get("https://www.googleapis.com/oauth2/v3/certs")
99
110
  .send()
@@ -106,10 +117,19 @@ async fn verify_id_token(id_token: &str, client_id: &str) -> Result<Claims, Stri
106
117
  let key = jwks
107
118
  .get("keys")
108
119
  .and_then(|k| k.as_array())
109
- .and_then(|keys| keys.iter().find(|k| k.get("kid").and_then(|v| v.as_str()) == Some(kid.as_str())))
120
+ .and_then(|keys| {
121
+ keys.iter()
122
+ .find(|k| k.get("kid").and_then(|v| v.as_str()) == Some(kid.as_str()))
123
+ })
110
124
  .ok_or_else(|| format!("no JWKS key matches id_token's kid {kid:?}"))?;
111
- let n = key.get("n").and_then(|v| v.as_str()).ok_or_else(|| "JWKS key has no n".to_string())?;
112
- let e = key.get("e").and_then(|v| v.as_str()).ok_or_else(|| "JWKS key has no e".to_string())?;
125
+ let n = key
126
+ .get("n")
127
+ .and_then(|v| v.as_str())
128
+ .ok_or_else(|| "JWKS key has no n".to_string())?;
129
+ let e = key
130
+ .get("e")
131
+ .and_then(|v| v.as_str())
132
+ .ok_or_else(|| "JWKS key has no e".to_string())?;
113
133
  let decoding_key = jsonwebtoken::DecodingKey::from_rsa_components(n, e)
114
134
  .map_err(|err| format!("building decoding key from JWKS: {err}"))?;
115
135
 
@@ -122,13 +142,20 @@ async fn verify_id_token(id_token: &str, client_id: &str) -> Result<Claims, Stri
122
142
  let claims = token.claims;
123
143
 
124
144
  Ok(Claims {
125
- issuer: claims.get("iss").and_then(|v| v.as_str()).unwrap_or(ISSUER).to_string(),
145
+ issuer: claims
146
+ .get("iss")
147
+ .and_then(|v| v.as_str())
148
+ .unwrap_or(ISSUER)
149
+ .to_string(),
126
150
  subject: claims
127
151
  .get("sub")
128
152
  .and_then(|v| v.as_str())
129
153
  .ok_or_else(|| "id_token has no sub".to_string())?
130
154
  .to_string(),
131
- email: claims.get("email").and_then(|v| v.as_str()).map(|s| s.to_string()),
155
+ email: claims
156
+ .get("email")
157
+ .and_then(|v| v.as_str())
158
+ .map(std::string::ToString::to_string),
132
159
  email_verified: matches!(claims.get("email_verified"), Some(Value::Bool(true)))
133
160
  || claims.get("email_verified").and_then(|v| v.as_str()) == Some("true"),
134
161
  })
@@ -153,26 +180,30 @@ pub fn resolve_account_cookie(configured: Option<&str>) -> Result<String, String
153
180
  /// Reads `HECKS_SESSION_COOKIE`; an invalid value falls back to the
154
181
  /// default here, but `main` refuses it at boot so it can't go unnoticed.
155
182
  pub fn account_cookie_name() -> String {
156
- resolve_account_cookie(std::env::var("HECKS_SESSION_COOKIE").ok().as_deref()).unwrap_or_else(|_| DEFAULT_ACCOUNT_COOKIE.to_string())
183
+ resolve_account_cookie(std::env::var("HECKS_SESSION_COOKIE").ok().as_deref())
184
+ .unwrap_or_else(|_| DEFAULT_ACCOUNT_COOKIE.to_string())
157
185
  }
158
186
 
159
187
  /// A flat, HMAC-signed `email` + expiry claim, verified by /accounts/me
160
188
  /// and the SSO handoff — deliberately not a `Session` cookie.
161
189
  pub fn account_token(secret: &str, email: &str, ttl_secs: u64) -> String {
162
190
  let payload = json!({"email": email, "exp": now_secs() + ttl_secs});
163
- let encoded = base64_encode(payload.to_string().as_bytes());
191
+ let encoded = b64::url_encode(payload.to_string().as_bytes());
164
192
  format!("{encoded}.{}", sign(secret, &encoded))
165
193
  }
166
194
 
167
195
  pub fn verify_account_token(secret: &str, token: &str) -> Option<String> {
168
196
  let payload = verify_sig(secret, token)?;
169
- let bytes = base64_decode(&payload);
197
+ let bytes = b64::url_decode(&payload)?;
170
198
  let value: Value = serde_json::from_slice(&bytes).ok()?;
171
199
  let exp = value.get("exp")?.as_u64()?;
172
200
  if now_secs() > exp {
173
201
  return None;
174
202
  }
175
- value.get("email")?.as_str().map(|s| s.to_string())
203
+ value
204
+ .get("email")?
205
+ .as_str()
206
+ .map(std::string::ToString::to_string)
176
207
  }
177
208
 
178
209
  /// Verifies only for `purpose` (the signing key derives from it, so it
@@ -181,15 +212,18 @@ pub fn purpose_token(secret: &str, purpose: &str, claims: Value, ttl_secs: u64)
181
212
  let mut payload = claims;
182
213
  payload["purpose"] = json!(purpose);
183
214
  payload["exp"] = json!(now_secs() + ttl_secs);
184
- let encoded = base64_encode(payload.to_string().as_bytes());
185
- format!("{encoded}.{}", sign(&purpose_key(secret, purpose), &encoded))
215
+ let encoded = b64::url_encode(payload.to_string().as_bytes());
216
+ format!(
217
+ "{encoded}.{}",
218
+ sign(&purpose_key(secret, purpose), &encoded)
219
+ )
186
220
  }
187
221
 
188
222
  /// The claims of a token minted by `purpose_token` for the same `purpose`,
189
223
  /// or `None` when the signature, the purpose or the expiry does not hold.
190
224
  pub fn verify_purpose_token(secret: &str, purpose: &str, token: &str) -> Option<Value> {
191
225
  let payload = verify_sig(&purpose_key(secret, purpose), token)?;
192
- let claims: Value = serde_json::from_slice(&base64_decode(&payload)).ok()?;
226
+ let claims: Value = serde_json::from_slice(&b64::url_decode(&payload)?).ok()?;
193
227
  if claims.get("purpose")?.as_str()? != purpose || now_secs() > claims.get("exp")?.as_u64()? {
194
228
  return None;
195
229
  }
@@ -211,13 +245,13 @@ pub fn session_cookie(secret: &str, session: &Session) -> String {
211
245
  "role": session.role,
212
246
  "exp": now_secs() + SESSION_TTL_SECS,
213
247
  });
214
- let encoded = base64_encode(payload.to_string().as_bytes());
248
+ let encoded = b64::url_encode(payload.to_string().as_bytes());
215
249
  format!("{encoded}.{}", sign(secret, &encoded))
216
250
  }
217
251
 
218
252
  pub fn parse_session_cookie(secret: &str, cookie: &str) -> Option<Session> {
219
253
  let payload = verify_sig(secret, cookie)?;
220
- let bytes = base64_decode(&payload);
254
+ let bytes = b64::url_decode(&payload)?;
221
255
  let value: Value = serde_json::from_slice(&bytes).ok()?;
222
256
  // A cookie with no `exp`, or one in the past, is no session: a signed
223
257
  // payload alone would otherwise stay valid until the secret changed.
@@ -228,7 +262,10 @@ pub fn parse_session_cookie(secret: &str, cookie: &str) -> Option<Session> {
228
262
  identity_id: value.get("identity_id")?.as_str()?.to_string(),
229
263
  email: value.get("email")?.as_str()?.to_string(),
230
264
  name: value.get("name")?.as_str()?.to_string(),
231
- role: value.get("role").and_then(|v| v.as_str()).map(|s| s.to_string()),
265
+ role: value
266
+ .get("role")
267
+ .and_then(|v| v.as_str())
268
+ .map(std::string::ToString::to_string),
232
269
  })
233
270
  }
234
271
 
@@ -250,7 +287,11 @@ fn identity_id_from_state(state: &Value) -> Option<String> {
250
287
  .and_then(|v| v.as_str())
251
288
  .map(str::to_string)
252
289
  })
253
- .or_else(|| state.get("identity_id").and_then(|v| v.as_str().map(str::to_string)))
290
+ .or_else(|| {
291
+ state
292
+ .get("identity_id")
293
+ .and_then(|v| v.as_str().map(str::to_string))
294
+ })
254
295
  .or_else(|| {
255
296
  state
256
297
  .get("identity_id")
@@ -286,7 +327,11 @@ pub async fn resolve_identity_anywhere(
286
327
  Err(error) if flat_journal_is_absent(&error) => return Ok(None),
287
328
  Err(error) => return Err(error),
288
329
  };
289
- Ok(resolve_identity(read.get("instances").unwrap_or(&Value::Null), issuer, subject))
330
+ Ok(resolve_identity(
331
+ read.get("instances").unwrap_or(&Value::Null),
332
+ issuer,
333
+ subject,
334
+ ))
290
335
  }
291
336
 
292
337
  // A store with heads only has no flat journal to read; that is "no link there", not an outage.
@@ -327,7 +372,7 @@ pub async fn resolve_identity_from_head(
327
372
  return Ok(identity_id_from_state(&state));
328
373
  }
329
374
  Ok(None) => return Ok(None),
330
- Err(e) if e.code() == Some(&tokio_postgres::error::SqlState::UNDEFINED_TABLE) => continue,
375
+ Err(e) if e.code() == Some(&tokio_postgres::error::SqlState::UNDEFINED_TABLE) => {}
331
376
  Err(e) => return Err(e.into()),
332
377
  }
333
378
  }
@@ -337,7 +382,11 @@ pub async fn resolve_identity_from_head(
337
382
  // Member lives in the PostgresEra head, not `dispatch::read`'s journal
338
383
  // `instances` — reading `instances` for an already-linked member misses
339
384
  // it entirely, so this queries the head view directly instead.
340
- async fn member_row_by_email(client: &Mutex<Client>, domain_ir: &Value, email: &str) -> anyhow::Result<Option<Value>> {
385
+ async fn member_row_by_email(
386
+ client: &Mutex<Client>,
387
+ domain_ir: &Value,
388
+ email: &str,
389
+ ) -> anyhow::Result<Option<Value>> {
341
390
  let (_, storage_name) = membership_aggregate(domain_ir)?;
342
391
  // docs/decisions/0059: `head_view` needs the domain name too, the
343
392
  // same one `ir.rs`/`web.rs` already extract this way.
@@ -346,7 +395,10 @@ async fn member_row_by_email(client: &Mutex<Client>, domain_ir: &Value, email: &
346
395
  journal::read_lineage_head_by_id(&*guard, domain, &storage_name, email).await
347
396
  }
348
397
 
349
- async fn member_rows(client: &Mutex<Client>, domain_ir: &Value) -> anyhow::Result<Vec<(String, Value)>> {
398
+ async fn member_rows(
399
+ client: &Mutex<Client>,
400
+ domain_ir: &Value,
401
+ ) -> anyhow::Result<Vec<(String, Value)>> {
350
402
  let (_, storage_name) = membership_aggregate(domain_ir)?;
351
403
  let domain = domain_ir.get("name").and_then(|v| v.as_str()).unwrap_or("");
352
404
  let guard = client.lock().await;
@@ -366,7 +418,11 @@ fn membership_aggregate(domain_ir: &Value) -> anyhow::Result<(String, String)> {
366
418
  }
367
419
 
368
420
  fn membership_names(aggregate: &str) -> (String, String) {
369
- let bare = aggregate.rsplit("::").next().unwrap_or(aggregate).to_string();
421
+ let bare = aggregate
422
+ .rsplit("::")
423
+ .next()
424
+ .unwrap_or(aggregate)
425
+ .to_string();
370
426
  let storage_name = crate::journal::snake(&bare);
371
427
  (bare, storage_name)
372
428
  }
@@ -374,7 +430,13 @@ fn membership_names(aggregate: &str) -> (String, String) {
374
430
  // Ported from `Adapters::PostgresEra#append` -- the same journal-insert
375
431
  // + snapshot-upsert transaction every Member write uses. The advisory
376
432
  // lock guards a concurrent domain rename, not ordinal uniqueness.
377
- async fn append_member_state(client: &Mutex<Client>, config: &LineageConfig, domain_ir: &Value, id: &str, state: &Value) -> anyhow::Result<()> {
433
+ async fn append_member_state(
434
+ client: &Mutex<Client>,
435
+ config: &LineageConfig,
436
+ domain_ir: &Value,
437
+ id: &str,
438
+ state: &Value,
439
+ ) -> anyhow::Result<()> {
378
440
  let (aggregate_name, _) = membership_aggregate(domain_ir)?;
379
441
  let mut guard = client.lock().await;
380
442
  let txn = guard.transaction().await?;
@@ -388,7 +450,12 @@ async fn append_member_state(client: &Mutex<Client>, config: &LineageConfig, dom
388
450
  journal::append_lineage_mutation(
389
451
  &txn,
390
452
  config,
391
- &journal::Mutation { aggregate: &aggregate_name, id, operation: "save", state },
453
+ &journal::Mutation {
454
+ aggregate: &aggregate_name,
455
+ id,
456
+ operation: "save",
457
+ state,
458
+ },
392
459
  )
393
460
  .await?;
394
461
 
@@ -396,7 +463,11 @@ async fn append_member_state(client: &Mutex<Client>, config: &LineageConfig, dom
396
463
  Ok(())
397
464
  }
398
465
 
399
- pub async fn session_for_member_by_identity(client: &Mutex<Client>, domain_ir: &Value, identity_id: &str) -> anyhow::Result<Option<Session>> {
466
+ pub async fn session_for_member_by_identity(
467
+ client: &Mutex<Client>,
468
+ domain_ir: &Value,
469
+ identity_id: &str,
470
+ ) -> anyhow::Result<Option<Session>> {
400
471
  let (_, storage_name) = membership_aggregate(domain_ir)?;
401
472
  // docs/decisions/0059 — same domain-qualification as above.
402
473
  let domain = domain_ir.get("name").and_then(|v| v.as_str()).unwrap_or("");
@@ -420,7 +491,11 @@ pub async fn session_for_member_by_identity(client: &Mutex<Client>, domain_ir: &
420
491
  identity_id: identity_id.to_string(),
421
492
  email: state.get("email")?.get("value")?.as_str()?.to_string(),
422
493
  name: state.get("name")?.get("value")?.as_str()?.to_string(),
423
- role: state.get("role").and_then(|v| v.get("value")).and_then(|v| v.as_str()).map(|s| s.to_string()),
494
+ role: state
495
+ .get("role")
496
+ .and_then(|v| v.get("value"))
497
+ .and_then(|v| v.as_str())
498
+ .map(std::string::ToString::to_string),
424
499
  })
425
500
  }))
426
501
  }
@@ -438,15 +513,20 @@ pub async fn provision(
438
513
  subject: &str,
439
514
  invoker: &dyn LambdaInvoker,
440
515
  ) -> anyhow::Result<Option<Session>> {
441
- let member = match member_row_by_email(client, domain_ir, email).await? {
442
- Some(m) => m,
443
- None => return Ok(None),
516
+ let Some(member) = member_row_by_email(client, domain_ir, email).await? else {
517
+ return Ok(None);
444
518
  };
445
519
  if is_disabled(&member) {
446
520
  return Ok(None);
447
521
  }
448
- let role = member.get("role").and_then(|v| v.get("value")).and_then(|v| v.as_str());
449
- let already_linked = member.get("identity_id").and_then(|v| v.get("value")).is_some();
522
+ let role = member
523
+ .get("role")
524
+ .and_then(|v| v.get("value"))
525
+ .and_then(|v| v.as_str());
526
+ let already_linked = member
527
+ .get("identity_id")
528
+ .and_then(|v| v.get("value"))
529
+ .is_some();
450
530
  let (Some(role), false) = (role, already_linked) else {
451
531
  return Ok(None);
452
532
  };
@@ -470,49 +550,55 @@ pub async fn provision(
470
550
  // already_linked guard above never recognized as unlinked. Resolving
471
551
  // first makes a retry converge onto the earlier attempt's identity
472
552
  // instead of accumulating another orphan.
473
- let identity_id = match resolve_identity_from_head(client, domain_ir, issuer, subject).await? {
474
- Some(existing) => existing,
475
- None => {
476
- let identity_id = uuid::Uuid::new_v4().to_string();
477
-
478
- // `None` on every dispatch below: this is system-initiated
479
- // provisioning, not a caller-submitted command, so there is no
480
- // caller role to assert.
481
- let register = dispatch::handle(
482
- client, wasm_path, &identity.register,
483
- json!({"identity_id": {"value": identity_id}}), None, config, invoker,
484
- ).await?;
485
- if !register.accepted {
486
- anyhow::bail!("{} refused: {}", identity.register, register.result);
487
- }
488
-
489
- let link_external = dispatch::handle(
490
- client, wasm_path, &identity.link,
491
- json!({
492
- "identity": identity_id, "key": {"value": format!("{issuer}:{subject}")},
493
- "issuer": {"value": issuer}, "subject": {"value": subject},
494
- }), None, config, invoker,
495
- ).await?;
496
- if !link_external.accepted {
497
- anyhow::bail!("{} refused: {}", identity.link, link_external.result);
498
- }
553
+ let identity_id = if let Some(existing) =
554
+ resolve_identity_from_head(client, domain_ir, issuer, subject).await?
555
+ {
556
+ existing
557
+ } else {
558
+ let identity_id = uuid::Uuid::new_v4().to_string();
559
+
560
+ // `None` on every dispatch below: this is system-initiated
561
+ // provisioning, not a caller-submitted command, so there is no
562
+ // caller role to assert.
563
+ let register = dispatch::handle(
564
+ Host::new(client, wasm_path, config, invoker),
565
+ &identity.register,
566
+ json!({"identity_id": {"value": identity_id}}),
567
+ None,
568
+ )
569
+ .await?;
570
+ if !register.accepted {
571
+ anyhow::bail!("{} refused: {}", identity.register, register.result);
572
+ }
499
573
 
500
- identity_id
574
+ let link_external = dispatch::handle(
575
+ Host::new(client, wasm_path, config, invoker),
576
+ &identity.link,
577
+ json!({
578
+ "identity": identity_id, "key": {"value": format!("{issuer}:{subject}")},
579
+ "issuer": {"value": issuer}, "subject": {"value": subject},
580
+ }),
581
+ None,
582
+ )
583
+ .await?;
584
+ if !link_external.accepted {
585
+ anyhow::bail!("{} refused: {}", identity.link, link_external.result);
501
586
  }
587
+
588
+ identity_id
502
589
  };
503
590
 
504
591
  // The grant verb this domain's declared authorization provider names
505
592
  // (`ir::authorization_provider`), never a hard-coded chapter.
506
593
  let Some(provider) = crate::ir::authorization_provider(domain_ir) else {
507
- anyhow::bail!("this domain attaches no chapter that provides \"authorization\" — cannot grant a role");
594
+ anyhow::bail!(
595
+ "this domain attaches no chapter that provides \"authorization\" — cannot grant a role"
596
+ );
508
597
  };
509
- let assign_role = dispatch::handle(
510
- client, wasm_path, &provider.grant,
511
- json!({
598
+ let assign_role = dispatch::handle(Host::new(client, wasm_path, config, invoker), &provider.grant, json!({
512
599
  "actor_id": {"value": identity_id}, "role_name": {"value": role}, "scope": {"value": config.domain.clone()},
513
600
  "starts_at": {"value": httpdate_now()},
514
- }), None, config, invoker,
515
- ).await?;
601
+ }), None).await?;
516
602
  if !assign_role.accepted {
517
603
  anyhow::bail!("{} refused: {}", provider.grant, assign_role.result);
518
604
  }
@@ -524,8 +610,18 @@ pub async fn provision(
524
610
  new_state["identity_id"] = json!({"value": identity_id});
525
611
  append_member_state(client, config, domain_ir, email, &new_state).await?;
526
612
 
527
- let name = member.get("name").and_then(|v| v.get("value")).and_then(|v| v.as_str()).unwrap_or_default().to_string();
528
- Ok(Some(Session { identity_id, email: email.to_string(), name, role: Some(role.to_string()) }))
613
+ let name = member
614
+ .get("name")
615
+ .and_then(|v| v.get("value"))
616
+ .and_then(|v| v.as_str())
617
+ .unwrap_or_default()
618
+ .to_string();
619
+ Ok(Some(Session {
620
+ identity_id,
621
+ email: email.to_string(),
622
+ name,
623
+ role: Some(role.to_string()),
624
+ }))
529
625
  }
530
626
 
531
627
  pub async fn grant_access(
@@ -559,7 +655,10 @@ pub async fn admit_person(
559
655
  name: &str,
560
656
  ) -> anyhow::Result<bool> {
561
657
  let email = email.to_lowercase();
562
- let exists = member_rows(client, domain_ir).await?.into_iter().any(|(id, _)| id.to_lowercase() == email);
658
+ let exists = member_rows(client, domain_ir)
659
+ .await?
660
+ .into_iter()
661
+ .any(|(id, _)| id.to_lowercase() == email);
563
662
  if exists {
564
663
  return Ok(false);
565
664
  }
@@ -571,7 +670,10 @@ pub async fn admit_person(
571
670
  // The disabled flag sits beside the role rather than replacing it, so
572
671
  // enabling a person again restores exactly the role they held.
573
672
  fn is_disabled(state: &Value) -> bool {
574
- state.get("disabled").and_then(|v| v.as_bool()).unwrap_or(false)
673
+ state
674
+ .get("disabled")
675
+ .and_then(serde_json::Value::as_bool)
676
+ .unwrap_or(false)
575
677
  }
576
678
 
577
679
  // A deleted person's row is never erased (this is an append-only journal,
@@ -579,12 +681,18 @@ fn is_disabled(state: &Value) -> bool {
579
681
  // stops listing it. There is deliberately no undelete route: re-admitting
580
682
  // the same email starts a fresh row rather than reviving this one.
581
683
  fn is_deleted(state: &Value) -> bool {
582
- state.get("deleted").and_then(|v| v.as_bool()).unwrap_or(false)
684
+ state
685
+ .get("deleted")
686
+ .and_then(serde_json::Value::as_bool)
687
+ .unwrap_or(false)
583
688
  }
584
689
 
585
690
  // The role name a membership row holds, if any.
586
691
  fn role_of(state: &Value) -> Option<&str> {
587
- state.get("role").and_then(|v| v.get("value")).and_then(|v| v.as_str())
692
+ state
693
+ .get("role")
694
+ .and_then(|v| v.get("value"))
695
+ .and_then(|v| v.as_str())
588
696
  }
589
697
 
590
698
  /// The roles that pass the admin gate. "Owner" is a superset of "Admin" —
@@ -612,24 +720,41 @@ fn is_last_active_admin(rows: &[(String, Value)], state: &Value) -> bool {
612
720
 
613
721
  /// Whether the person with `email` (compared case-insensitively) is an
614
722
  /// active admin: granted the "Admin" or "Owner" role and not disabled.
615
- pub async fn caller_is_admin(client: &Mutex<Client>, domain_ir: &Value, email: &str) -> anyhow::Result<bool> {
723
+ pub async fn caller_is_admin(
724
+ client: &Mutex<Client>,
725
+ domain_ir: &Value,
726
+ email: &str,
727
+ ) -> anyhow::Result<bool> {
616
728
  let email = email.to_lowercase();
617
- Ok(member_rows(client, domain_ir).await?.iter().any(|(id, state)| id.to_lowercase() == email && is_active_admin(state)))
729
+ Ok(member_rows(client, domain_ir)
730
+ .await?
731
+ .iter()
732
+ .any(|(id, state)| id.to_lowercase() == email && is_active_admin(state)))
618
733
  }
619
734
 
620
735
  /// Whether `email` may hold a session right now: admitted, granted a role,
621
736
  /// and not disabled — a signature alone can't tell, since a cookie outlives a later disable.
622
- pub async fn has_access(client: &Mutex<Client>, domain_ir: &Value, email: &str) -> anyhow::Result<bool> {
737
+ pub async fn has_access(
738
+ client: &Mutex<Client>,
739
+ domain_ir: &Value,
740
+ email: &str,
741
+ ) -> anyhow::Result<bool> {
623
742
  let email = email.to_lowercase();
624
743
  Ok(member_rows(client, domain_ir)
625
744
  .await?
626
745
  .iter()
627
- .any(|(id, state)| id.to_lowercase() == email && role_of(state).is_some() && !is_disabled(state)))
746
+ .any(|(id, state)| {
747
+ id.to_lowercase() == email && role_of(state).is_some() && !is_disabled(state)
748
+ }))
628
749
  }
629
750
 
630
751
  /// The role `email` holds right now, or `None` if unknown, roleless, or
631
752
  /// disabled — reads the live membership head, never the session cookie.
632
- pub async fn active_role(client: &Mutex<Client>, domain_ir: &Value, email: &str) -> anyhow::Result<Option<String>> {
753
+ pub async fn active_role(
754
+ client: &Mutex<Client>,
755
+ domain_ir: &Value,
756
+ email: &str,
757
+ ) -> anyhow::Result<Option<String>> {
633
758
  let email = email.to_lowercase();
634
759
  Ok(member_rows(client, domain_ir)
635
760
  .await?
@@ -640,7 +765,11 @@ pub async fn active_role(client: &Mutex<Client>, domain_ir: &Value, email: &str)
640
765
 
641
766
  /// The Governance identity id linked to the member with `email`, read from
642
767
  /// the live membership head. `None` when the member has not signed in yet.
643
- pub async fn member_identity_id(client: &Mutex<Client>, domain_ir: &Value, email: &str) -> anyhow::Result<Option<String>> {
768
+ pub async fn member_identity_id(
769
+ client: &Mutex<Client>,
770
+ domain_ir: &Value,
771
+ email: &str,
772
+ ) -> anyhow::Result<Option<String>> {
644
773
  let email = email.to_lowercase();
645
774
  Ok(member_rows(client, domain_ir)
646
775
  .await?
@@ -651,7 +780,11 @@ pub async fn member_identity_id(client: &Mutex<Client>, domain_ir: &Value, email
651
780
 
652
781
  // The id the sign-in flow wrote onto a Member row, or `None` before then.
653
782
  fn linked_identity_id(state: &Value) -> Option<String> {
654
- state.get("identity_id").and_then(|v| v.get("value")).and_then(|v| v.as_str()).map(str::to_string)
783
+ state
784
+ .get("identity_id")
785
+ .and_then(|v| v.get("value"))
786
+ .and_then(|v| v.as_str())
787
+ .map(str::to_string)
655
788
  }
656
789
 
657
790
  /// Every admitted person as a JSON row: name, email, role, `linked`
@@ -664,7 +797,10 @@ pub async fn all_people(client: &Mutex<Client>, domain_ir: &Value) -> anyhow::Re
664
797
  .filter(|(_, state)| !is_deleted(state))
665
798
  .map(|(_, state)| {
666
799
  let role = role_of(&state);
667
- let linked = state.get("identity_id").and_then(|v| v.get("value")).is_some();
800
+ let linked = state
801
+ .get("identity_id")
802
+ .and_then(|v| v.get("value"))
803
+ .is_some();
668
804
  let disabled = is_disabled(&state);
669
805
  json!({
670
806
  "name": state.get("name").and_then(|v| v.get("value")),
@@ -710,10 +846,17 @@ pub async fn set_person_disabled(
710
846
 
711
847
  let mut guard = client.lock().await;
712
848
  let txn = guard.transaction().await?;
713
- txn.execute("SELECT pg_advisory_xact_lock(hashtext('hecks_ordinal:' || $1))", &[&config.domain]).await?;
849
+ txn.execute(
850
+ "SELECT pg_advisory_xact_lock(hashtext('hecks_ordinal:' || $1))",
851
+ &[&config.domain],
852
+ )
853
+ .await?;
714
854
  let rows = journal::read_lineage_head_all(&txn, domain, &storage_name).await?;
715
855
 
716
- if !rows.iter().any(|(id, state)| id.to_lowercase() == caller && is_active_admin(state)) {
856
+ if !rows
857
+ .iter()
858
+ .any(|(id, state)| id.to_lowercase() == caller && is_active_admin(state))
859
+ {
717
860
  return Ok(DisableOutcome::CallerNotAdmin);
718
861
  }
719
862
  if disable && caller == target {
@@ -738,7 +881,12 @@ pub async fn set_person_disabled(
738
881
  journal::append_lineage_mutation(
739
882
  &txn,
740
883
  config,
741
- &journal::Mutation { aggregate: &aggregate_name, id, operation: "save", state: &new_state },
884
+ &journal::Mutation {
885
+ aggregate: &aggregate_name,
886
+ id,
887
+ operation: "save",
888
+ state: &new_state,
889
+ },
742
890
  )
743
891
  .await?;
744
892
  txn.commit().await?;
@@ -775,10 +923,17 @@ pub async fn set_person_deleted(
775
923
 
776
924
  let mut guard = client.lock().await;
777
925
  let txn = guard.transaction().await?;
778
- txn.execute("SELECT pg_advisory_xact_lock(hashtext('hecks_ordinal:' || $1))", &[&config.domain]).await?;
926
+ txn.execute(
927
+ "SELECT pg_advisory_xact_lock(hashtext('hecks_ordinal:' || $1))",
928
+ &[&config.domain],
929
+ )
930
+ .await?;
779
931
  let rows = journal::read_lineage_head_all(&txn, domain, &storage_name).await?;
780
932
 
781
- if !rows.iter().any(|(id, state)| id.to_lowercase() == caller && is_active_admin(state)) {
933
+ if !rows
934
+ .iter()
935
+ .any(|(id, state)| id.to_lowercase() == caller && is_active_admin(state))
936
+ {
782
937
  return Ok(DeleteOutcome::CallerNotAdmin);
783
938
  }
784
939
  let Some((id, state)) = rows.iter().find(|(id, _)| id.to_lowercase() == target) else {
@@ -796,7 +951,12 @@ pub async fn set_person_deleted(
796
951
  journal::append_lineage_mutation(
797
952
  &txn,
798
953
  config,
799
- &journal::Mutation { aggregate: &aggregate_name, id, operation: "save", state: &new_state },
954
+ &journal::Mutation {
955
+ aggregate: &aggregate_name,
956
+ id,
957
+ operation: "save",
958
+ state: &new_state,
959
+ },
800
960
  )
801
961
  .await?;
802
962
  txn.commit().await?;
@@ -832,10 +992,17 @@ pub async fn set_person_role(
832
992
 
833
993
  let mut guard = client.lock().await;
834
994
  let txn = guard.transaction().await?;
835
- txn.execute("SELECT pg_advisory_xact_lock(hashtext('hecks_ordinal:' || $1))", &[&config.domain]).await?;
995
+ txn.execute(
996
+ "SELECT pg_advisory_xact_lock(hashtext('hecks_ordinal:' || $1))",
997
+ &[&config.domain],
998
+ )
999
+ .await?;
836
1000
  let rows = journal::read_lineage_head_all(&txn, domain, &storage_name).await?;
837
1001
 
838
- if !rows.iter().any(|(id, state)| id.to_lowercase() == caller && is_active_admin(state)) {
1002
+ if !rows
1003
+ .iter()
1004
+ .any(|(id, state)| id.to_lowercase() == caller && is_active_admin(state))
1005
+ {
839
1006
  return Ok(RoleOutcome::CallerNotAdmin);
840
1007
  }
841
1008
  let Some((id, state)) = rows.iter().find(|(id, _)| id.to_lowercase() == target) else {
@@ -853,7 +1020,12 @@ pub async fn set_person_role(
853
1020
  journal::append_lineage_mutation(
854
1021
  &txn,
855
1022
  config,
856
- &journal::Mutation { aggregate: &aggregate_name, id, operation: "save", state: &new_state },
1023
+ &journal::Mutation {
1024
+ aggregate: &aggregate_name,
1025
+ id,
1026
+ operation: "save",
1027
+ state: &new_state,
1028
+ },
857
1029
  )
858
1030
  .await?;
859
1031
  txn.commit().await?;
@@ -862,21 +1034,38 @@ pub async fn set_person_role(
862
1034
 
863
1035
  /// Whether `identity_id` holds a live Admin/Owner assignment in this
864
1036
  /// domain's authorization provider; `None` provider means never admin.
865
- pub fn holds_admin(instances: &Value, identity_id: &str, provider: Option<&crate::ir::AuthorizationProvider>) -> bool {
866
- let Some(provider) = provider else { return false };
1037
+ pub fn holds_admin(
1038
+ instances: &Value,
1039
+ identity_id: &str,
1040
+ provider: Option<&crate::ir::AuthorizationProvider>,
1041
+ ) -> bool {
1042
+ let Some(provider) = provider else {
1043
+ return false;
1044
+ };
867
1045
  let prefix = format!("{}#", provider.assignment_aggregate);
868
1046
  instances.as_object().is_some_and(|obj| {
869
1047
  obj.iter().any(|(key, state)| {
870
1048
  key.starts_with(&prefix)
871
- && state.get("actor_id").and_then(|v| v.get("value")).and_then(|v| v.as_str()) == Some(identity_id)
872
- && state.get("role_name").and_then(|v| v.get("value")).and_then(|v| v.as_str()).is_some_and(is_admin_role)
873
- && state.get("ends_at").map(|v| v.is_null()).unwrap_or(true)
1049
+ && state
1050
+ .get("actor_id")
1051
+ .and_then(|v| v.get("value"))
1052
+ .and_then(|v| v.as_str())
1053
+ == Some(identity_id)
1054
+ && state
1055
+ .get("role_name")
1056
+ .and_then(|v| v.get("value"))
1057
+ .and_then(|v| v.as_str())
1058
+ .is_some_and(is_admin_role)
1059
+ && state.get("ends_at").is_none_or(serde_json::Value::is_null)
874
1060
  })
875
1061
  })
876
1062
  }
877
1063
 
878
1064
  fn now_secs() -> u64 {
879
- SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs()
1065
+ SystemTime::now()
1066
+ .duration_since(UNIX_EPOCH)
1067
+ .unwrap_or_default()
1068
+ .as_secs()
880
1069
  }
881
1070
 
882
1071
  // `dispatch.rs`'s `occurred_at` stamping reuses this exact rendering
@@ -896,11 +1085,11 @@ pub(crate) fn httpdate_now() -> String {
896
1085
 
897
1086
  // Howard Hinnant's days-from-civil algorithm, inverted -- avoids
898
1087
  // pulling in a full date/time crate for one timestamp format.
899
- fn civil_from_days(z: i64) -> (i64, u32, u32) {
1088
+ const fn civil_from_days(z: i64) -> (i64, u32, u32) {
900
1089
  let z = z + 719_468;
901
1090
  let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
902
1091
  let doe = (z - era * 146_097) as u64;
903
- let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
1092
+ let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365;
904
1093
  let y = yoe as i64 + era * 400;
905
1094
  let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
906
1095
  let mp = (5 * doy + 2) / 153;
@@ -909,10 +1098,12 @@ fn civil_from_days(z: i64) -> (i64, u32, u32) {
909
1098
  (if m <= 2 { y + 1 } else { y }, m, d)
910
1099
  }
911
1100
 
1101
+ #[allow(clippy::expect_used)] // reason: HMAC accepts a key of any length, so construction cannot fail
912
1102
  fn sign(secret: &str, payload: &str) -> String {
913
1103
  use hmac::{Hmac, Mac};
914
1104
  use sha2::Sha256;
915
- let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes()).expect("HMAC accepts any key length");
1105
+ let mut mac =
1106
+ Hmac::<Sha256>::new_from_slice(secret.as_bytes()).expect("HMAC accepts any key length");
916
1107
  mac.update(payload.as_bytes());
917
1108
  hex_encode(&mac.finalize().into_bytes())
918
1109
  }
@@ -926,64 +1117,17 @@ fn verify_sig(secret: &str, token: &str) -> Option<String> {
926
1117
  }
927
1118
  }
928
1119
 
929
- /// Compares two signatures without stopping at the first differing byte, so how long a forged
930
- /// cookie takes to refuse does not tell the caller how many leading characters were right.
931
- fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
932
- a.len() == b.len() && a.iter().zip(b).fold(0u8, |diff, (x, y)| diff | (x ^ y)) == 0
933
- }
934
-
935
- fn hex_encode(bytes: &[u8]) -> String {
936
- bytes.iter().map(|b| format!("{b:02x}")).collect()
937
- }
938
-
939
- const B64: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
940
-
941
- fn base64_encode(input: &[u8]) -> String {
942
- let mut out = String::new();
943
- for chunk in input.chunks(3) {
944
- let b = [chunk[0], *chunk.get(1).unwrap_or(&0), *chunk.get(2).unwrap_or(&0)];
945
- let n = ((b[0] as u32) << 16) | ((b[1] as u32) << 8) | b[2] as u32;
946
- out.push(B64[(n >> 18) as usize & 0x3f] as char);
947
- out.push(B64[(n >> 12) as usize & 0x3f] as char);
948
- if chunk.len() > 1 {
949
- out.push(B64[(n >> 6) as usize & 0x3f] as char);
950
- }
951
- if chunk.len() > 2 {
952
- out.push(B64[n as usize & 0x3f] as char);
953
- }
954
- }
955
- out
956
- }
957
-
958
- fn base64_decode(input: &str) -> Vec<u8> {
959
- let mut table = [255u8; 256];
960
- for (i, &c) in B64.iter().enumerate() {
961
- table[c as usize] = i as u8;
962
- }
963
- let mut out = Vec::new();
964
- let mut buf = 0u32;
965
- let mut bits = 0u32;
966
- for c in input.bytes() {
967
- let v = table[c as usize];
968
- if v == 255 {
969
- continue;
970
- }
971
- buf = (buf << 6) | v as u32;
972
- bits += 6;
973
- if bits >= 8 {
974
- bits -= 8;
975
- out.push((buf >> bits) as u8);
976
- }
977
- }
978
- out
979
- }
980
-
981
1120
  pub(crate) fn urlencode(s: &str) -> String {
1121
+ use std::fmt::Write;
982
1122
  let mut out = String::new();
983
1123
  for b in s.bytes() {
984
1124
  match b {
985
- b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => out.push(b as char),
986
- _ => out.push_str(&format!("%{b:02X}")),
1125
+ b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
1126
+ out.push(b as char);
1127
+ }
1128
+ _ => {
1129
+ let _ = write!(out, "%{b:02X}"); // writing to a String cannot fail
1130
+ }
987
1131
  }
988
1132
  }
989
1133
  out
@@ -993,15 +1137,6 @@ pub(crate) fn urlencode(s: &str) -> String {
993
1137
  mod tests {
994
1138
  use super::*;
995
1139
 
996
- #[test]
997
- fn base64_round_trips_arbitrary_bytes() {
998
- for input in [b"".as_slice(), b"a", b"ab", b"abc", b"abcd", b"\x00\xff\x10hello world!"] {
999
- let encoded = base64_encode(input);
1000
- assert!(!encoded.contains('+') && !encoded.contains('/'), "must be URL-safe: {encoded}");
1001
- assert_eq!(base64_decode(&encoded), input, "round trip failed for {input:?}");
1002
- }
1003
- }
1004
-
1005
1140
  #[test]
1006
1141
  fn signed_token_verifies_with_the_right_secret_and_rejects_tampering() {
1007
1142
  let token = sign_test_token("s3cret", "hello");
@@ -1019,9 +1154,15 @@ mod tests {
1019
1154
  #[test]
1020
1155
  fn account_token_round_trips_and_rejects_tampering_or_the_wrong_secret() {
1021
1156
  let token = account_token("s3cret", "chris@example.com", 60);
1022
- assert_eq!(verify_account_token("s3cret", &token).as_deref(), Some("chris@example.com"));
1157
+ assert_eq!(
1158
+ verify_account_token("s3cret", &token).as_deref(),
1159
+ Some("chris@example.com")
1160
+ );
1023
1161
  assert_eq!(verify_account_token("wrong-secret", &token), None);
1024
- assert_eq!(verify_account_token("s3cret", "garbage.notasignature"), None);
1162
+ assert_eq!(
1163
+ verify_account_token("s3cret", "garbage.notasignature"),
1164
+ None
1165
+ );
1025
1166
  }
1026
1167
 
1027
1168
  #[test]
@@ -1039,22 +1180,41 @@ mod tests {
1039
1180
  #[test]
1040
1181
  fn account_token_matches_the_known_answer_vector() {
1041
1182
  let payload = r#"{"email":"chris@example.com","exp":4102444800}"#;
1042
- let encoded = base64_encode(payload.as_bytes());
1043
- assert_eq!(encoded, "eyJlbWFpbCI6ImNocmlzQGV4YW1wbGUuY29tIiwiZXhwIjo0MTAyNDQ0ODAwfQ");
1044
- assert_eq!(sign("s3cret", &encoded), "143ebe224b067f9744b509937358bb39a87ed30df06aaec594934b85a288cade");
1183
+ let encoded = b64::url_encode(payload.as_bytes());
1184
+ assert_eq!(
1185
+ encoded,
1186
+ "eyJlbWFpbCI6ImNocmlzQGV4YW1wbGUuY29tIiwiZXhwIjo0MTAyNDQ0ODAwfQ"
1187
+ );
1188
+ assert_eq!(
1189
+ sign("s3cret", &encoded),
1190
+ "143ebe224b067f9744b509937358bb39a87ed30df06aaec594934b85a288cade"
1191
+ );
1045
1192
  let token = sign_test_token("s3cret", &encoded);
1046
- assert_eq!(verify_account_token("s3cret", &token).as_deref(), Some("chris@example.com"));
1193
+ assert_eq!(
1194
+ verify_account_token("s3cret", &token).as_deref(),
1195
+ Some("chris@example.com")
1196
+ );
1047
1197
  // account_token writes the same payload shape: keys in sorted order, no spaces.
1048
1198
  let minted = account_token("s3cret", "chris@example.com", 60);
1049
1199
  let (minted_payload, _) = minted.rsplit_once('.').unwrap();
1050
- let claims: Value = serde_json::from_slice(&base64_decode(minted_payload)).unwrap();
1051
- assert_eq!(String::from_utf8(base64_decode(minted_payload)).unwrap(), format!(r#"{{"email":"chris@example.com","exp":{}}}"#, claims["exp"]));
1200
+ let claims: Value =
1201
+ serde_json::from_slice(&b64::url_decode(minted_payload).unwrap()).unwrap();
1202
+ assert_eq!(
1203
+ String::from_utf8(b64::url_decode(minted_payload).unwrap()).unwrap(),
1204
+ format!(r#"{{"email":"chris@example.com","exp":{}}}"#, claims["exp"])
1205
+ );
1052
1206
  }
1053
1207
 
1054
1208
  #[test]
1055
1209
  fn the_account_cookie_defaults_when_unset_or_empty() {
1056
- assert_eq!(resolve_account_cookie(None).unwrap(), DEFAULT_ACCOUNT_COOKIE);
1057
- assert_eq!(resolve_account_cookie(Some("")).unwrap(), DEFAULT_ACCOUNT_COOKIE);
1210
+ assert_eq!(
1211
+ resolve_account_cookie(None).unwrap(),
1212
+ DEFAULT_ACCOUNT_COOKIE
1213
+ );
1214
+ assert_eq!(
1215
+ resolve_account_cookie(Some("")).unwrap(),
1216
+ DEFAULT_ACCOUNT_COOKIE
1217
+ );
1058
1218
  }
1059
1219
 
1060
1220
  #[test]
@@ -1064,14 +1224,23 @@ mod tests {
1064
1224
 
1065
1225
  #[test]
1066
1226
  fn the_account_cookie_takes_a_configured_valid_name() {
1067
- assert_eq!(resolve_account_cookie(Some("hecks_session")).unwrap(), "hecks_session");
1068
- assert_eq!(resolve_account_cookie(Some("app-session.v2")).unwrap(), "app-session.v2");
1227
+ assert_eq!(
1228
+ resolve_account_cookie(Some("hecks_session")).unwrap(),
1229
+ "hecks_session"
1230
+ );
1231
+ assert_eq!(
1232
+ resolve_account_cookie(Some("app-session.v2")).unwrap(),
1233
+ "app-session.v2"
1234
+ );
1069
1235
  }
1070
1236
 
1071
1237
  #[test]
1072
1238
  fn the_account_cookie_refuses_a_name_that_could_break_the_set_cookie_header() {
1073
1239
  for bad in ["a;b", "a=b", "a b", "a\r\nSet-Cookie: x", "sessão"] {
1074
- assert!(resolve_account_cookie(Some(bad)).is_err(), "{bad:?} should be refused");
1240
+ assert!(
1241
+ resolve_account_cookie(Some(bad)).is_err(),
1242
+ "{bad:?} should be refused"
1243
+ );
1075
1244
  }
1076
1245
  }
1077
1246
 
@@ -1084,7 +1253,8 @@ mod tests {
1084
1253
  role: Some("Admin".to_string()),
1085
1254
  };
1086
1255
  let cookie = session_cookie("s3cret", &session);
1087
- let parsed = parse_session_cookie("s3cret", &cookie).expect("should parse with the right secret");
1256
+ let parsed =
1257
+ parse_session_cookie("s3cret", &cookie).expect("should parse with the right secret");
1088
1258
  assert_eq!(parsed.identity_id, "id-1");
1089
1259
  assert_eq!(parsed.email, "chris@example.com");
1090
1260
  assert_eq!(parsed.role.as_deref(), Some("Admin"));
@@ -1098,7 +1268,7 @@ mod tests {
1098
1268
  if let Some(exp) = exp {
1099
1269
  payload["exp"] = json!(exp);
1100
1270
  }
1101
- let encoded = base64_encode(payload.to_string().as_bytes());
1271
+ let encoded = b64::url_encode(payload.to_string().as_bytes());
1102
1272
  format!("{encoded}.{}", sign(secret, &encoded))
1103
1273
  }
1104
1274
 
@@ -1153,10 +1323,20 @@ mod tests {
1153
1323
  });
1154
1324
  let read = json!({ "instances": instances });
1155
1325
  assert_eq!(
1156
- resolve_identity(read.get("instances").unwrap_or(&Value::Null), "https://accounts.google.com", "111").as_deref(),
1326
+ resolve_identity(
1327
+ read.get("instances").unwrap_or(&Value::Null),
1328
+ "https://accounts.google.com",
1329
+ "111"
1330
+ )
1331
+ .as_deref(),
1157
1332
  Some("471ef402-8609-4fa4-9288-6372fc74650d")
1158
1333
  );
1159
- assert!(resolve_identity(read.get("instances").unwrap_or(&Value::Null), "https://accounts.google.com", "112").is_none());
1334
+ assert!(resolve_identity(
1335
+ read.get("instances").unwrap_or(&Value::Null),
1336
+ "https://accounts.google.com",
1337
+ "112"
1338
+ )
1339
+ .is_none());
1160
1340
  assert!(resolve_identity(&Value::Null, "https://accounts.google.com", "111").is_none());
1161
1341
  }
1162
1342
 
@@ -1198,22 +1378,49 @@ mod tests {
1198
1378
  let admin = json!({"role": {"value": "Admin"}});
1199
1379
  let disabled_admin = json!({"role": {"value": "Admin"}, "disabled": true});
1200
1380
  let member = json!({"role": {"value": "Member"}});
1201
- let rows = |states: &[&Value]| -> Vec<(String, Value)> { states.iter().enumerate().map(|(i, s)| (i.to_string(), (*s).clone())).collect() };
1381
+ let rows = |states: &[&Value]| -> Vec<(String, Value)> {
1382
+ states
1383
+ .iter()
1384
+ .enumerate()
1385
+ .map(|(i, s)| (i.to_string(), (*s).clone()))
1386
+ .collect()
1387
+ };
1202
1388
 
1203
- assert!(is_last_active_admin(&rows(&[&admin, &member, &disabled_admin]), &admin), "the only active admin");
1204
- assert!(!is_last_active_admin(&rows(&[&admin, &admin]), &admin), "another active admin remains");
1205
- assert!(!is_last_active_admin(&rows(&[&admin, &disabled_admin]), &disabled_admin), "a disabled admin is not an active one");
1206
- assert!(!is_last_active_admin(&rows(&[&admin, &member]), &member), "a non-admin never counts");
1389
+ assert!(
1390
+ is_last_active_admin(&rows(&[&admin, &member, &disabled_admin]), &admin),
1391
+ "the only active admin"
1392
+ );
1393
+ assert!(
1394
+ !is_last_active_admin(&rows(&[&admin, &admin]), &admin),
1395
+ "another active admin remains"
1396
+ );
1397
+ assert!(
1398
+ !is_last_active_admin(&rows(&[&admin, &disabled_admin]), &disabled_admin),
1399
+ "a disabled admin is not an active one"
1400
+ );
1401
+ assert!(
1402
+ !is_last_active_admin(&rows(&[&admin, &member]), &member),
1403
+ "a non-admin never counts"
1404
+ );
1207
1405
 
1208
1406
  // An Owner is an admin too: it counts toward, and is protected by, the guard.
1209
1407
  let owner = json!({"role": {"value": "Owner"}});
1210
- assert!(is_last_active_admin(&rows(&[&owner, &member]), &owner), "the only active admin may be an Owner");
1211
- assert!(!is_last_active_admin(&rows(&[&owner, &admin]), &admin), "an Owner keeps another admin from being the last");
1408
+ assert!(
1409
+ is_last_active_admin(&rows(&[&owner, &member]), &owner),
1410
+ "the only active admin may be an Owner"
1411
+ );
1412
+ assert!(
1413
+ !is_last_active_admin(&rows(&[&owner, &admin]), &admin),
1414
+ "an Owner keeps another admin from being the last"
1415
+ );
1212
1416
  }
1213
1417
 
1214
1418
  #[test]
1215
1419
  fn holds_admin_accepts_an_owner_assignment_and_refuses_other_roles() {
1216
- let provider = crate::ir::AuthorizationProvider { grant: "G".to_string(), assignment_aggregate: "Governance::RoleAssignment".to_string() };
1420
+ let provider = crate::ir::AuthorizationProvider {
1421
+ grant: "G".to_string(),
1422
+ assignment_aggregate: "Governance::RoleAssignment".to_string(),
1423
+ };
1217
1424
  let assignment = |role: &str| json!({"Governance::RoleAssignment#a": {"actor_id": {"value": "id-1"}, "role_name": {"value": role}, "ends_at": null}});
1218
1425
  assert!(holds_admin(&assignment("Admin"), "id-1", Some(&provider)));
1219
1426
  assert!(holds_admin(&assignment("Owner"), "id-1", Some(&provider)));
@@ -1223,8 +1430,14 @@ mod tests {
1223
1430
 
1224
1431
  #[test]
1225
1432
  fn membership_names_snakes_the_bare_aggregate() {
1226
- assert_eq!(membership_names("Member"), ("Member".to_string(), "member".to_string()));
1227
- assert_eq!(membership_names("Membership::Person"), ("Person".to_string(), "person".to_string()));
1433
+ assert_eq!(
1434
+ membership_names("Member"),
1435
+ ("Member".to_string(), "member".to_string())
1436
+ );
1437
+ assert_eq!(
1438
+ membership_names("Membership::Person"),
1439
+ ("Person".to_string(), "person".to_string())
1440
+ );
1228
1441
  }
1229
1442
 
1230
1443
  #[test]
@@ -1251,10 +1464,16 @@ mod tests {
1251
1464
  tokio::spawn(async move {
1252
1465
  let _ = conn.await;
1253
1466
  });
1254
- admin.batch_execute(&format!("DROP DATABASE IF EXISTS {name} WITH (FORCE)")).await.unwrap();
1255
- admin.batch_execute(&format!("CREATE DATABASE {name}")).await.unwrap();
1467
+ admin
1468
+ .batch_execute(&format!("DROP DATABASE IF EXISTS {name} WITH (FORCE)"))
1469
+ .await
1470
+ .unwrap();
1471
+ admin
1472
+ .batch_execute(&format!("CREATE DATABASE {name}"))
1473
+ .await
1474
+ .unwrap();
1256
1475
 
1257
- let (client, conn) = tokio_postgres::connect(&crate::test_pg::conninfo(&name), NoTls)
1476
+ let (client, conn) = tokio_postgres::connect(&crate::test_pg::conninfo(name), NoTls)
1258
1477
  .await
1259
1478
  .expect("connect to scratch db");
1260
1479
  tokio::spawn(async move {
@@ -1306,36 +1525,74 @@ mod tests {
1306
1525
  ).await.unwrap();
1307
1526
  }
1308
1527
 
1309
- let chris = member_row_by_email(&db, &domain_ir, "chris@example.com").await.unwrap().expect("should find chris");
1528
+ let chris = member_row_by_email(&db, &domain_ir, "chris@example.com")
1529
+ .await
1530
+ .unwrap()
1531
+ .expect("should find chris");
1310
1532
  assert_eq!(chris["email"]["value"], "chris@example.com");
1311
- assert!(member_row_by_email(&db, &domain_ir, "nobody@example.com").await.unwrap().is_none());
1533
+ assert!(member_row_by_email(&db, &domain_ir, "nobody@example.com")
1534
+ .await
1535
+ .unwrap()
1536
+ .is_none());
1312
1537
 
1313
- let session = session_for_member_by_identity(&db, &domain_ir, "id-1").await.unwrap().expect("should find the linked member");
1538
+ let session = session_for_member_by_identity(&db, &domain_ir, "id-1")
1539
+ .await
1540
+ .unwrap()
1541
+ .expect("should find the linked member");
1314
1542
  assert_eq!(session.email, "chris@example.com");
1315
1543
  assert_eq!(session.role.as_deref(), Some("Admin"));
1316
- assert!(session_for_member_by_identity(&db, &domain_ir, "id-nope").await.unwrap().is_none());
1544
+ assert!(session_for_member_by_identity(&db, &domain_ir, "id-nope")
1545
+ .await
1546
+ .unwrap()
1547
+ .is_none());
1317
1548
 
1318
1549
  let people = all_people(&db, &domain_ir).await.unwrap();
1319
1550
  assert_eq!(people.len(), 2);
1320
- let chris = people.iter().find(|p| p["email"] == "chris@example.com").unwrap();
1551
+ let chris = people
1552
+ .iter()
1553
+ .find(|p| p["email"] == "chris@example.com")
1554
+ .unwrap();
1321
1555
  assert_eq!(chris["linked"], true);
1322
1556
  assert_eq!(chris["granted"], true);
1323
1557
  assert_eq!(chris["identity_id"], "id-1");
1324
- let angie = people.iter().find(|p| p["email"] == "angie@example.com").unwrap();
1558
+ let angie = people
1559
+ .iter()
1560
+ .find(|p| p["email"] == "angie@example.com")
1561
+ .unwrap();
1325
1562
  assert_eq!(angie["linked"], false);
1326
1563
  assert_eq!(angie["granted"], false);
1327
1564
  assert!(angie["identity_id"].is_null());
1328
1565
 
1329
- assert_eq!(member_identity_id(&db, &domain_ir, "Chris@Example.com").await.unwrap().as_deref(), Some("id-1"));
1330
- assert_eq!(member_identity_id(&db, &domain_ir, "angie@example.com").await.unwrap(), None);
1331
- assert_eq!(member_identity_id(&db, &domain_ir, "nobody@example.com").await.unwrap(), None);
1566
+ assert_eq!(
1567
+ member_identity_id(&db, &domain_ir, "Chris@Example.com")
1568
+ .await
1569
+ .unwrap()
1570
+ .as_deref(),
1571
+ Some("id-1")
1572
+ );
1573
+ assert_eq!(
1574
+ member_identity_id(&db, &domain_ir, "angie@example.com")
1575
+ .await
1576
+ .unwrap(),
1577
+ None
1578
+ );
1579
+ assert_eq!(
1580
+ member_identity_id(&db, &domain_ir, "nobody@example.com")
1581
+ .await
1582
+ .unwrap(),
1583
+ None
1584
+ );
1332
1585
  }
1333
1586
 
1334
1587
  #[tokio::test]
1335
1588
  async fn append_member_state_writes_the_journal_and_advances_the_head_snapshot() {
1336
1589
  let domain_ir = member_domain_ir();
1337
1590
  let db = scratch_member_db("hecks_host_auth_test_append_member").await;
1338
- let config = LineageConfig { domain: "Acme".to_string(), era: Some(1), mirrored: None };
1591
+ let config = LineageConfig {
1592
+ domain: "Acme".to_string(),
1593
+ era: Some(1),
1594
+ mirrored: None,
1595
+ };
1339
1596
  {
1340
1597
  let guard = db.lock().await;
1341
1598
  // ordinal 0 -- below the journal's bigserial sequence (starts
@@ -1353,16 +1610,24 @@ mod tests {
1353
1610
 
1354
1611
  let granted = json!({"name": {"value": "Angie Chen"}, "email": {"value": "angie@example.com"},
1355
1612
  "role": {"value": "Admin"}, "identity_id": null});
1356
- append_member_state(&db, &config, &domain_ir, "angie@example.com", &granted).await.unwrap();
1613
+ append_member_state(&db, &config, &domain_ir, "angie@example.com", &granted)
1614
+ .await
1615
+ .unwrap();
1357
1616
 
1358
1617
  // The head view reflects the new state immediately.
1359
- let after = member_row_by_email(&db, &domain_ir, "angie@example.com").await.unwrap().expect("still there");
1618
+ let after = member_row_by_email(&db, &domain_ir, "angie@example.com")
1619
+ .await
1620
+ .unwrap()
1621
+ .expect("still there");
1360
1622
  assert_eq!(after["role"]["value"], "Admin");
1361
1623
 
1362
1624
  // A real journal row was appended -- not a raw update bypassing it.
1363
1625
  let guard = db.lock().await;
1364
1626
  let journal_rows = guard
1365
- .query("SELECT era, aggregate, aggregate_id, operation, state FROM hecks_journal_acme", &[])
1627
+ .query(
1628
+ "SELECT era, aggregate, aggregate_id, operation, state FROM hecks_journal_acme",
1629
+ &[],
1630
+ )
1366
1631
  .await
1367
1632
  .unwrap();
1368
1633
  assert_eq!(journal_rows.len(), 1);
@@ -1380,11 +1645,17 @@ mod tests {
1380
1645
 
1381
1646
  // The snapshot's own ordinal advanced past the seed row's.
1382
1647
  let ordinal: i64 = guard
1383
- .query_one("SELECT ordinal FROM acme_member_head_snapshot_1 WHERE id = $1", &[&"angie@example.com"])
1648
+ .query_one(
1649
+ "SELECT ordinal FROM acme_member_head_snapshot_1 WHERE id = $1",
1650
+ &[&"angie@example.com"],
1651
+ )
1384
1652
  .await
1385
1653
  .unwrap()
1386
1654
  .get(0);
1387
- assert!(ordinal > 0, "should have advanced past the seed row's ordinal 0");
1655
+ assert!(
1656
+ ordinal > 0,
1657
+ "should have advanced past the seed row's ordinal 0"
1658
+ );
1388
1659
  drop(guard);
1389
1660
 
1390
1661
  // The ordinal-guarded upsert (`WHERE ordinal < EXCLUDED.ordinal`)
@@ -1398,11 +1669,17 @@ mod tests {
1398
1669
  &[&"angie@example.com", &json!({"role": {"value": "SHOULD_NOT_APPLY"}})],
1399
1670
  ).await.unwrap();
1400
1671
  let state: Value = guard
1401
- .query_one("SELECT state FROM acme_member_head_snapshot_1 WHERE id = $1", &[&"angie@example.com"])
1672
+ .query_one(
1673
+ "SELECT state FROM acme_member_head_snapshot_1 WHERE id = $1",
1674
+ &[&"angie@example.com"],
1675
+ )
1402
1676
  .await
1403
1677
  .unwrap()
1404
1678
  .get(0);
1405
- assert_eq!(state["role"]["value"], "Admin", "a lower ordinal must never move the snapshot backward");
1679
+ assert_eq!(
1680
+ state["role"]["value"], "Admin",
1681
+ "a lower ordinal must never move the snapshot backward"
1682
+ );
1406
1683
  }
1407
1684
  }
1408
1685