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.
- checksums.yaml +4 -4
- data/examples/pizzas/adapters/fake_processor.rb +34 -0
- data/examples/pizzas/adapters/payment_signature.rb +66 -0
- data/examples/pizzas/adapters/payment_webhook.rb +87 -0
- data/examples/pizzas/bluebook/pizzas.behaviors +33 -0
- data/examples/pizzas/bluebook/pizzas.hecksagon +5 -3
- data/examples/pizzas/config.ru +22 -0
- data/lib/hecks/adapters/driven/prism.rb +0 -2
- data/lib/hecks/adapters/driven.rb +0 -2
- data/lib/hecks/adapters/driving/json.rb +1 -7
- data/lib/hecks/bluebook/dsl/bootstrap_table.rb +2 -2
- data/lib/hecks/bluebook/dsl/derivation_words.rb +58 -0
- data/lib/hecks/bluebook/dsl/translation_builder.rb +6 -37
- data/lib/hecks/bluebook/expression/ast_json.rb +0 -9
- data/lib/hecks/bluebook/hecksagon.rb +1 -1
- data/lib/hecks/bluebook/meta_validator/translation_judge/rules.rb +7 -10
- data/lib/hecks/bluebook/meta_validator/translation_judge.rb +4 -8
- data/lib/hecks/bluebook/meta_validator.rb +0 -11
- data/lib/hecks/bluebook/translation.rb +18 -10
- data/lib/hecks/bluebook/translation_expression/evaluator.rb +63 -0
- data/lib/hecks/bluebook/translation_expression/parser.rb +127 -0
- data/lib/hecks/bluebook/translation_expression.rb +39 -0
- data/lib/hecks/bluebook/translation_plan.rb +70 -0
- data/lib/hecks/bluebook.rb +2 -0
- data/lib/hecks/chapters.rb +3 -3
- data/lib/hecks/composer/adapters/claude_composer.adapter +3 -0
- data/lib/hecks/composer/adapters/claude_composer.rb +83 -0
- data/lib/hecks/composer/adapters/fake_agent.adapter +3 -0
- data/lib/hecks/composer/adapters/fake_agent.rb +55 -0
- data/lib/hecks/composer/adapters/jev.adapter +3 -0
- data/lib/hecks/composer/adapters/jev.rb +66 -0
- data/lib/hecks/composer/agent.rb +113 -0
- data/lib/hecks/composer/assistant.rb +110 -0
- data/lib/hecks/composer/behavior_run.rb +53 -0
- data/lib/hecks/composer/bluebook/composer.bluebook +178 -0
- data/lib/hecks/composer/bluebook/composer.hecksagon +16 -0
- data/lib/hecks/composer/board.rb +65 -0
- data/lib/hecks/composer/board_edit/command_text.rb +39 -0
- data/lib/hecks/composer/board_edit/source.rb +84 -0
- data/lib/hecks/composer/board_edit.rb +144 -0
- data/lib/hecks/composer/check.rb +89 -0
- data/lib/hecks/composer/domains.rb +81 -0
- data/lib/hecks/composer/jobs.rb +134 -0
- data/lib/hecks/composer/layout.rb +37 -0
- data/lib/hecks/composer/live.rb +150 -0
- data/lib/hecks/composer/outline.rb +48 -0
- data/lib/hecks/composer/page.html +989 -0
- data/lib/hecks/composer/panels.rb +142 -0
- data/lib/hecks/composer/parse.rb +83 -0
- data/lib/hecks/composer/plain.rb +28 -0
- data/lib/hecks/composer/ports/composer_agent.port +7 -0
- data/lib/hecks/composer/session.rb +141 -0
- data/lib/hecks/composer/toolbox.rb +166 -0
- data/lib/hecks/composer/web.rb +156 -0
- data/lib/hecks/composer/workbench.rb +51 -0
- data/lib/hecks/composer/workspace.rb +81 -0
- data/lib/hecks/composer.rb +17 -0
- data/lib/hecks/corpus/adapters.rb +22 -0
- data/lib/hecks/corpus/rust.rb +1 -1
- data/lib/hecks/corpus/worlds.rb +19 -0
- data/lib/hecks/corpus.rb +5 -0
- data/lib/hecks/forms/app/commands.rb +2 -3
- data/lib/hecks/forms/app/queries.rb +2 -3
- data/lib/hecks/forms/app/records.rb +17 -5
- data/lib/hecks/forms/command_form_renderer.rb +42 -16
- data/lib/hecks/forms/field_controls.rb +2 -2
- data/lib/hecks/forms/field_renderer.rb +1 -1
- data/lib/hecks/forms/field_shape.rb +36 -17
- data/lib/hecks/forms/index_renderer.rb +2 -1
- data/lib/hecks/forms/page.rb +1 -1
- data/lib/hecks/forms/params.rb +0 -13
- data/lib/hecks/forms/query_form_renderer.rb +7 -6
- data/lib/hecks/forms/record_renderer.rb +11 -7
- data/lib/hecks/forms/rule_words.rb +100 -0
- data/lib/hecks/forms/value_object_shape.rb +2 -1
- data/lib/hecks/fuzzing/bounded_exhaustive_expressions.rb +0 -3
- data/lib/hecks/fuzzing/isolated_boot/worlds.rb +3 -6
- data/lib/hecks/fuzzing/replay/filters.rb +2 -6
- data/lib/hecks/gate/stages.yml +25 -0
- data/lib/hecks/grammar/evolve/seeds.rb +0 -6
- data/lib/hecks/grammar/evolve.rb +0 -3
- data/lib/hecks/grammar/translation.bluebook +1 -1
- data/lib/hecks/hecks/adapters/codebase/composing.rb +56 -0
- data/lib/hecks/hecks/adapters/codebase/source_tree.rb +3 -1
- data/lib/hecks/hecks/adapters/journal_store/changes.rb +0 -26
- data/lib/hecks/hecks/adapters/journal_store/examination.rb +2 -23
- data/lib/hecks/hecks/adapters/journal_store/readings.rb +3 -11
- data/lib/hecks/hecks/adapters/journal_store.rb +0 -11
- data/lib/hecks/hecks/adapters/rehearsal/inquiry.rb +4 -11
- data/lib/hecks/hecks/adapters/rehearsal/memory_store.rb +0 -9
- data/lib/hecks/hecks/adapters/rehearsal/postgres_reading.rb +0 -4
- data/lib/hecks/hecks/codebase.behaviors +6 -0
- data/lib/hecks/hecks/codebase.bluebook +21 -0
- data/lib/hecks/hecks/composer.behaviors +68 -0
- data/lib/hecks/hecks/context_map.hecksagon +1 -1
- data/lib/hecks/hecks/custodian.behaviors +0 -6
- data/lib/hecks/hecks/custodian.bluebook +3 -80
- data/lib/hecks/hecks/hecks.hecksagon +2 -2
- data/lib/hecks/hecks/hecks.world +1 -1
- data/lib/hecks/hecks/rehearsal.bluebook +7 -13
- data/lib/hecks/kernel_capabilities/templates.rb +4 -4
- data/lib/hecks/kernel_capabilities.rb +1 -1
- data/lib/hecks/language/bluebook/vocabulary.bluebook +2 -3
- data/lib/hecks/language/translation/translation_aggregate.bluebook +21 -24
- data/lib/hecks/ports/access_control.rb +2 -2
- data/lib/hecks/ports/persistence/plugins/era/era_check.rb +3 -41
- data/lib/hecks/ports/persistence/plugins/era/lineage/coverage.rb +7 -7
- data/lib/hecks/ports/persistence/plugins/era/lineage/rewrite.rb +25 -2
- data/lib/hecks/ports/persistence/plugins/era/lineage/rules.rb +4 -4
- data/lib/hecks/ports/persistence/plugins/era/lineage.rb +17 -25
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/base_sql.rb +0 -12
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/domain_rename.rb +1 -1
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/era_store.rb +2 -22
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/provisioning.rb +0 -1
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage.rb +9 -3
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/minter.rb +3 -29
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/refusals.rb +2 -26
- data/lib/hecks/ports/persistence/plugins/era/translation/audit/layer_two.rb +6 -33
- data/lib/hecks/ports/persistence/plugins/era/translation/audit/unfed_report.rb +3 -3
- data/lib/hecks/ports/persistence/plugins/era/translation/audit.rb +2 -4
- data/lib/hecks/ports/persistence/plugins/era/translation/expression_sql.rb +52 -0
- data/lib/hecks/ports/persistence/plugins/era/translation/rule_compiler.rb +23 -29
- data/lib/hecks/ports/persistence/plugins/era/translation/scaffold.rb +1 -1
- data/lib/hecks/ports/persistence/plugins/era/translation.rb +0 -1
- data/lib/hecks/ports/persistence/plugins/era.rb +3 -6
- data/lib/hecks/ports.rb +0 -1
- data/lib/hecks/projection_files.rb +3 -1
- data/lib/hecks/projections/deploy/templates/lambda/nat_comment_oauth.tmpl +1 -1
- data/lib/hecks/projections/glossary/entries.rb +10 -4
- data/lib/hecks/projections/glossary/groups.rb +42 -0
- data/lib/hecks/projections/glossary/html/page.rb +7 -2
- data/lib/hecks/projections/glossary/html/parser.rb +1 -1
- data/lib/hecks/projections/glossary/html/sections.rb +31 -11
- data/lib/hecks/projections/glossary/html.rb +1 -2
- data/lib/hecks/projections/glossary/markdown.rb +31 -28
- data/lib/hecks/projections/glossary/mermaid.rb +3 -0
- data/lib/hecks/projections/glossary/page.css +143 -153
- data/lib/hecks/projections/glossary/page.js +63 -3
- data/lib/hecks/projections/glossary/sentences/actions.rb +88 -0
- data/lib/hecks/projections/glossary/sentences/paths.rb +77 -0
- data/lib/hecks/projections/glossary/sentences/queries.rb +90 -0
- data/lib/hecks/projections/glossary/sentences/value_objects.rb +32 -5
- data/lib/hecks/projections/glossary/sentences.rb +13 -3
- data/lib/hecks/projections/glossary/slugs.rb +4 -1
- data/lib/hecks/projections/glossary.rb +2 -1
- data/lib/hecks/projections/plain_english.rb +97 -0
- data/lib/hecks/projections/rust_vocabulary/arguments.rb +2 -2
- data/lib/hecks/projections/rust_vocabulary/enum_body.rb +20 -25
- data/lib/hecks/projections/rust_vocabulary/tests.rb +2 -2
- data/lib/hecks/projections/rust_vocabulary.rb +14 -6
- data/lib/hecks/projections/site/cms_editor/color.rb +0 -6
- data/lib/hecks/projections/statements/actions.rb +99 -0
- data/lib/hecks/projections/statements/relationships.rb +86 -0
- data/lib/hecks/projections/statements.rb +15 -24
- data/lib/hecks/projector/exporter/adapters.rb +112 -0
- data/lib/hecks/projector/exporter/translations.rb +18 -35
- data/lib/hecks/projector/exporter/worlds.rb +103 -0
- data/lib/hecks/projector/exporter.rb +5 -1
- data/lib/hecks/quality_control/adapters/agent.rb +6 -7
- data/lib/hecks/quality_control/adapters/agent_profile.rb +16 -114
- data/lib/hecks/quality_control/cli/qa_mine_combinations.rb +1 -1
- data/lib/hecks/runtime/loader/boot_steps.rb +0 -20
- data/lib/hecks/runtime/outbox.rb +2 -9
- data/lib/hecks/runtime/port_operation_interpreter.rb +1 -9
- data/lib/hecks/runtime/remote_dispatcher.rb +2 -2
- data/lib/hecks/runtime/value.rb +13 -0
- data/lib/hecks/rust_build/project_rust/target_ir.rb +23 -4
- data/lib/hecks/rust_build/wasm.rb +0 -12
- data/lib/hecks/rust_build/write_if_changed.rb +12 -12
- data/lib/hecks/rust_format.rb +77 -0
- data/lib/hecks/tools/fuzz_sweep/findings.rb +3 -9
- data/lib/hecks/tools/fuzz_sweep.rb +1 -3
- data/lib/hecks/tools/translation_audit/verdicts.rb +3 -28
- data/lib/hecks/tools/translation_audit.rb +8 -10
- data/lib/hecks/tools/translation_scaffold/reporting.rb +1 -1
- data/lib/hecks/version.rb +1 -1
- data/lib/hecks/vocabulary.rb +2 -1
- data/lib/hecks.rb +3 -0
- data/prebuilt/{a883a7e568667aa5ac904c82daff356bdf9e996252a36af6def4a4a43efb967e.marshal → 02c09c73b272c1fb5a9ae6d87bb5e5e529208e00f6b4b597dc2c0fd70a9c0964.marshal} +0 -0
- data/prebuilt/{1a6f596be124228be93eba1d86dbc9d2933190fa389b284331241cb9f27115a0.marshal → bb84a835cf6a15136bad5ca03aa5bf93a5b92f5f72444618d3ef2412b987fb32.marshal} +0 -0
- data/prebuilt/{verdicts-872993b1aa26d73fe48f28251374b8585fed145d02aab42be0f00de386b6ff80.json → verdicts-f1f10da14c8e8f01b4c9b7e3e2d74be8cd2bf4676364ed17fa16975d2a076d75.json} +1 -1
- data/rust/Cargo.toml +40 -0
- data/rust/build/Cargo.toml +36 -0
- data/rust/build/clippy.toml +4 -0
- data/rust/build/src/adapters_pass.rs +196 -0
- data/rust/build/src/binding_pass.rs +54 -13
- data/rust/build/src/build_artifact.rs +23 -5
- data/rust/build/src/cargo_sync.rs +69 -26
- data/rust/build/src/lineage_pass.rs +101 -87
- data/rust/build/src/main.rs +11 -2
- data/rust/build/src/pipeline.rs +80 -19
- data/rust/build/src/reserved_names.rs +6 -50
- data/rust/build/src/resolve.rs +82 -35
- data/rust/build/src/rustfmt.rs +100 -0
- data/rust/build/src/seams_pass.rs +85 -16
- data/rust/build/src/subprocess.rs +30 -6
- data/rust/build/src/tmp.rs +5 -2
- data/rust/build/src/translation_pass.rs +72 -22
- data/rust/build/src/world_pass.rs +109 -0
- data/rust/clippy.toml +4 -0
- data/rust/codegen/Cargo.toml +36 -0
- data/rust/codegen/clippy.toml +4 -0
- data/rust/codegen/src/asks.rs +40 -14
- data/rust/codegen/src/attr.rs +2 -2
- data/rust/codegen/src/bridging.rs +676 -117
- data/rust/codegen/src/commands.rs +979 -373
- data/rust/codegen/src/constraints.rs +88 -25
- data/rust/codegen/src/dependency_planning.rs +128 -34
- data/rust/codegen/src/domain_generator.rs +300 -170
- data/rust/codegen/src/exemplar.rs +87 -32
- data/rust/codegen/src/expr_emitter.rs +54 -6
- data/rust/codegen/src/fielded.rs +193 -41
- data/rust/codegen/src/hecks_naming.rs +124 -16
- data/rust/codegen/src/hecks_naming_tests.rs +81 -0
- data/rust/codegen/src/json.rs +80 -44
- data/rust/codegen/src/json_codec.rs +585 -149
- data/rust/codegen/src/literal.rs +104 -138
- data/rust/codegen/src/main.rs +145 -52
- data/rust/codegen/src/manifest.rs +76 -14
- data/rust/codegen/src/mutations.rs +1212 -239
- data/rust/codegen/src/naming.rs +111 -60
- data/rust/codegen/src/optional_pass.rs +90 -28
- data/rust/codegen/src/ports.rs +5 -4
- data/rust/codegen/src/prelude.rs +139 -49
- data/rust/codegen/src/queries.rs +601 -226
- data/rust/codegen/src/reactions.rs +268 -74
- data/rust/codegen/src/read_models.rs +606 -205
- data/rust/codegen/src/reference_specs.rs +16 -3
- data/rust/codegen/src/registry.rs +128 -79
- data/rust/codegen/src/reserved_names.rs +6 -50
- data/rust/codegen/src/shared.rs +71 -22
- data/rust/codegen/src/sidecars.rs +15 -5
- data/rust/codegen/src/skip_reason.rs +8 -2
- data/rust/codegen/src/types.rs +306 -108
- data/rust/host/Cargo.lock +1 -0
- data/rust/host/Cargo.toml +40 -4
- data/rust/host/HECKS_RELEASE +1 -1
- data/rust/host/clippy.toml +4 -0
- data/rust/host/src/api.rs +698 -218
- data/rust/host/src/auth.rs +491 -214
- data/rust/host/src/b64.rs +91 -0
- data/rust/host/src/bin/expr_harness.rs +6 -2
- data/rust/host/src/bin/lineage_harness.rs +47 -11
- data/rust/host/src/bin/mint_harness.rs +70 -37
- data/rust/host/src/bin/validate_harness.rs +16 -8
- data/rust/host/src/boot.rs +144 -68
- data/rust/host/src/boundary_fuzz/api.rs +130 -32
- data/rust/host/src/boundary_fuzz/auth.rs +108 -23
- data/rust/host/src/boundary_fuzz/expr_json.rs +85 -11
- data/rust/host/src/boundary_fuzz/ir.rs +164 -12
- data/rust/host/src/boundary_fuzz/server.rs +283 -36
- data/rust/host/src/boundary_fuzz/web.rs +56 -8
- data/rust/host/src/checkout.rs +78 -32
- data/rust/host/src/clock.rs +8 -0
- data/rust/host/src/commerce_ir.rs +333 -95
- data/rust/host/src/crypto.rs +33 -0
- data/rust/host/src/dispatch.rs +990 -331
- data/rust/host/src/expr_json.rs +1021 -273
- data/rust/host/src/extension.rs +24 -4
- data/rust/host/src/field_hints.rs +11 -7
- data/rust/host/src/fuzz_support.rs +85 -13
- data/rust/host/src/host.rs +100 -0
- data/rust/host/src/http.rs +22 -0
- data/rust/host/src/ir.rs +326 -16
- data/rust/host/src/journal.rs +277 -176
- data/rust/host/src/lambda_client.rs +219 -53
- data/rust/host/src/lib.rs +16 -4
- data/rust/host/src/log.rs +62 -11
- data/rust/host/src/mint.rs +1153 -326
- data/rust/host/src/needs.rs +185 -44
- data/rust/host/src/payments/keystore.rs +197 -45
- data/rust/host/src/payments/roles.rs +39 -7
- data/rust/host/src/payments/tests.rs +1427 -282
- data/rust/host/src/payments.rs +391 -112
- data/rust/host/src/platform_signin/attack_tests.rs +5 -5
- data/rust/host/src/platform_signin/config.rs +11 -11
- data/rust/host/src/platform_signin/cookie.rs +2 -2
- data/rust/host/src/platform_signin/extension.rs +2 -1
- data/rust/host/src/platform_signin/handler.rs +9 -8
- data/rust/host/src/platform_signin/jti.rs +4 -2
- data/rust/host/src/platform_signin/jwks.rs +2 -1
- data/rust/host/src/platform_signin/jws.rs +2 -37
- data/rust/host/src/platform_signin/known_answer_tests.rs +5 -1
- data/rust/host/src/platform_signin/mode_tests.rs +5 -3
- data/rust/host/src/platform_signin/pages.rs +20 -20
- data/rust/host/src/platform_signin/session.rs +8 -1
- data/rust/host/src/platform_signin/settings.rs +6 -6
- data/rust/host/src/platform_signin/strict_json.rs +12 -9
- data/rust/host/src/platform_signin/verify.rs +41 -41
- data/rust/host/src/platform_signin/wiring_support.rs +13 -18
- data/rust/host/src/presentation.rs +207 -63
- data/rust/host/src/presentation_write.rs +502 -187
- data/rust/host/src/query_step.rs +69 -18
- data/rust/host/src/rate_limit.rs +541 -142
- data/rust/host/src/reference_transform.rs +77 -25
- data/rust/host/src/reference_validate.rs +443 -104
- data/rust/host/src/resend.rs +190 -55
- data/rust/host/src/secrets.rs +116 -21
- data/rust/host/src/server.rs +483 -104
- data/rust/host/src/storage_shape.rs +88 -21
- data/rust/host/src/test_pg.rs +17 -7
- data/rust/host/src/ui_schema.rs +495 -207
- data/rust/host/src/wasm_runner.rs +21 -6
- data/rust/host/src/web/commerce.rs +101 -23
- data/rust/host/src/web/newsletter/cooldown.rs +10 -3
- data/rust/host/src/web/newsletter/template.rs +26 -7
- data/rust/host/src/web/newsletter/tests.rs +112 -23
- data/rust/host/src/web/newsletter.rs +209 -59
- data/rust/host/src/web/newsletter_send.rs +251 -79
- data/rust/host/src/web/registration_receipt/tests.rs +16 -4
- data/rust/host/src/web/registration_receipt.rs +100 -24
- data/rust/host/src/web/registrations.rs +1384 -332
- data/rust/host/src/web/resend_webhook.rs +138 -80
- data/rust/host/src/web.rs +2416 -508
- data/rust/lsp/Cargo.toml +36 -0
- data/rust/lsp/clippy.toml +4 -0
- data/rust/lsp/src/json.rs +29 -22
- data/rust/lsp/src/main.rs +81 -31
- data/rust/lsp/src/outline.rs +23 -7
- data/rust/lsp/src/rpc.rs +17 -4
- data/rust/parser/Cargo.toml +36 -0
- data/rust/parser/clippy.toml +4 -0
- data/rust/parser/src/build/closed_sets.rs +6 -1
- data/rust/parser/src/build/identity.rs +4 -3
- data/rust/parser/src/build/mod.rs +1 -1
- data/rust/parser/src/build/named_pattern.rs +4 -1
- data/rust/parser/src/build/naming.rs +2 -80
- data/rust/parser/src/build/pattern_subset.rs +6 -8
- data/rust/parser/src/build/query_derive.rs +3 -1
- data/rust/parser/src/build/query_inference.rs +14 -5
- data/rust/parser/src/build/read_model.rs +4 -1
- data/rust/parser/src/build/references.rs +4 -3
- data/rust/parser/src/canonical.rs +34 -15
- data/rust/parser/src/emit.rs +187 -105
- data/rust/parser/src/expr/ast_json.rs +134 -42
- data/rust/parser/src/expr/evaluator.rs +27 -9
- data/rust/parser/src/expr/mod.rs +54 -13
- data/rust/parser/src/expr/resolver.rs +299 -92
- data/rust/parser/src/ir.rs +35 -3
- data/rust/parser/src/keywords.rs +7168 -516
- data/rust/parser/src/lex.rs +11 -25
- data/rust/parser/src/lib.rs +1 -0
- data/rust/parser/src/main.rs +65 -6
- data/rust/parser/src/parse/adapter.rs +157 -0
- data/rust/parser/src/parse/aggregate.rs +19 -10
- data/rust/parser/src/parse/chapter.rs +16 -17
- data/rust/parser/src/parse/command.rs +35 -16
- data/rust/parser/src/parse/domain_port.rs +4 -7
- data/rust/parser/src/parse/entity.rs +21 -11
- data/rust/parser/src/parse/hecksagon.rs +10 -4
- data/rust/parser/src/parse/lifecycle.rs +9 -5
- data/rust/parser/src/parse/mod.rs +22 -91
- data/rust/parser/src/parse/needs.rs +21 -12
- data/rust/parser/src/parse/policy.rs +2 -2
- data/rust/parser/src/parse/process_manager.rs +9 -11
- data/rust/parser/src/parse/query.rs +8 -4
- data/rust/parser/src/parse/read_model.rs +57 -16
- data/rust/parser/src/parse/value_object.rs +7 -2
- data/rust/parser/src/parse/world.rs +322 -0
- data/rust/parser/src/ruby_value.rs +7 -6
- data/rust/src/exemplar/json.rs +4 -4
- data/rust/src/exemplar/mutations.rs +18 -0
- data/rust/src/exemplar/read_models.rs +1 -10
- data/rust/src/kernel/attribute_shapes/composite.rs +54 -14
- data/rust/src/kernel/attribute_shapes/list.rs +2 -2
- data/rust/src/kernel/attribute_shapes/mod.rs +8 -8
- data/rust/src/kernel/attribute_shapes/scalar.rs +2 -2
- data/rust/src/kernel/cli.rs +229 -112
- data/rust/src/kernel/dispatch.rs +323 -115
- data/rust/src/kernel/dispatch_guard_tests.rs +327 -65
- data/rust/src/kernel/expr.rs +203 -86
- data/rust/src/kernel/expression_operators/arithmetic.rs +138 -25
- data/rust/src/kernel/expression_operators/comparison.rs +87 -2
- data/rust/src/kernel/expression_operators/enumeration.rs +106 -22
- data/rust/src/kernel/expression_operators/logical.rs +9 -3
- data/rust/src/kernel/expression_operators/membership.rs +3 -1
- data/rust/src/kernel/expression_operators/mod.rs +34 -21
- data/rust/src/kernel/expression_operators/pattern_match.rs +46 -11
- data/rust/src/kernel/expression_operators/positional.rs +84 -30
- data/rust/src/kernel/expression_operators/presence.rs +52 -13
- data/rust/src/kernel/expression_operators/sign_test.rs +3 -1
- data/rust/src/kernel/expression_operators/sized.rs +24 -8
- data/rust/src/kernel/expression_operators/text.rs +120 -27
- data/rust/src/kernel/expression_operators/to_string.rs +10 -4
- data/rust/src/kernel/json.rs +230 -148
- data/rust/src/kernel/mod.rs +46 -33
- data/rust/src/kernel/named_query.rs +63 -15
- data/rust/src/kernel/needs.rs +112 -25
- data/rust/src/kernel/orchestrate.rs +980 -581
- data/rust/src/kernel/orchestrate_guard_tests.rs +538 -140
- data/rust/src/kernel/pattern.rs +107 -34
- data/rust/src/kernel/query_comparators.rs +118 -41
- data/rust/src/kernel/query_ordering.rs +13 -5
- data/rust/src/kernel/read_model.rs +479 -181
- data/rust/src/kernel/reference_lookup.rs +122 -26
- data/rust/src/kernel/repository.rs +293 -66
- data/rust/src/kernel/routing.rs +58 -42
- data/rust/src/kernel/ruby_float.rs +139 -0
- data/rust/src/kernel/vocab/aggregate_dispatch_order.rs +57 -57
- data/rust/src/kernel/vocab/entity_dispatch_order.rs +54 -54
- data/rust/src/kernel/vocab/field_hint.rs +19 -25
- data/rust/src/kernel/vocab/mod.rs +6 -6
- data/rust/src/kernel/vocab/query_comparator.rs +23 -24
- data/rust/src/kernel/vocab/refusal_template.rs +501 -297
- data/rust/src/lib.rs +50 -0
- data/rust/src/main.rs +4 -1
- data/rust/web/Cargo.toml +36 -0
- data/rust/web/clippy.toml +4 -0
- metadata +82 -15
- data/examples/pizzas/bluebook/hecksagon/mock_stripe_payment_adapter.rb +0 -36
- data/lib/hecks/adapters/driven/google_authentication.adapter +0 -3
- data/lib/hecks/adapters/driven/google_authentication.rb +0 -99
- data/lib/hecks/adapters/driven/mock_stripe_adapter.adapter +0 -3
- data/lib/hecks/adapters/driven/mock_stripe_adapter.rb +0 -32
- data/lib/hecks/ports/authentication.port +0 -6
- data/lib/hecks/ports/authentication.rb +0 -67
- data/lib/hecks/ports/persistence/plugins/era/translation/approval_file.rb +0 -255
- data/lib/hecks/ports/persistence/plugins/era/translation/audit/approval_digest.rb +0 -22
- data/rust/host/src/approval.rs +0 -652
data/rust/host/src/auth.rs
CHANGED
|
@@ -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 =
|
|
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 =
|
|
59
|
-
|
|
60
|
-
|
|
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 =
|
|
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
|
|
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 =
|
|
94
|
-
|
|
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 =
|
|
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|
|
|
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
|
|
112
|
-
|
|
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
|
|
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
|
|
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())
|
|
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 =
|
|
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 =
|
|
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
|
|
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 =
|
|
185
|
-
format!(
|
|
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(&
|
|
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 =
|
|
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 =
|
|
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
|
|
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(||
|
|
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(
|
|
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) =>
|
|
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(
|
|
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(
|
|
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
|
|
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(
|
|
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 {
|
|
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(
|
|
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
|
|
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 =
|
|
442
|
-
|
|
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
|
|
449
|
-
|
|
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 =
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
)
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
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
|
-
|
|
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!(
|
|
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
|
|
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
|
|
528
|
-
|
|
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)
|
|
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
|
|
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
|
|
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
|
|
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(
|
|
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)
|
|
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(
|
|
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)|
|
|
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(
|
|
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(
|
|
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
|
|
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
|
|
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(
|
|
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
|
|
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 {
|
|
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(
|
|
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
|
|
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 {
|
|
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(
|
|
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
|
|
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 {
|
|
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(
|
|
866
|
-
|
|
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
|
|
872
|
-
|
|
873
|
-
|
|
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()
|
|
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 /
|
|
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 =
|
|
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'~' =>
|
|
986
|
-
|
|
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!(
|
|
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!(
|
|
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 =
|
|
1043
|
-
assert_eq!(
|
|
1044
|
-
|
|
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!(
|
|
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 =
|
|
1051
|
-
|
|
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!(
|
|
1057
|
-
|
|
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!(
|
|
1068
|
-
|
|
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!(
|
|
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 =
|
|
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 =
|
|
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(
|
|
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(
|
|
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)> {
|
|
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!(
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
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!(
|
|
1211
|
-
|
|
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 {
|
|
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!(
|
|
1227
|
-
|
|
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
|
|
1255
|
-
|
|
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(
|
|
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")
|
|
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")
|
|
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")
|
|
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")
|
|
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
|
|
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
|
|
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!(
|
|
1330
|
-
|
|
1331
|
-
|
|
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 {
|
|
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)
|
|
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")
|
|
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(
|
|
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(
|
|
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!(
|
|
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(
|
|
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!(
|
|
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
|
|