hecks 2.9.0 → 3.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/exe/hecks +51 -6
- data/lib/hecks/adapters/driven/heki/journal.rb +1 -1
- data/lib/hecks/adapters/driven/lambda/client.rb +1 -1
- data/lib/hecks/adapters/driven/lambda.rb +1 -1
- data/lib/hecks/adapters/driven/memory.rb +16 -3
- data/lib/hecks/adapters/driven/postgres/reconnect.rb +6 -2
- data/lib/hecks/adapters/driven/postgres/shared_connection.rb +180 -0
- data/lib/hecks/adapters/driven/postgres.rb +13 -4
- data/lib/hecks/adapters/driven/prism.rb +41 -8
- data/lib/hecks/adapters/driven/tenant_provisioner.rb +73 -16
- data/lib/hecks/behaviors/expectations.rb +1 -1
- data/lib/hecks/behaviors.rb +1 -1
- data/lib/hecks/bench/cli.rb +62 -0
- data/lib/hecks/bench/environment.rb +71 -0
- data/lib/hecks/bench/postgres_probe.rb +30 -0
- data/lib/hecks/bench/report.rb +76 -0
- data/lib/hecks/bench/ruby_runner.rb +93 -0
- data/lib/hecks/bench/run.rb +55 -0
- data/lib/hecks/bench/rust_runner.rb +101 -0
- data/lib/hecks/bench/stats.rb +46 -0
- data/lib/hecks/bench/suite.rb +98 -0
- data/lib/hecks/bench/workload.rb +89 -0
- data/lib/hecks/bench.rb +17 -0
- data/lib/hecks/bluebook/assembly/contracts.rb +2 -0
- data/lib/hecks/bluebook/attribute.rb +1 -1
- data/lib/hecks/bluebook/behaviour/aggregate.rb +30 -1
- data/lib/hecks/bluebook/behaviour/domain_port.rb +7 -0
- data/lib/hecks/bluebook/chapter.rb +6 -2
- data/lib/hecks/bluebook/domain_port.rb +36 -5
- data/lib/hecks/bluebook/dsl/bluebook_builder/validation.rb +10 -0
- data/lib/hecks/bluebook/dsl/bluebook_builder.rb +8 -0
- data/lib/hecks/bluebook/dsl/bootstrap_table.rb +2 -1
- data/lib/hecks/bluebook/dsl/domain_port_builder.rb +31 -3
- data/lib/hecks/bluebook/dsl/hecksagon_builder.rb +122 -3
- data/lib/hecks/bluebook/dsl/lifecycle_builder.rb +1 -1
- data/lib/hecks/bluebook/dsl/query_builder.rb +27 -1
- data/lib/hecks/bluebook/dsl/rule_reference.rb +1 -1
- data/lib/hecks/bluebook/dsl/world_builder.rb +29 -2
- data/lib/hecks/bluebook/expression/canonical_form.rb +1 -1
- data/lib/hecks/bluebook/expression/resolver.rb +3 -2
- data/lib/hecks/bluebook/hexagon.rb +13 -5
- data/lib/hecks/bluebook/meta_validator/reconstruction.rb +1 -0
- data/lib/hecks/bluebook/meta_validator/syntax_boot.rb +4 -1
- data/lib/hecks/bluebook/meta_validator/verdict_cache.rb +246 -0
- data/lib/hecks/bluebook/meta_validator.rb +9 -4
- data/lib/hecks/bluebook/model_check.rb +23 -0
- data/lib/hecks/bluebook/pattern_subset.rb +38 -0
- data/lib/hecks/bluebook/query.rb +20 -5
- data/lib/hecks/bluebook/smoke_test.rb +1 -1
- data/lib/hecks/canonical_json.rb +30 -0
- data/lib/hecks/chapters.rb +102 -0
- data/lib/hecks/cli/behaviors.rb +61 -0
- data/lib/hecks/cli/canonicalise.rb +25 -0
- data/lib/hecks/cli/check_engine_agreement.rb +32 -0
- data/lib/hecks/cli/check_era.rb +88 -0
- data/lib/hecks/cli/console.rb +66 -0
- data/lib/hecks/cli/deploy_template_diff.rb +40 -0
- data/lib/hecks/cli/doc_coverage.rb +32 -0
- data/lib/hecks/cli/document.rb +2 -2
- data/lib/hecks/cli/expression_projection.rb +41 -0
- data/lib/hecks/cli/follow.rb +88 -0
- data/lib/hecks/cli/history.rb +44 -0
- data/lib/hecks/cli/ir.rb +1 -1
- data/lib/hecks/cli/mcp.rb +3 -3
- data/lib/hecks/cli/model_check.rb +15 -8
- data/lib/hecks/cli/pattern_cases.rb +47 -0
- data/lib/hecks/cli/present.rb +39 -0
- data/lib/hecks/cli/project_cli.rb +224 -32
- data/lib/hecks/cli/project_diagrams.rb +4 -3
- data/lib/hecks/cli/project_glossary.rb +44 -0
- data/lib/hecks/cli/reference.rb +46 -0
- data/lib/hecks/cli/refresh_projections.rb +44 -0
- data/lib/hecks/cli/refresh_rspec_runtime_baseline.rb +150 -0
- data/lib/hecks/cli/release.rb +70 -0
- data/lib/hecks/cli/release_gem.rb +59 -0
- data/lib/hecks/cli/rspec_io_parallel_files.rb +137 -0
- data/lib/hecks/cli/rspec_shard_files.rb +66 -0
- data/lib/hecks/cli/run.rb +3 -3
- data/lib/hecks/cli/seed_semantics_corpus.rb +71 -0
- data/lib/hecks/cli/serve_query_ir_mcp.rb +30 -0
- data/lib/hecks/cli/shape.rb +70 -0
- data/lib/hecks/cli/smoke_http.rb +230 -0
- data/lib/hecks/cli/smoke_test.rb +5 -3
- data/lib/hecks/cli/spec_example.rb +27 -0
- data/lib/hecks/cli/statements.rb +32 -0
- data/lib/hecks/cli/stores.rb +4 -5
- data/lib/hecks/cli/stress_concurrency_specs.rb +177 -0
- data/lib/hecks/cli.rb +201 -52
- data/lib/hecks/codemod.rb +324 -0
- data/lib/hecks/corpus.rb +423 -0
- data/lib/hecks/deploy/bluebook/deploy.bluebook +591 -23
- data/lib/hecks/deploy/bluebook/deploy.hecksagon +41 -4
- data/lib/hecks/deploy/oidc.json +82 -2
- data/lib/hecks/doc/reference.rb +351 -0
- data/lib/hecks/doors/cli_door.rb +196 -0
- data/lib/hecks/doors/cli_runner.rb +297 -0
- data/lib/hecks/{facade → doors}/command_request.rb +1 -1
- data/lib/hecks/{facade → doors}/handle.rb +2 -2
- data/lib/hecks/{facade → doors}/json_door.rb +4 -4
- data/lib/hecks/doors/launcher_options.rb +129 -0
- data/lib/hecks/{cli → doors}/mcp_door.rb +7 -7
- data/lib/hecks/doors/mcp_door_scope.rb +97 -0
- data/lib/hecks/{facade/surface → doors/ruby_door}/aggregate_door.rb +8 -3
- data/lib/hecks/{facade/surface → doors/ruby_door}/chapter.rb +3 -3
- data/lib/hecks/{facade/surface.rb → doors/ruby_door.rb} +36 -5
- data/lib/hecks/doors.rb +61 -0
- data/lib/hecks/embryonaut_bluebook/vendor_cli.rb +1 -1
- data/lib/hecks/embryonaut_bluebook.rb +1 -1
- data/lib/hecks/engine_agreement.rb +156 -0
- data/lib/hecks/facade.rb +24 -37
- data/lib/hecks/forms/app.rb +6 -6
- data/lib/hecks/forms/banking_presentation.rb +50 -0
- data/lib/hecks/forms/field_shape.rb +2 -1
- data/lib/hecks/forms/port_argument.rb +1 -1
- data/lib/hecks/fuzzing/bounded_exhaustive_expressions.rb +393 -0
- data/lib/hecks/fuzzing/combination_miner.rb +86 -0
- data/lib/hecks/fuzzing/concurrent_dispatch.rb +214 -0
- data/lib/hecks/fuzzing/coverage_campaign.rb +143 -0
- data/lib/hecks/fuzzing/differential.rb +156 -0
- data/lib/hecks/fuzzing/domain_generator.rb +615 -0
- data/lib/hecks/fuzzing/era_boundary.rb +71 -0
- data/lib/hecks/fuzzing/form_census.rb +136 -0
- data/lib/hecks/fuzzing/generated_domain_check.rb +93 -0
- data/lib/hecks/fuzzing/invalid_value_generator.rb +64 -0
- data/lib/hecks/fuzzing/isolated_boot.rb +301 -0
- data/lib/hecks/fuzzing/nondeterministic.rb +51 -0
- data/lib/hecks/fuzzing/outside_world.rb +45 -0
- data/lib/hecks/fuzzing/persistence_parity.rb +102 -0
- data/lib/hecks/fuzzing/properties/corrections.rb +88 -0
- data/lib/hecks/fuzzing/properties/dispatch_and_mutations.rb +314 -0
- data/lib/hecks/fuzzing/properties/guards.rb +190 -0
- data/lib/hecks/fuzzing/properties/invariants_and_aggregation.rb +300 -0
- data/lib/hecks/fuzzing/properties/lifecycle_and_replay.rb +93 -0
- data/lib/hecks/fuzzing/properties/outbox.rb +89 -0
- data/lib/hecks/fuzzing/properties/querying.rb +175 -0
- data/lib/hecks/fuzzing/properties.rb +162 -0
- data/lib/hecks/fuzzing/qa_settings.rb +123 -0
- data/lib/hecks/fuzzing/replay.rb +505 -0
- data/lib/hecks/fuzzing/rotation_priority.rb +48 -0
- data/lib/hecks/fuzzing/rust_gap_manifest.rb +108 -0
- data/lib/hecks/fuzzing/self_consistency.rb +339 -0
- data/lib/hecks/fuzzing/sequence_generator/adversary.rb +385 -0
- data/lib/hecks/fuzzing/sequence_generator/catalog.rb +132 -0
- data/lib/hecks/fuzzing/sequence_generator/outcome_tracker.rb +95 -0
- data/lib/hecks/fuzzing/sequence_generator/picker.rb +77 -0
- data/lib/hecks/fuzzing/sequence_generator/query_binding.rb +109 -0
- data/lib/hecks/fuzzing/sequence_generator/step_builder.rb +237 -0
- data/lib/hecks/fuzzing/sequence_generator.rb +189 -0
- data/lib/hecks/fuzzing/shrinker.rb +166 -0
- data/lib/hecks/fuzzing/structural_skips.rb +33 -0
- data/lib/hecks/fuzzing/sweep_depth.rb +43 -0
- data/lib/hecks/fuzzing/target_capabilities.rb +163 -0
- data/lib/hecks/fuzzing/value_generator.rb +216 -0
- data/lib/hecks/fuzzing.rb +23 -0
- data/lib/hecks/grammar/evolve.rb +336 -0
- data/lib/hecks/hecks/adapters/codebase/codemods.rb +45 -0
- data/lib/hecks/hecks/adapters/codebase/conformance.rb +71 -0
- data/lib/hecks/hecks/adapters/codebase/corpus_tasks.rb +202 -0
- data/lib/hecks/hecks/adapters/codebase/gem_registry.rb +80 -0
- data/lib/hecks/hecks/adapters/codebase/kernel_tables.rb +54 -0
- data/lib/hecks/hecks/adapters/codebase/language.rb +240 -0
- data/lib/hecks/hecks/adapters/codebase/npm_registry.rb +102 -0
- data/lib/hecks/hecks/adapters/codebase/publishing.rb +142 -0
- data/lib/hecks/hecks/adapters/codebase/regeneration.rb +67 -0
- data/lib/hecks/hecks/adapters/codebase/release_facts.rb +90 -0
- data/lib/hecks/hecks/adapters/codebase/ruby_child.rb +83 -0
- data/lib/hecks/hecks/adapters/codebase/secret_vault.rb +36 -0
- data/lib/hecks/hecks/adapters/codebase/source_tree.adapter +7 -0
- data/lib/hecks/hecks/adapters/codebase/source_tree.rb +189 -0
- data/lib/hecks/hecks/adapters/codebase/sqlite_fixture.rb +71 -0
- data/lib/hecks/hecks/adapters/codebase/style.rb +171 -0
- data/lib/hecks/hecks/adapters/codebase/test_runner.rb +55 -0
- data/lib/hecks/hecks/adapters/codebase/test_suite.rb +165 -0
- data/lib/hecks/hecks/adapters/codebase/tree.rb +111 -0
- data/lib/hecks/hecks/adapters/console_capture.rb +79 -0
- data/lib/hecks/hecks/adapters/deploy_toolchain.adapter +7 -0
- data/lib/hecks/hecks/adapters/deploy_toolchain.rb +108 -0
- data/lib/hecks/hecks/adapters/git.adapter +7 -0
- data/lib/hecks/hecks/adapters/git.rb +102 -0
- data/lib/hecks/hecks/adapters/host_http.adapter +7 -0
- data/lib/hecks/hecks/adapters/host_http.rb +65 -0
- data/lib/hecks/hecks/adapters/in_process_boot.adapter +7 -0
- data/lib/hecks/hecks/adapters/in_process_boot.rb +197 -0
- data/lib/hecks/hecks/adapters/in_process_operations.rb +202 -0
- data/lib/hecks/hecks/adapters/journal_store/changes.rb +143 -0
- data/lib/hecks/hecks/adapters/journal_store/compaction.rb +145 -0
- data/lib/hecks/hecks/adapters/journal_store/edge_audit.rb +132 -0
- data/lib/hecks/hecks/adapters/journal_store/examination.rb +135 -0
- data/lib/hecks/hecks/adapters/journal_store/held_domain.rb +163 -0
- data/lib/hecks/hecks/adapters/journal_store/readings.rb +187 -0
- data/lib/hecks/hecks/adapters/journal_store.adapter +7 -0
- data/lib/hecks/hecks/adapters/journal_store.rb +64 -0
- data/lib/hecks/hecks/adapters/local_files.adapter +7 -0
- data/lib/hecks/hecks/adapters/local_files.rb +46 -0
- data/lib/hecks/hecks/adapters/pg_admin.adapter +7 -0
- data/lib/hecks/hecks/adapters/pg_admin.rb +245 -0
- data/lib/hecks/hecks/adapters/process_pool.adapter +7 -0
- data/lib/hecks/hecks/adapters/process_pool.rb +119 -0
- data/lib/hecks/hecks/adapters/rust_toolchain.adapter +7 -0
- data/lib/hecks/hecks/adapters/rust_toolchain.rb +179 -0
- data/lib/hecks/hecks/adapters/rust_workspace.rb +150 -0
- data/lib/hecks/hecks/adapters/shell.adapter +7 -0
- data/lib/hecks/hecks/adapters/shell.rb +49 -0
- data/lib/hecks/hecks/adapters/terminal.adapter +7 -0
- data/lib/hecks/hecks/adapters/terminal.rb +74 -0
- data/lib/hecks/hecks/codebase.behaviors +172 -0
- data/lib/hecks/hecks/codebase.bluebook +2453 -0
- data/lib/hecks/hecks/context_map.hecksagon +13 -0
- data/lib/hecks/hecks/custodian.behaviors +179 -0
- data/lib/hecks/hecks/custodian.bluebook +1847 -0
- data/lib/hecks/hecks/deploy.behaviors +74 -0
- data/lib/hecks/hecks/deploy.world +12 -0
- data/lib/hecks/hecks/environments/memory.world +10 -0
- data/lib/hecks/hecks/governance.world +7 -0
- data/lib/hecks/hecks/hecks.behaviors +62 -0
- data/lib/hecks/hecks/hecks.bluebook +124 -0
- data/lib/hecks/hecks/hecks.hecksagon +390 -0
- data/lib/hecks/hecks/hecks.world +42 -0
- data/lib/hecks/kernel_capabilities.rb +194 -0
- data/lib/hecks/language/bluebook/bluebook.bluebook +11 -0
- data/lib/hecks/language/bluebook/query.bluebook +7 -0
- data/lib/hecks/language/bluebook/vocabulary.bluebook +4 -4
- data/lib/hecks/language/hecksagon/domain_port.bluebook +10 -0
- data/lib/hecks/language/hecksagon/hecksagon.bluebook +20 -0
- data/lib/hecks/mcp_stdio_guard.rb +2 -2
- data/lib/hecks/persistence_legacy_fixture/regenerate.rb +161 -0
- data/lib/hecks/persistence_legacy_fixture.rb +113 -0
- data/lib/hecks/ports/persistence/plugins/era/era_check.rb +8 -5
- data/lib/hecks/ports/persistence/plugins/era/era_guard.rb +4 -1
- data/lib/hecks/ports/persistence/plugins/era/expected_era.rb +15 -2
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/era_store.rb +1 -1
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/tail_merge.rb +1 -1
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/merge_coordinator.rb +1 -1
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/minter.rb +32 -15
- data/lib/hecks/ports/persistence/plugins/era/postgres_era.rb +15 -5
- data/lib/hecks/ports/persistence/plugins/era/translation/approval_file.rb +227 -0
- data/lib/hecks/ports/persistence/plugins/era/translation/audit/approval_digest.rb +1 -1
- data/lib/hecks/ports/persistence/plugins/era/translation/reattest.rb +43 -24
- data/lib/hecks/ports/persistence/plugins/era/translation.rb +1 -0
- data/lib/hecks/ports/persistence/state_codec.rb +24 -5
- data/lib/hecks/projection_files.rb +285 -0
- data/lib/hecks/projections/bootstrap_table.rb +1 -1
- data/lib/hecks/projections/deploy/fargate.rb +5 -5
- data/lib/hecks/projections/deploy/lambda.rb +43 -32
- data/lib/hecks/projections/deploy/preview/preview.sh.erb +1 -1
- data/lib/hecks/projections/deploy/preview/template.rb +1 -1
- data/lib/hecks/projections/deploy/scripts/deploy-service.sh.tmpl +1 -1
- data/lib/hecks/projections/deploy/scripts/expected-era.header +1 -1
- data/lib/hecks/projections/deploy/scripts/hosting.mk.tmpl +3 -3
- data/lib/hecks/projections/deploy/scripts/smoke-after-deploy.sh.tmpl +1 -1
- data/lib/hecks/projections/deploy/shared.rb +1 -1
- data/lib/hecks/projections/diagrams.rb +3 -3
- data/lib/hecks/projections/model/deviations.rb +1 -1
- data/lib/hecks/projections/parser_table.rb +4 -4
- data/lib/hecks/projections/reference.rb +2 -3
- data/lib/hecks/projections/rust_vocabulary.rb +1 -1
- data/lib/hecks/projector/cli_projector.rb +106 -37
- data/lib/hecks/projector.rb +1 -1
- data/lib/hecks/quality_control/adapters/agent.adapter +7 -0
- data/lib/hecks/quality_control/adapters/agent.rb +58 -0
- data/lib/hecks/quality_control/adapters/angle_tools.adapter +7 -0
- data/lib/hecks/quality_control/adapters/angle_tools.rb +15 -0
- data/lib/hecks/quality_control/adapters/clearance_recorder.rb +36 -0
- data/lib/hecks/quality_control/adapters/clearance_tools.adapter +7 -0
- data/lib/hecks/quality_control/adapters/clearance_tools.rb +17 -0
- data/lib/hecks/quality_control/adapters/git_pr.adapter +7 -0
- data/lib/hecks/quality_control/adapters/git_pr.rb +226 -0
- data/lib/hecks/quality_control/adapters/github_checks.adapter +9 -0
- data/lib/hecks/quality_control/adapters/github_checks.rb +83 -0
- data/lib/hecks/quality_control/adapters/github_ci_webhook.rb +56 -0
- data/lib/hecks/quality_control/adapters/github_issues.adapter +7 -0
- data/lib/hecks/quality_control/adapters/github_issues.rb +49 -0
- data/lib/hecks/quality_control/adapters/qa_tool.rb +59 -0
- data/lib/hecks/quality_control/adapters/sweep_tools.adapter +7 -0
- data/lib/hecks/quality_control/adapters/sweep_tools.rb +51 -0
- data/lib/hecks/quality_control/adapters/target_tools.adapter +7 -0
- data/lib/hecks/quality_control/adapters/target_tools.rb +43 -0
- data/lib/hecks/quality_control/cli/child.rb +45 -0
- data/lib/hecks/quality_control/cli/qa_concurrency_racer.rb +45 -0
- data/lib/hecks/quality_control/cli/qa_discover_external_domains.rb +248 -0
- data/lib/hecks/quality_control/cli/qa_domain_novelty.rb +163 -0
- data/lib/hecks/quality_control/cli/qa_generated_domains.rb +450 -0
- data/lib/hecks/quality_control/cli/qa_log_bug.rb +195 -0
- data/lib/hecks/quality_control/cli/qa_mine_combinations.rb +220 -0
- data/lib/hecks/quality_control/cli/qa_open_pr.rb +305 -0
- data/lib/hecks/quality_control/cli/qa_postgres_migrate.rb +154 -0
- data/lib/hecks/quality_control/cli/qa_postgres_role.rb +159 -0
- data/lib/hecks/quality_control/cli/qa_pr_check.rb +232 -0
- data/lib/hecks/quality_control/cli/qa_seed_angles.rb +142 -0
- data/lib/hecks/quality_control/cli/qa_seed_targets.rb +69 -0
- data/lib/hecks/quality_control/cli/qa_sweep/all_mode.rb +280 -0
- data/lib/hecks/quality_control/cli/qa_sweep/checks.rb +329 -0
- data/lib/hecks/quality_control/cli/qa_sweep/dials.rb +92 -0
- data/lib/hecks/quality_control/cli/qa_sweep/finding_report.rb +117 -0
- data/lib/hecks/quality_control/cli/qa_sweep/parsing.rb +175 -0
- data/lib/hecks/quality_control/cli/qa_sweep/release_mode.rb +60 -0
- data/lib/hecks/quality_control/cli/qa_sweep.rb +611 -0
- data/lib/hecks/quality_control/cli/qa_tick.rb +274 -0
- data/lib/hecks/quality_control/quality_control.bluebook +2224 -0
- data/lib/hecks/quality_control/quality_control.hecksagon +13 -0
- data/lib/hecks/quality_control/quality_control.ports.hecksagon +54 -0
- data/lib/hecks/query_ir.rb +346 -0
- data/lib/hecks/query_ir_mcp.rb +198 -0
- data/lib/hecks/release/runner/ci_publisher.rb +2 -2
- data/lib/hecks/release/runner/clean_tree.rb +42 -0
- data/lib/hecks/release/runner/console.rb +6 -0
- data/lib/hecks/release/runner/gem_publisher.rb +14 -7
- data/lib/hecks/release/runner/npm_publisher.rb +6 -36
- data/lib/hecks/release/runner/preflight.rb +26 -2
- data/lib/hecks/release/runner/published.rb +8 -26
- data/lib/hecks/release/runner/tagger.rb +30 -15
- data/lib/hecks/release/runner.rb +57 -6
- data/lib/hecks/router/namespace_installer.rb +3 -3
- data/lib/hecks/runtime/adapter_lookup.rb +124 -0
- data/lib/hecks/runtime/command_interpreter/mutation_applier.rb +4 -0
- data/lib/hecks/runtime/dependency_planning.rb +20 -2
- data/lib/hecks/runtime/dispatcher.rb +53 -3
- data/lib/hecks/runtime/interpreting.rb +3 -0
- data/lib/hecks/runtime/loader.rb +65 -12
- data/lib/hecks/runtime/outbox.rb +1 -1
- data/lib/hecks/runtime/policy_interpreter.rb +5 -2
- data/lib/hecks/runtime/port_operation_interpreter.rb +2 -10
- data/lib/hecks/runtime/query_interpreter.rb +77 -0
- data/lib/hecks/runtime/reaction_outcome.rb +89 -0
- data/lib/hecks/runtime/refusal_wording.rb +23 -1
- data/lib/hecks/runtime/registry/verification.rb +163 -4
- data/lib/hecks/runtime/registry.rb +57 -3
- data/lib/hecks/runtime/remote_dispatcher.rb +31 -2
- data/lib/hecks/runtime/value/coercion.rb +23 -135
- data/lib/hecks/runtime/value/field_checks.rb +172 -0
- data/lib/hecks/runtime/value.rb +4 -1
- data/lib/hecks/runtime.rb +20 -6
- data/lib/hecks/rust_build/conformance.rb +158 -0
- data/lib/hecks/rust_build/conformance_fuzz.rb +71 -0
- data/lib/hecks/rust_build/coverage.rb +218 -0
- data/lib/hecks/rust_build/native_build.rb +124 -0
- data/lib/hecks/rust_build/project_rust/root_mod.rb +120 -0
- data/lib/hecks/rust_build/project_rust.rb +332 -0
- data/lib/hecks/rust_build/wasm.rb +105 -0
- data/lib/hecks/rust_build/wasm_browser.rb +113 -0
- data/lib/hecks/rust_build.rb +144 -0
- data/lib/hecks/storehouse.rb +16 -16
- data/lib/hecks/tenancy/bluebook/tenancy.bluebook +2 -2
- data/lib/hecks/tenancy/bluebook/tenancy.hecksagon +1 -1
- data/lib/hecks/three_zero/forms.yml +127 -0
- data/lib/hecks/three_zero.rb +12 -0
- data/lib/hecks/tools/argument_gate_matrix.rb +316 -0
- data/lib/hecks/tools/comment_style.rb +1070 -0
- data/lib/hecks/tools/corpus_report.rb +87 -0
- data/lib/hecks/tools/deploy_recipe.rb +255 -0
- data/lib/hecks/tools/deploy_recipe_lint.rb +282 -0
- data/lib/hecks/tools/drop_implicit_append_fields.rb +95 -0
- data/lib/hecks/tools/era_projection_backfill.rb +94 -0
- data/lib/hecks/tools/era_reattest.rb +146 -0
- data/lib/hecks/tools/evolve_run.rb +189 -0
- data/lib/hecks/tools/fuzz_sweep.rb +373 -0
- data/lib/hecks/tools/heki_compaction.rb +100 -0
- data/lib/hecks/tools/hoist_local_givens.rb +246 -0
- data/lib/hecks/tools/journal_compaction.rb +89 -0
- data/lib/hecks/tools/oidc_manifests.rb +70 -0
- data/lib/hecks/tools/query_ir_run.rb +81 -0
- data/lib/hecks/tools/regeneration_run.rb +166 -0
- data/lib/hecks/tools/rust_comment_style.rb +642 -0
- data/lib/hecks/tools/sequence_script.rb +62 -0
- data/lib/hecks/tools/tail_merge.rb +117 -0
- data/lib/hecks/tools/tenant_provisioning.rb +142 -0
- data/lib/hecks/tools/translation_audit.rb +208 -0
- data/lib/hecks/tools/translation_scaffold.rb +131 -0
- data/lib/hecks/tools.rb +86 -0
- data/lib/hecks/version.rb +1 -1
- data/lib/hecks.rb +32 -15
- data/qa/settings.yml +66 -0
- data/rust/.cargo/config.toml +6 -0
- data/rust/.gitignore +2 -0
- data/rust/Cargo.lock +54 -0
- data/rust/Cargo.toml +61 -0
- data/rust/build/.cargo/config.toml +3 -0
- data/rust/build/.gitignore +1 -0
- data/rust/build/Cargo.lock +7 -0
- data/rust/build/Cargo.toml +19 -0
- data/rust/build/src/build_artifact.rs +47 -0
- data/rust/build/src/cargo_sync.rs +248 -0
- data/rust/build/src/json.rs +421 -0
- data/rust/build/src/lineage_pass.rs +285 -0
- data/rust/build/src/main.rs +51 -0
- data/rust/build/src/optional_pass.rs +150 -0
- data/rust/build/src/pipeline.rs +264 -0
- data/rust/build/src/reserved_names.rs +76 -0
- data/rust/build/src/resolve.rs +124 -0
- data/rust/build/src/root.rs +25 -0
- data/rust/build/src/sidecars.rs +73 -0
- data/rust/build/src/subprocess.rs +51 -0
- data/rust/build/src/tmp.rs +31 -0
- data/rust/codegen/.cargo/config.toml +3 -0
- data/rust/codegen/.gitignore +1 -0
- data/rust/codegen/Cargo.lock +7 -0
- data/rust/codegen/Cargo.toml +20 -0
- data/rust/codegen/src/attr.rs +32 -0
- data/rust/codegen/src/bridging.rs +481 -0
- data/rust/codegen/src/commands.rs +1226 -0
- data/rust/codegen/src/constraints.rs +109 -0
- data/rust/codegen/src/dependency_planning.rs +209 -0
- data/rust/codegen/src/domain_generator.rs +1288 -0
- data/rust/codegen/src/exemplar.rs +230 -0
- data/rust/codegen/src/expr_emitter.rs +87 -0
- data/rust/codegen/src/fielded.rs +192 -0
- data/rust/codegen/src/hecks_naming.rs +33 -0
- data/rust/codegen/src/json.rs +335 -0
- data/rust/codegen/src/json_codec.rs +697 -0
- data/rust/codegen/src/literal.rs +207 -0
- data/rust/codegen/src/main.rs +290 -0
- data/rust/codegen/src/manifest.rs +227 -0
- data/rust/codegen/src/mutations.rs +784 -0
- data/rust/codegen/src/naming.rs +286 -0
- data/rust/codegen/src/ports.rs +208 -0
- data/rust/codegen/src/prelude.rs +116 -0
- data/rust/codegen/src/queries.rs +786 -0
- data/rust/codegen/src/reactions.rs +532 -0
- data/rust/codegen/src/read_models.rs +697 -0
- data/rust/codegen/src/reference_specs.rs +40 -0
- data/rust/codegen/src/registry.rs +966 -0
- data/rust/codegen/src/reserved_names.rs +76 -0
- data/rust/codegen/src/shared.rs +93 -0
- data/rust/codegen/src/skip_reason.rs +25 -0
- data/rust/codegen/src/types.rs +362 -0
- data/rust/host/.cargo/config.toml +3 -0
- data/rust/host/.gitignore +1 -0
- data/rust/host/Cargo.lock +4297 -0
- data/rust/host/Cargo.toml +61 -0
- data/rust/host/HECKS_RELEASE +1 -0
- data/rust/host/rds-ca-bundle.pem +2736 -0
- data/rust/host/src/api.rs +1596 -0
- data/rust/host/src/approval.rs +652 -0
- data/rust/host/src/auth.rs +1287 -0
- data/rust/host/src/bin/expr_harness.rs +46 -0
- data/rust/host/src/bin/lineage_harness.rs +119 -0
- data/rust/host/src/bin/mint_harness.rs +135 -0
- data/rust/host/src/checkout.rs +265 -0
- data/rust/host/src/dispatch.rs +1443 -0
- data/rust/host/src/expr_json.rs +1117 -0
- data/rust/host/src/field_hints.rs +52 -0
- data/rust/host/src/ir.rs +733 -0
- data/rust/host/src/journal.rs +874 -0
- data/rust/host/src/lambda_client.rs +378 -0
- data/rust/host/src/log.rs +147 -0
- data/rust/host/src/main.rs +474 -0
- data/rust/host/src/mint.rs +1865 -0
- data/rust/host/src/payments/keystore.rs +330 -0
- data/rust/host/src/payments/tests.rs +1400 -0
- data/rust/host/src/payments.rs +767 -0
- data/rust/host/src/presentation.rs +698 -0
- data/rust/host/src/presentation_write.rs +1431 -0
- data/rust/host/src/query_step.rs +149 -0
- data/rust/host/src/rate_limit.rs +904 -0
- data/rust/host/src/reference_transform.rs +241 -0
- data/rust/host/src/reference_validate.rs +338 -0
- data/rust/host/src/resend.rs +296 -0
- data/rust/host/src/secrets.rs +106 -0
- data/rust/host/src/server.rs +663 -0
- data/rust/host/src/storage_shape.rs +175 -0
- data/rust/host/src/test_pg.rs +40 -0
- data/rust/host/src/ui_schema.rs +1351 -0
- data/rust/host/src/wasm_runner.rs +191 -0
- data/rust/host/src/web/newsletter/cooldown.rs +126 -0
- data/rust/host/src/web/newsletter/tests.rs +104 -0
- data/rust/host/src/web/newsletter.rs +310 -0
- data/rust/host/src/web/newsletter_send.rs +357 -0
- data/rust/host/src/web/registrations.rs +1505 -0
- data/rust/host/src/web.rs +2937 -0
- data/rust/lsp/.gitignore +1 -0
- data/rust/lsp/Cargo.lock +7 -0
- data/rust/lsp/Cargo.toml +16 -0
- data/rust/lsp/README.md +133 -0
- data/rust/lsp/src/diagnostics.rs +165 -0
- data/rust/lsp/src/json.rs +385 -0
- data/rust/lsp/src/main.rs +399 -0
- data/rust/lsp/src/outline.rs +177 -0
- data/rust/lsp/src/rpc.rs +67 -0
- data/rust/parser/.cargo/config.toml +5 -0
- data/rust/parser/.gitignore +1 -0
- data/rust/parser/Cargo.lock +7 -0
- data/rust/parser/Cargo.toml +21 -0
- data/rust/parser/src/build/closed_sets.rs +27 -0
- data/rust/parser/src/build/identity.rs +276 -0
- data/rust/parser/src/build/mod.rs +12 -0
- data/rust/parser/src/build/naming.rs +141 -0
- data/rust/parser/src/build/pattern_subset.rs +161 -0
- data/rust/parser/src/build/query_derive.rs +72 -0
- data/rust/parser/src/build/query_inference.rs +269 -0
- data/rust/parser/src/build/query_options.rs +100 -0
- data/rust/parser/src/build/read_model.rs +94 -0
- data/rust/parser/src/build/references.rs +32 -0
- data/rust/parser/src/canonical.rs +114 -0
- data/rust/parser/src/diag.rs +55 -0
- data/rust/parser/src/emit.rs +953 -0
- data/rust/parser/src/expr/ast_json.rs +147 -0
- data/rust/parser/src/expr/evaluator.rs +81 -0
- data/rust/parser/src/expr/mod.rs +84 -0
- data/rust/parser/src/expr/resolver.rs +497 -0
- data/rust/parser/src/ir.rs +387 -0
- data/rust/parser/src/keywords.rs +585 -0
- data/rust/parser/src/lex.rs +662 -0
- data/rust/parser/src/main.rs +252 -0
- data/rust/parser/src/parse/aggregate.rs +612 -0
- data/rust/parser/src/parse/chapter.rs +517 -0
- data/rust/parser/src/parse/command.rs +637 -0
- data/rust/parser/src/parse/domain_port.rs +127 -0
- data/rust/parser/src/parse/entity.rs +527 -0
- data/rust/parser/src/parse/file.rs +67 -0
- data/rust/parser/src/parse/hecksagon.rs +231 -0
- data/rust/parser/src/parse/lifecycle.rs +162 -0
- data/rust/parser/src/parse/mod.rs +1262 -0
- data/rust/parser/src/parse/policy.rs +87 -0
- data/rust/parser/src/parse/process_manager.rs +305 -0
- data/rust/parser/src/parse/query.rs +100 -0
- data/rust/parser/src/parse/read_model.rs +214 -0
- data/rust/parser/src/parse/value_object.rs +250 -0
- data/rust/parser/src/ruby_value.rs +422 -0
- data/rust/project/bridging.rb +257 -0
- data/rust/project/commands.rb +877 -0
- data/rust/project/constraints.rb +86 -0
- data/rust/project/dependency_planning.rb +178 -0
- data/rust/project/domain_generator.rb +980 -0
- data/rust/project/exemplar.rb +148 -0
- data/rust/project/expr_emitter.rb +72 -0
- data/rust/project/fielded.rb +141 -0
- data/rust/project/json_codec.rb +641 -0
- data/rust/project/mutations.rb +721 -0
- data/rust/project/naming.rb +205 -0
- data/rust/project/ports.rb +65 -0
- data/rust/project/queries.rb +493 -0
- data/rust/project/reactions.rb +317 -0
- data/rust/project/read_models.rb +690 -0
- data/rust/project/reference_specs.rb +27 -0
- data/rust/project/registry.rb +396 -0
- data/rust/project/skip_reason.rb +29 -0
- data/rust/project/types.rb +225 -0
- data/rust/project/write_if_changed.rb +83 -0
- data/rust/project.rb +30 -0
- data/rust/project_rust_pipeline.rb +383 -0
- data/rust/src/exemplar/commands.rs +279 -0
- data/rust/src/exemplar/constraints.rs +18 -0
- data/rust/src/exemplar/fielded.rs +280 -0
- data/rust/src/exemplar/json.rs +277 -0
- data/rust/src/exemplar/mod.rs +17 -0
- data/rust/src/exemplar/mutations.rs +80 -0
- data/rust/src/exemplar/queries.rs +33 -0
- data/rust/src/exemplar/reactions.rs +88 -0
- data/rust/src/exemplar/read_models.rs +48 -0
- data/rust/src/exemplar/registry.rs +166 -0
- data/rust/src/exemplar/types.rs +44 -0
- data/rust/src/kernel/attribute_shapes/composite.rs +88 -0
- data/rust/src/kernel/attribute_shapes/list.rs +18 -0
- data/rust/src/kernel/attribute_shapes/mod.rs +44 -0
- data/rust/src/kernel/attribute_shapes/optional.rs +9 -0
- data/rust/src/kernel/attribute_shapes/scalar.rs +39 -0
- data/rust/src/kernel/cli.rs +585 -0
- data/rust/src/kernel/dispatch.rs +1090 -0
- data/rust/src/kernel/expr.rs +458 -0
- data/rust/src/kernel/expression_operators/arithmetic.rs +128 -0
- data/rust/src/kernel/expression_operators/comparison.rs +48 -0
- data/rust/src/kernel/expression_operators/enumeration.rs +232 -0
- data/rust/src/kernel/expression_operators/logical.rs +13 -0
- data/rust/src/kernel/expression_operators/membership.rs +18 -0
- data/rust/src/kernel/expression_operators/mod.rs +68 -0
- data/rust/src/kernel/expression_operators/pattern_match.rs +73 -0
- data/rust/src/kernel/expression_operators/positional.rs +126 -0
- data/rust/src/kernel/expression_operators/presence.rs +101 -0
- data/rust/src/kernel/expression_operators/sign_test.rs +16 -0
- data/rust/src/kernel/expression_operators/sized.rs +35 -0
- data/rust/src/kernel/expression_operators/text.rs +79 -0
- data/rust/src/kernel/expression_operators/to_string.rs +18 -0
- data/rust/src/kernel/json.rs +779 -0
- data/rust/src/kernel/mod.rs +133 -0
- data/rust/src/kernel/named_query.rs +219 -0
- data/rust/src/kernel/orchestrate.rs +1810 -0
- data/rust/src/kernel/pattern.rs +307 -0
- data/rust/src/kernel/query_comparators.rs +206 -0
- data/rust/src/kernel/query_ordering.rs +103 -0
- data/rust/src/kernel/read_model.rs +914 -0
- data/rust/src/kernel/reference_lookup.rs +149 -0
- data/rust/src/kernel/repository.rs +468 -0
- data/rust/src/kernel/routing.rs +612 -0
- data/rust/src/kernel/vocab/aggregate_dispatch_order.rs +118 -0
- data/rust/src/kernel/vocab/entity_dispatch_order.rs +114 -0
- data/rust/src/kernel/vocab/field_hint.rs +70 -0
- data/rust/src/kernel/vocab/mod.rs +20 -0
- data/rust/src/kernel/vocab/query_comparator.rs +65 -0
- data/rust/src/kernel/vocab/refusal_template.rs +1592 -0
- data/rust/src/lib.rs +8 -0
- data/rust/src/main.rs +16 -0
- data/rust/web/.gitignore +1 -0
- data/rust/web/Cargo.lock +119 -0
- data/rust/web/Cargo.toml +18 -0
- data/rust/web/src/lib.rs +8 -0
- metadata +477 -12
- data/lib/hecks/facade/cli_door.rb +0 -96
- data/lib/hecks/facade/cli_runner.rb +0 -127
- data/lib/hecks/mcp_door_scope.rb +0 -95
|
@@ -0,0 +1,1287 @@
|
|
|
1
|
+
//! Google sign-in and stateless sessions: OAuth, ID-token verification
|
|
2
|
+
//! against Google's real JWKS, and HMAC-signed session/account cookies.
|
|
3
|
+
|
|
4
|
+
use crate::dispatch;
|
|
5
|
+
use crate::journal;
|
|
6
|
+
use crate::journal::LineageConfig;
|
|
7
|
+
use crate::lambda_client::LambdaInvoker;
|
|
8
|
+
use serde_json::{json, Value};
|
|
9
|
+
use std::path::Path;
|
|
10
|
+
use std::time::{SystemTime, UNIX_EPOCH};
|
|
11
|
+
use tokio::sync::Mutex;
|
|
12
|
+
use tokio_postgres::Client;
|
|
13
|
+
|
|
14
|
+
const ISSUER: &str = "https://accounts.google.com";
|
|
15
|
+
const STATE_TTL_SECS: u64 = 600;
|
|
16
|
+
|
|
17
|
+
/// A verified Google ID token's claims.
|
|
18
|
+
pub struct Claims {
|
|
19
|
+
pub issuer: String,
|
|
20
|
+
pub subject: String,
|
|
21
|
+
pub email: Option<String>,
|
|
22
|
+
pub email_verified: bool,
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/// A signed-in person, as carried in the session cookie.
|
|
26
|
+
pub struct Session {
|
|
27
|
+
pub identity_id: String,
|
|
28
|
+
pub email: String,
|
|
29
|
+
pub name: String,
|
|
30
|
+
pub role: Option<String>,
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
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())?;
|
|
35
|
+
let payload = now_secs().to_string();
|
|
36
|
+
let state = format!("{payload}.{}", sign(secret, &payload));
|
|
37
|
+
Ok(format!(
|
|
38
|
+
"https://accounts.google.com/o/oauth2/v2/auth?client_id={}&redirect_uri={}&response_type=code&scope={}&state={}",
|
|
39
|
+
urlencode(&client_id),
|
|
40
|
+
urlencode(redirect_uri),
|
|
41
|
+
urlencode("openid email profile"),
|
|
42
|
+
urlencode(&state),
|
|
43
|
+
))
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// No server-side stash to compare against — recomputing the HMAC here
|
|
47
|
+
// is the whole check: a forged, mismatched, or expired state fails on it.
|
|
48
|
+
pub fn verify_state(state: &str, secret: &str) -> Result<(), String> {
|
|
49
|
+
let payload = verify_sig(secret, state).ok_or_else(|| "state mismatch".to_string())?;
|
|
50
|
+
let minted: u64 = payload.parse().map_err(|_| "state mismatch".to_string())?;
|
|
51
|
+
if now_secs().saturating_sub(minted) > STATE_TTL_SECS {
|
|
52
|
+
return Err("state expired".to_string());
|
|
53
|
+
}
|
|
54
|
+
Ok(())
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
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())?;
|
|
61
|
+
|
|
62
|
+
let http = reqwest::Client::new();
|
|
63
|
+
let resp = http
|
|
64
|
+
.post("https://oauth2.googleapis.com/token")
|
|
65
|
+
.form(&[
|
|
66
|
+
("code", code),
|
|
67
|
+
("client_id", client_id.as_str()),
|
|
68
|
+
("client_secret", client_secret.as_str()),
|
|
69
|
+
("redirect_uri", redirect_uri),
|
|
70
|
+
("grant_type", "authorization_code"),
|
|
71
|
+
])
|
|
72
|
+
.send()
|
|
73
|
+
.await
|
|
74
|
+
.map_err(|e| format!("token exchange: {e}"))?;
|
|
75
|
+
|
|
76
|
+
if !resp.status().is_success() {
|
|
77
|
+
let body = resp.text().await.unwrap_or_default();
|
|
78
|
+
return Err(format!("token exchange failed: {body}"));
|
|
79
|
+
}
|
|
80
|
+
let body: Value = resp.json().await.map_err(|e| format!("token response: {e}"))?;
|
|
81
|
+
let id_token = body
|
|
82
|
+
.get("id_token")
|
|
83
|
+
.and_then(|v| v.as_str())
|
|
84
|
+
.ok_or_else(|| "no id_token in the response".to_string())?;
|
|
85
|
+
|
|
86
|
+
verify_id_token(id_token, &client_id).await
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Fetched fresh, never cached: this is a real sign-in, not a hot path,
|
|
90
|
+
// and missing Google's own key rotation would be worse than one extra
|
|
91
|
+
// HTTPS round trip per login.
|
|
92
|
+
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())?;
|
|
95
|
+
|
|
96
|
+
let http = reqwest::Client::new();
|
|
97
|
+
let jwks: Value = http
|
|
98
|
+
.get("https://www.googleapis.com/oauth2/v3/certs")
|
|
99
|
+
.send()
|
|
100
|
+
.await
|
|
101
|
+
.map_err(|e| format!("fetching Google's JWKS: {e}"))?
|
|
102
|
+
.json()
|
|
103
|
+
.await
|
|
104
|
+
.map_err(|e| format!("parsing Google's JWKS: {e}"))?;
|
|
105
|
+
|
|
106
|
+
let key = jwks
|
|
107
|
+
.get("keys")
|
|
108
|
+
.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())))
|
|
110
|
+
.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())?;
|
|
113
|
+
let decoding_key = jsonwebtoken::DecodingKey::from_rsa_components(n, e)
|
|
114
|
+
.map_err(|err| format!("building decoding key from JWKS: {err}"))?;
|
|
115
|
+
|
|
116
|
+
let mut validation = jsonwebtoken::Validation::new(jsonwebtoken::Algorithm::RS256);
|
|
117
|
+
validation.set_audience(&[client_id]);
|
|
118
|
+
validation.set_issuer(&[ISSUER, "accounts.google.com"]);
|
|
119
|
+
|
|
120
|
+
let token = jsonwebtoken::decode::<Value>(id_token, &decoding_key, &validation)
|
|
121
|
+
.map_err(|err| format!("id_token signature/claims invalid: {err}"))?;
|
|
122
|
+
let claims = token.claims;
|
|
123
|
+
|
|
124
|
+
Ok(Claims {
|
|
125
|
+
issuer: claims.get("iss").and_then(|v| v.as_str()).unwrap_or(ISSUER).to_string(),
|
|
126
|
+
subject: claims
|
|
127
|
+
.get("sub")
|
|
128
|
+
.and_then(|v| v.as_str())
|
|
129
|
+
.ok_or_else(|| "id_token has no sub".to_string())?
|
|
130
|
+
.to_string(),
|
|
131
|
+
email: claims.get("email").and_then(|v| v.as_str()).map(|s| s.to_string()),
|
|
132
|
+
email_verified: matches!(claims.get("email_verified"), Some(Value::Bool(true)))
|
|
133
|
+
|| claims.get("email_verified").and_then(|v| v.as_str()) == Some("true"),
|
|
134
|
+
})
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/// The cookie name the account token travels in when a deploy names none.
|
|
138
|
+
/// A deploy naming its own uses `HECKS_SESSION_COOKIE` instead.
|
|
139
|
+
pub const DEFAULT_ACCOUNT_COOKIE: &str = "hecks_session";
|
|
140
|
+
|
|
141
|
+
/// Unset or empty means the default; otherwise must be a valid cookie
|
|
142
|
+
/// name (letters, digits, `_`, `-`, `.`) since it lands in `Set-Cookie` raw.
|
|
143
|
+
pub fn resolve_account_cookie(configured: Option<&str>) -> Result<String, String> {
|
|
144
|
+
match configured {
|
|
145
|
+
None | Some("") => Ok(DEFAULT_ACCOUNT_COOKIE.to_string()),
|
|
146
|
+
Some(name) if name.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.')) => Ok(name.to_string()),
|
|
147
|
+
Some(name) => Err(format!(
|
|
148
|
+
"HECKS_SESSION_COOKIE {name:?} is not a valid cookie name (letters, digits, '_', '-' and '.' only)"
|
|
149
|
+
)),
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/// Reads `HECKS_SESSION_COOKIE`; an invalid value falls back to the
|
|
154
|
+
/// default here, but `main` refuses it at boot so it can't go unnoticed.
|
|
155
|
+
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())
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/// A flat, HMAC-signed `email` + expiry claim, verified by /accounts/me
|
|
160
|
+
/// and the SSO handoff — deliberately not a `Session` cookie.
|
|
161
|
+
pub fn account_token(secret: &str, email: &str, ttl_secs: u64) -> String {
|
|
162
|
+
let payload = json!({"email": email, "exp": now_secs() + ttl_secs});
|
|
163
|
+
let encoded = base64_encode(payload.to_string().as_bytes());
|
|
164
|
+
format!("{encoded}.{}", sign(secret, &encoded))
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
pub fn verify_account_token(secret: &str, token: &str) -> Option<String> {
|
|
168
|
+
let payload = verify_sig(secret, token)?;
|
|
169
|
+
let bytes = base64_decode(&payload);
|
|
170
|
+
let value: Value = serde_json::from_slice(&bytes).ok()?;
|
|
171
|
+
let exp = value.get("exp")?.as_u64()?;
|
|
172
|
+
if now_secs() > exp {
|
|
173
|
+
return None;
|
|
174
|
+
}
|
|
175
|
+
value.get("email")?.as_str().map(|s| s.to_string())
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/// Verifies only for `purpose` (the signing key derives from it, so it
|
|
179
|
+
/// can't be replayed elsewhere). `claims` must be a JSON object.
|
|
180
|
+
pub fn purpose_token(secret: &str, purpose: &str, claims: Value, ttl_secs: u64) -> String {
|
|
181
|
+
let mut payload = claims;
|
|
182
|
+
payload["purpose"] = json!(purpose);
|
|
183
|
+
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))
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/// The claims of a token minted by `purpose_token` for the same `purpose`,
|
|
189
|
+
/// or `None` when the signature, the purpose or the expiry does not hold.
|
|
190
|
+
pub fn verify_purpose_token(secret: &str, purpose: &str, token: &str) -> Option<Value> {
|
|
191
|
+
let payload = verify_sig(&purpose_key(secret, purpose), token)?;
|
|
192
|
+
let claims: Value = serde_json::from_slice(&base64_decode(&payload)).ok()?;
|
|
193
|
+
if claims.get("purpose")?.as_str()? != purpose || now_secs() > claims.get("exp")?.as_u64()? {
|
|
194
|
+
return None;
|
|
195
|
+
}
|
|
196
|
+
Some(claims)
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
fn purpose_key(secret: &str, purpose: &str) -> String {
|
|
200
|
+
format!("{purpose}:{secret}")
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
pub fn session_cookie(secret: &str, session: &Session) -> String {
|
|
204
|
+
let payload = json!({
|
|
205
|
+
"identity_id": session.identity_id,
|
|
206
|
+
"email": session.email,
|
|
207
|
+
"name": session.name,
|
|
208
|
+
"role": session.role,
|
|
209
|
+
});
|
|
210
|
+
let encoded = base64_encode(payload.to_string().as_bytes());
|
|
211
|
+
format!("{encoded}.{}", sign(secret, &encoded))
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
pub fn parse_session_cookie(secret: &str, cookie: &str) -> Option<Session> {
|
|
215
|
+
let payload = verify_sig(secret, cookie)?;
|
|
216
|
+
let bytes = base64_decode(&payload);
|
|
217
|
+
let value: Value = serde_json::from_slice(&bytes).ok()?;
|
|
218
|
+
Some(Session {
|
|
219
|
+
identity_id: value.get("identity_id")?.as_str()?.to_string(),
|
|
220
|
+
email: value.get("email")?.as_str()?.to_string(),
|
|
221
|
+
name: value.get("name")?.as_str()?.to_string(),
|
|
222
|
+
role: value.get("role").and_then(|v| v.as_str()).map(|s| s.to_string()),
|
|
223
|
+
})
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/// Looks up `"Identity::ExternalIdentifier#{issuer}:{subject}"` in
|
|
227
|
+
/// `instances` — the same key shape used everywhere else there.
|
|
228
|
+
pub fn resolve_identity(instances: &Value, issuer: &str, subject: &str) -> Option<String> {
|
|
229
|
+
let key = format!("Identity::ExternalIdentifier#{issuer}:{subject}");
|
|
230
|
+
identity_id_from_state(instances.get(&key)?)
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
fn identity_id_from_state(state: &Value) -> Option<String> {
|
|
234
|
+
state
|
|
235
|
+
.get("identity")
|
|
236
|
+
.and_then(|v| v.as_str().map(str::to_string))
|
|
237
|
+
.or_else(|| {
|
|
238
|
+
state
|
|
239
|
+
.get("identity")
|
|
240
|
+
.and_then(|v| v.get("value"))
|
|
241
|
+
.and_then(|v| v.as_str())
|
|
242
|
+
.map(str::to_string)
|
|
243
|
+
})
|
|
244
|
+
.or_else(|| state.get("identity_id").and_then(|v| v.as_str().map(str::to_string)))
|
|
245
|
+
.or_else(|| {
|
|
246
|
+
state
|
|
247
|
+
.get("identity_id")
|
|
248
|
+
.and_then(|v| v.get("value"))
|
|
249
|
+
.and_then(|v| v.as_str())
|
|
250
|
+
.map(str::to_string)
|
|
251
|
+
})
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/// Identity lives in the PostgresEra head, not `dispatch::read`'s
|
|
255
|
+
/// `instances` — tries both the domain-qualified and the bare relation name.
|
|
256
|
+
pub async fn resolve_identity_from_head(
|
|
257
|
+
client: &Mutex<Client>,
|
|
258
|
+
domain_ir: &Value,
|
|
259
|
+
issuer: &str,
|
|
260
|
+
subject: &str,
|
|
261
|
+
) -> anyhow::Result<Option<String>> {
|
|
262
|
+
let Some(_) = crate::ir::identity_provider(domain_ir) else {
|
|
263
|
+
return Ok(None);
|
|
264
|
+
};
|
|
265
|
+
let domain = domain_ir.get("name").and_then(|v| v.as_str()).unwrap_or("");
|
|
266
|
+
let mut names = vec![
|
|
267
|
+
journal::head_view(domain, "external_identifier"),
|
|
268
|
+
journal::head_view("Identity", "external_identifier"),
|
|
269
|
+
"external_identifier_head".to_string(),
|
|
270
|
+
];
|
|
271
|
+
names.dedup();
|
|
272
|
+
let guard = client.lock().await;
|
|
273
|
+
for name in names {
|
|
274
|
+
let sql = format!(
|
|
275
|
+
"SELECT state FROM {} WHERE state->'issuer'->>'value' = $1 AND state->'subject'->>'value' = $2",
|
|
276
|
+
journal::quote_ident(&name)
|
|
277
|
+
);
|
|
278
|
+
match guard.query_opt(&sql, &[&issuer, &subject]).await {
|
|
279
|
+
Ok(Some(row)) => {
|
|
280
|
+
let state: Value = row.get(0);
|
|
281
|
+
return Ok(identity_id_from_state(&state));
|
|
282
|
+
}
|
|
283
|
+
Ok(None) => return Ok(None),
|
|
284
|
+
Err(e) if e.code() == Some(&tokio_postgres::error::SqlState::UNDEFINED_TABLE) => continue,
|
|
285
|
+
Err(e) => return Err(e.into()),
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
Ok(None)
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// Member lives in the PostgresEra head, not `dispatch::read`'s journal
|
|
292
|
+
// `instances` — reading `instances` for an already-linked member misses
|
|
293
|
+
// it entirely, so this queries the head view directly instead.
|
|
294
|
+
async fn member_row_by_email(client: &Mutex<Client>, domain_ir: &Value, email: &str) -> anyhow::Result<Option<Value>> {
|
|
295
|
+
let (_, storage_name) = membership_aggregate(domain_ir)?;
|
|
296
|
+
// docs/decisions/0059: `head_view` needs the domain name too, the
|
|
297
|
+
// same one `ir.rs`/`web.rs` already extract this way.
|
|
298
|
+
let domain = domain_ir.get("name").and_then(|v| v.as_str()).unwrap_or("");
|
|
299
|
+
let guard = client.lock().await;
|
|
300
|
+
journal::read_lineage_head_by_id(&*guard, domain, &storage_name, email).await
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
async fn member_rows(client: &Mutex<Client>, domain_ir: &Value) -> anyhow::Result<Vec<(String, Value)>> {
|
|
304
|
+
let (_, storage_name) = membership_aggregate(domain_ir)?;
|
|
305
|
+
let domain = domain_ir.get("name").and_then(|v| v.as_str()).unwrap_or("");
|
|
306
|
+
let guard = client.lock().await;
|
|
307
|
+
journal::read_lineage_head_all(&*guard, domain, &storage_name).await
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// The membership aggregate comes from `ir.json`'s declared `membership`
|
|
311
|
+
// key, never an env var or a lookup against lineage_capable_aggregates —
|
|
312
|
+
// the chapter declaration is the fact, same as Governance's authorization.
|
|
313
|
+
fn membership_aggregate(domain_ir: &Value) -> anyhow::Result<(String, String)> {
|
|
314
|
+
let provider = crate::ir::membership_provider(domain_ir).ok_or_else(|| {
|
|
315
|
+
anyhow::anyhow!(
|
|
316
|
+
"this domain attaches no chapter that provides \"membership\" — cannot resolve who may sign in"
|
|
317
|
+
)
|
|
318
|
+
})?;
|
|
319
|
+
Ok(membership_names(&provider.aggregate))
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
fn membership_names(aggregate: &str) -> (String, String) {
|
|
323
|
+
let bare = aggregate.rsplit("::").next().unwrap_or(aggregate).to_string();
|
|
324
|
+
let storage_name = crate::journal::snake(&bare);
|
|
325
|
+
(bare, storage_name)
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// Ported from `Adapters::PostgresEra#append` -- the same journal-insert
|
|
329
|
+
// + snapshot-upsert transaction every Member write uses. The advisory
|
|
330
|
+
// lock guards a concurrent domain rename, not ordinal uniqueness.
|
|
331
|
+
async fn append_member_state(client: &Mutex<Client>, config: &LineageConfig, domain_ir: &Value, id: &str, state: &Value) -> anyhow::Result<()> {
|
|
332
|
+
let (aggregate_name, _) = membership_aggregate(domain_ir)?;
|
|
333
|
+
let mut guard = client.lock().await;
|
|
334
|
+
let txn = guard.transaction().await?;
|
|
335
|
+
|
|
336
|
+
txn.execute(
|
|
337
|
+
"SELECT pg_advisory_xact_lock(hashtext('hecks_ordinal:' || $1))",
|
|
338
|
+
&[&config.domain],
|
|
339
|
+
)
|
|
340
|
+
.await?;
|
|
341
|
+
|
|
342
|
+
journal::append_lineage_mutation(
|
|
343
|
+
&txn,
|
|
344
|
+
config,
|
|
345
|
+
&journal::Mutation { aggregate: &aggregate_name, id, operation: "save", state },
|
|
346
|
+
)
|
|
347
|
+
.await?;
|
|
348
|
+
|
|
349
|
+
txn.commit().await?;
|
|
350
|
+
Ok(())
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
pub async fn session_for_member_by_identity(client: &Mutex<Client>, domain_ir: &Value, identity_id: &str) -> anyhow::Result<Option<Session>> {
|
|
354
|
+
let (_, storage_name) = membership_aggregate(domain_ir)?;
|
|
355
|
+
// docs/decisions/0059 — same domain-qualification as above.
|
|
356
|
+
let domain = domain_ir.get("name").and_then(|v| v.as_str()).unwrap_or("");
|
|
357
|
+
let guard = client.lock().await;
|
|
358
|
+
let row = guard
|
|
359
|
+
.query_opt(
|
|
360
|
+
&format!(
|
|
361
|
+
"SELECT state FROM {} WHERE state->'identity_id'->>'value' = $1",
|
|
362
|
+
journal::quote_ident(&journal::head_view(domain, &storage_name))
|
|
363
|
+
),
|
|
364
|
+
&[&identity_id],
|
|
365
|
+
)
|
|
366
|
+
.await?;
|
|
367
|
+
drop(guard);
|
|
368
|
+
Ok(row.and_then(|r| {
|
|
369
|
+
let state: Value = r.get(0);
|
|
370
|
+
if is_disabled(&state) {
|
|
371
|
+
return None;
|
|
372
|
+
}
|
|
373
|
+
Some(Session {
|
|
374
|
+
identity_id: identity_id.to_string(),
|
|
375
|
+
email: state.get("email")?.get("value")?.as_str()?.to_string(),
|
|
376
|
+
name: state.get("name")?.get("value")?.as_str()?.to_string(),
|
|
377
|
+
role: state.get("role").and_then(|v| v.get("value")).and_then(|v| v.as_str()).map(|s| s.to_string()),
|
|
378
|
+
})
|
|
379
|
+
}))
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/// Mints Identity/Governance facts for an already-granted Member —
|
|
383
|
+
/// never creates one; Admit is a separate, real cap-table event.
|
|
384
|
+
#[allow(clippy::too_many_arguments)]
|
|
385
|
+
pub async fn provision(
|
|
386
|
+
client: &Mutex<Client>,
|
|
387
|
+
wasm_path: &Path,
|
|
388
|
+
config: &LineageConfig,
|
|
389
|
+
domain_ir: &Value,
|
|
390
|
+
email: &str,
|
|
391
|
+
issuer: &str,
|
|
392
|
+
subject: &str,
|
|
393
|
+
invoker: &dyn LambdaInvoker,
|
|
394
|
+
) -> anyhow::Result<Option<Session>> {
|
|
395
|
+
let member = match member_row_by_email(client, domain_ir, email).await? {
|
|
396
|
+
Some(m) => m,
|
|
397
|
+
None => return Ok(None),
|
|
398
|
+
};
|
|
399
|
+
if is_disabled(&member) {
|
|
400
|
+
return Ok(None);
|
|
401
|
+
}
|
|
402
|
+
let role = member.get("role").and_then(|v| v.get("value")).and_then(|v| v.as_str());
|
|
403
|
+
let already_linked = member.get("identity_id").and_then(|v| v.get("value")).is_some();
|
|
404
|
+
let (Some(role), false) = (role, already_linked) else {
|
|
405
|
+
return Ok(None);
|
|
406
|
+
};
|
|
407
|
+
|
|
408
|
+
// Uses this domain's declared identity provider (`ir::identity_provider`),
|
|
409
|
+
// never a hard-coded chapter. Link's reference field is `identity`, not
|
|
410
|
+
// `identity_id` -- changing this silently breaks linking.
|
|
411
|
+
let Some(identity) = crate::ir::identity_provider(domain_ir) else {
|
|
412
|
+
anyhow::bail!("this domain attaches no chapter that provides \"identity\" — cannot register or link an identity");
|
|
413
|
+
};
|
|
414
|
+
|
|
415
|
+
// Reuse an Identity already registered+linked for this (issuer, subject)
|
|
416
|
+
// instead of always minting a fresh one. Register, link and the Member's
|
|
417
|
+
// own identity_id write below are three separate, non-transactional
|
|
418
|
+
// steps; a process restart or a retried callback between them used to
|
|
419
|
+
// leave an orphaned Identity (registered and externally linked, but
|
|
420
|
+
// never reaching the Member) while a later call minted yet another
|
|
421
|
+
// identity_id for the same Google account and wrote *that* onto the
|
|
422
|
+
// Member instead — the two permanently disagreeing from then on, so
|
|
423
|
+
// every subsequent sign-in resolved an identity_id the Member's
|
|
424
|
+
// already_linked guard above never recognized as unlinked. Resolving
|
|
425
|
+
// first makes a retry converge onto the earlier attempt's identity
|
|
426
|
+
// instead of accumulating another orphan.
|
|
427
|
+
let identity_id = match resolve_identity_from_head(client, domain_ir, issuer, subject).await? {
|
|
428
|
+
Some(existing) => existing,
|
|
429
|
+
None => {
|
|
430
|
+
let identity_id = uuid::Uuid::new_v4().to_string();
|
|
431
|
+
|
|
432
|
+
// `None` on every dispatch below: this is system-initiated
|
|
433
|
+
// provisioning, not a caller-submitted command, so there is no
|
|
434
|
+
// caller role to assert.
|
|
435
|
+
let register = dispatch::handle(
|
|
436
|
+
client, wasm_path, &identity.register,
|
|
437
|
+
json!({"identity_id": {"value": identity_id}}), None, config, invoker,
|
|
438
|
+
).await?;
|
|
439
|
+
if !register.accepted {
|
|
440
|
+
anyhow::bail!("{} refused: {}", identity.register, register.result);
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
let link_external = dispatch::handle(
|
|
444
|
+
client, wasm_path, &identity.link,
|
|
445
|
+
json!({
|
|
446
|
+
"identity": identity_id, "key": {"value": format!("{issuer}:{subject}")},
|
|
447
|
+
"issuer": {"value": issuer}, "subject": {"value": subject},
|
|
448
|
+
}), None, config, invoker,
|
|
449
|
+
).await?;
|
|
450
|
+
if !link_external.accepted {
|
|
451
|
+
anyhow::bail!("{} refused: {}", identity.link, link_external.result);
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
identity_id
|
|
455
|
+
}
|
|
456
|
+
};
|
|
457
|
+
|
|
458
|
+
// The grant verb this domain's declared authorization provider names
|
|
459
|
+
// (`ir::authorization_provider`), never a hard-coded chapter.
|
|
460
|
+
let Some(provider) = crate::ir::authorization_provider(domain_ir) else {
|
|
461
|
+
anyhow::bail!("this domain attaches no chapter that provides \"authorization\" — cannot grant a role");
|
|
462
|
+
};
|
|
463
|
+
let assign_role = dispatch::handle(
|
|
464
|
+
client, wasm_path, &provider.grant,
|
|
465
|
+
json!({
|
|
466
|
+
"actor_id": {"value": identity_id}, "role_name": {"value": role}, "scope": {"value": config.domain.clone()},
|
|
467
|
+
"starts_at": {"value": httpdate_now()},
|
|
468
|
+
}), None, config, invoker,
|
|
469
|
+
).await?;
|
|
470
|
+
if !assign_role.accepted {
|
|
471
|
+
anyhow::bail!("{} refused: {}", provider.grant, assign_role.result);
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
// Not dispatch::handle: Member isn't in the flat journal, so a
|
|
475
|
+
// WASM-replay dispatch here would rehydrate no prior steps and
|
|
476
|
+
// refuse. Writes through the same journal+snapshot append instead.
|
|
477
|
+
let mut new_state = member.clone();
|
|
478
|
+
new_state["identity_id"] = json!({"value": identity_id});
|
|
479
|
+
append_member_state(client, config, domain_ir, email, &new_state).await?;
|
|
480
|
+
|
|
481
|
+
let name = member.get("name").and_then(|v| v.get("value")).and_then(|v| v.as_str()).unwrap_or_default().to_string();
|
|
482
|
+
Ok(Some(Session { identity_id, email: email.to_string(), name, role: Some(role.to_string()) }))
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
pub async fn grant_access(
|
|
486
|
+
client: &Mutex<Client>,
|
|
487
|
+
wasm_path: &Path,
|
|
488
|
+
config: &LineageConfig,
|
|
489
|
+
domain_ir: &Value,
|
|
490
|
+
email: &str,
|
|
491
|
+
role: &str,
|
|
492
|
+
) -> anyhow::Result<bool> {
|
|
493
|
+
let _ = wasm_path;
|
|
494
|
+
let Some(member) = member_row_by_email(client, domain_ir, email).await? else {
|
|
495
|
+
return Ok(false);
|
|
496
|
+
};
|
|
497
|
+
|
|
498
|
+
// Same append protocol `provision` uses. GrantAccess's own bluebook
|
|
499
|
+
// command sets only `:role`, no other invariant to preserve here.
|
|
500
|
+
let mut new_state = member;
|
|
501
|
+
new_state["role"] = json!({"value": role});
|
|
502
|
+
append_member_state(client, config, domain_ir, email, &new_state).await?;
|
|
503
|
+
Ok(true)
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
/// Admits a new person (name + email), the same state `Admit` sets.
|
|
507
|
+
/// Returns `false` without writing when that email already exists.
|
|
508
|
+
pub async fn admit_person(
|
|
509
|
+
client: &Mutex<Client>,
|
|
510
|
+
config: &LineageConfig,
|
|
511
|
+
domain_ir: &Value,
|
|
512
|
+
email: &str,
|
|
513
|
+
name: &str,
|
|
514
|
+
) -> anyhow::Result<bool> {
|
|
515
|
+
let email = email.to_lowercase();
|
|
516
|
+
let exists = member_rows(client, domain_ir).await?.into_iter().any(|(id, _)| id.to_lowercase() == email);
|
|
517
|
+
if exists {
|
|
518
|
+
return Ok(false);
|
|
519
|
+
}
|
|
520
|
+
let state = json!({"name": {"value": name}, "email": {"value": email}});
|
|
521
|
+
append_member_state(client, config, domain_ir, &email, &state).await?;
|
|
522
|
+
Ok(true)
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
// The disabled flag sits beside the role rather than replacing it, so
|
|
526
|
+
// enabling a person again restores exactly the role they held.
|
|
527
|
+
fn is_disabled(state: &Value) -> bool {
|
|
528
|
+
state.get("disabled").and_then(|v| v.as_bool()).unwrap_or(false)
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
// A deleted person's row is never erased (this is an append-only journal,
|
|
532
|
+
// same as every other aggregate here) — "deleted" only means all_people
|
|
533
|
+
// stops listing it. There is deliberately no undelete route: re-admitting
|
|
534
|
+
// the same email starts a fresh row rather than reviving this one.
|
|
535
|
+
fn is_deleted(state: &Value) -> bool {
|
|
536
|
+
state.get("deleted").and_then(|v| v.as_bool()).unwrap_or(false)
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
// The role name a membership row holds, if any.
|
|
540
|
+
fn role_of(state: &Value) -> Option<&str> {
|
|
541
|
+
state.get("role").and_then(|v| v.get("value")).and_then(|v| v.as_str())
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
/// The roles that pass the admin gate. "Owner" is a superset of "Admin" —
|
|
545
|
+
/// a person holds one role, so Owner must pass this gate too.
|
|
546
|
+
pub const ADMIN_ROLES: [&str; 2] = ["Admin", "Owner"];
|
|
547
|
+
|
|
548
|
+
/// The roles the grant routes will assign. Granting "Owner" is deliberately
|
|
549
|
+
/// open to any admin, because the first Owner has to be granted by an Admin.
|
|
550
|
+
pub const GRANTABLE_ROLES: [&str; 3] = ["Admin", "Owner", "Member"];
|
|
551
|
+
|
|
552
|
+
/// Whether `role` passes the admin gate.
|
|
553
|
+
pub fn is_admin_role(role: &str) -> bool {
|
|
554
|
+
ADMIN_ROLES.contains(&role)
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
// Whether the row holds an admin role ("Admin" or "Owner") and is not disabled.
|
|
558
|
+
fn is_active_admin(state: &Value) -> bool {
|
|
559
|
+
role_of(state).is_some_and(is_admin_role) && !is_disabled(state)
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
// Whether `state` is the only active admin (Admin or Owner) among `rows`.
|
|
563
|
+
fn is_last_active_admin(rows: &[(String, Value)], state: &Value) -> bool {
|
|
564
|
+
is_active_admin(state) && rows.iter().filter(|(_, s)| is_active_admin(s)).count() <= 1
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
/// Whether the person with `email` (compared case-insensitively) is an
|
|
568
|
+
/// active admin: granted the "Admin" or "Owner" role and not disabled.
|
|
569
|
+
pub async fn caller_is_admin(client: &Mutex<Client>, domain_ir: &Value, email: &str) -> anyhow::Result<bool> {
|
|
570
|
+
let email = email.to_lowercase();
|
|
571
|
+
Ok(member_rows(client, domain_ir).await?.iter().any(|(id, state)| id.to_lowercase() == email && is_active_admin(state)))
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
/// Whether `email` may hold a session right now: admitted, granted a role,
|
|
575
|
+
/// and not disabled — a signature alone can't tell, since a cookie outlives a later disable.
|
|
576
|
+
pub async fn has_access(client: &Mutex<Client>, domain_ir: &Value, email: &str) -> anyhow::Result<bool> {
|
|
577
|
+
let email = email.to_lowercase();
|
|
578
|
+
Ok(member_rows(client, domain_ir)
|
|
579
|
+
.await?
|
|
580
|
+
.iter()
|
|
581
|
+
.any(|(id, state)| id.to_lowercase() == email && role_of(state).is_some() && !is_disabled(state)))
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
/// The role `email` holds right now, or `None` if unknown, roleless, or
|
|
585
|
+
/// disabled — reads the live membership head, never the session cookie.
|
|
586
|
+
pub async fn active_role(client: &Mutex<Client>, domain_ir: &Value, email: &str) -> anyhow::Result<Option<String>> {
|
|
587
|
+
let email = email.to_lowercase();
|
|
588
|
+
Ok(member_rows(client, domain_ir)
|
|
589
|
+
.await?
|
|
590
|
+
.iter()
|
|
591
|
+
.find(|(id, state)| id.to_lowercase() == email && !is_disabled(state))
|
|
592
|
+
.and_then(|(_, state)| role_of(state).map(String::from)))
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
/// Every admitted person as a JSON row: name, email, role, `linked`
|
|
596
|
+
/// (signed in), `granted` (has access), and `disabled`.
|
|
597
|
+
pub async fn all_people(client: &Mutex<Client>, domain_ir: &Value) -> anyhow::Result<Vec<Value>> {
|
|
598
|
+
Ok(member_rows(client, domain_ir)
|
|
599
|
+
.await?
|
|
600
|
+
.into_iter()
|
|
601
|
+
.filter(|(_, state)| !is_deleted(state))
|
|
602
|
+
.map(|(_, state)| {
|
|
603
|
+
let role = role_of(&state);
|
|
604
|
+
let linked = state.get("identity_id").and_then(|v| v.get("value")).is_some();
|
|
605
|
+
let disabled = is_disabled(&state);
|
|
606
|
+
json!({
|
|
607
|
+
"name": state.get("name").and_then(|v| v.get("value")),
|
|
608
|
+
"email": state.get("email").and_then(|v| v.get("value")),
|
|
609
|
+
"role": role,
|
|
610
|
+
"linked": linked,
|
|
611
|
+
"granted": role.is_some() && !disabled,
|
|
612
|
+
"disabled": disabled,
|
|
613
|
+
})
|
|
614
|
+
})
|
|
615
|
+
.collect())
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
/// What `set_person_disabled` decided.
|
|
619
|
+
#[derive(Debug, PartialEq, Eq)]
|
|
620
|
+
pub enum DisableOutcome {
|
|
621
|
+
/// The person is now in the requested state, whether or not a write was needed.
|
|
622
|
+
Done,
|
|
623
|
+
/// The caller is not an active Admin.
|
|
624
|
+
CallerNotAdmin,
|
|
625
|
+
/// An admin tried to disable themselves.
|
|
626
|
+
SelfDisable,
|
|
627
|
+
/// No person with that email exists.
|
|
628
|
+
UnknownPerson,
|
|
629
|
+
/// Disabling would leave no active Admin.
|
|
630
|
+
LastAdmin,
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
/// Disables or enables `target` for `caller`, keeping role and identity
|
|
634
|
+
/// link — locked with other membership writes so concurrent disables can't both win.
|
|
635
|
+
pub async fn set_person_disabled(
|
|
636
|
+
client: &Mutex<Client>,
|
|
637
|
+
config: &LineageConfig,
|
|
638
|
+
domain_ir: &Value,
|
|
639
|
+
caller: &str,
|
|
640
|
+
target: &str,
|
|
641
|
+
disable: bool,
|
|
642
|
+
) -> anyhow::Result<DisableOutcome> {
|
|
643
|
+
let (aggregate_name, storage_name) = membership_aggregate(domain_ir)?;
|
|
644
|
+
let domain = domain_ir.get("name").and_then(|v| v.as_str()).unwrap_or("");
|
|
645
|
+
let (caller, target) = (caller.trim().to_lowercase(), target.trim().to_lowercase());
|
|
646
|
+
|
|
647
|
+
let mut guard = client.lock().await;
|
|
648
|
+
let txn = guard.transaction().await?;
|
|
649
|
+
txn.execute("SELECT pg_advisory_xact_lock(hashtext('hecks_ordinal:' || $1))", &[&config.domain]).await?;
|
|
650
|
+
let rows = journal::read_lineage_head_all(&txn, domain, &storage_name).await?;
|
|
651
|
+
|
|
652
|
+
if !rows.iter().any(|(id, state)| id.to_lowercase() == caller && is_active_admin(state)) {
|
|
653
|
+
return Ok(DisableOutcome::CallerNotAdmin);
|
|
654
|
+
}
|
|
655
|
+
if disable && caller == target {
|
|
656
|
+
return Ok(DisableOutcome::SelfDisable);
|
|
657
|
+
}
|
|
658
|
+
let Some((id, state)) = rows.iter().find(|(id, _)| id.to_lowercase() == target) else {
|
|
659
|
+
return Ok(DisableOutcome::UnknownPerson);
|
|
660
|
+
};
|
|
661
|
+
if is_disabled(state) == disable {
|
|
662
|
+
return Ok(DisableOutcome::Done);
|
|
663
|
+
}
|
|
664
|
+
if disable && is_last_active_admin(&rows, state) {
|
|
665
|
+
return Ok(DisableOutcome::LastAdmin);
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
let mut new_state = state.clone();
|
|
669
|
+
if disable {
|
|
670
|
+
new_state["disabled"] = json!(true);
|
|
671
|
+
} else if let Some(fields) = new_state.as_object_mut() {
|
|
672
|
+
fields.remove("disabled");
|
|
673
|
+
}
|
|
674
|
+
journal::append_lineage_mutation(
|
|
675
|
+
&txn,
|
|
676
|
+
config,
|
|
677
|
+
&journal::Mutation { aggregate: &aggregate_name, id, operation: "save", state: &new_state },
|
|
678
|
+
)
|
|
679
|
+
.await?;
|
|
680
|
+
txn.commit().await?;
|
|
681
|
+
Ok(DisableOutcome::Done)
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
/// What `set_person_deleted` decided.
|
|
685
|
+
#[derive(Debug, PartialEq, Eq)]
|
|
686
|
+
pub enum DeleteOutcome {
|
|
687
|
+
/// The person is now deleted (or already was).
|
|
688
|
+
Done,
|
|
689
|
+
/// The caller is not an active Admin.
|
|
690
|
+
CallerNotAdmin,
|
|
691
|
+
/// No person with that email exists.
|
|
692
|
+
UnknownPerson,
|
|
693
|
+
/// `target` must be disabled first — deleting an active admin outright
|
|
694
|
+
/// skips the "are you sure" step disabling them already forces.
|
|
695
|
+
NotDisabled,
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
/// Soft-deletes an already-disabled `target` on `caller`'s behalf: the row
|
|
699
|
+
/// stays in the journal (nothing here is ever erased) but `all_people` stops
|
|
700
|
+
/// listing it. Locked with other membership writes the same as disable/role.
|
|
701
|
+
pub async fn set_person_deleted(
|
|
702
|
+
client: &Mutex<Client>,
|
|
703
|
+
config: &LineageConfig,
|
|
704
|
+
domain_ir: &Value,
|
|
705
|
+
caller: &str,
|
|
706
|
+
target: &str,
|
|
707
|
+
) -> anyhow::Result<DeleteOutcome> {
|
|
708
|
+
let (aggregate_name, storage_name) = membership_aggregate(domain_ir)?;
|
|
709
|
+
let domain = domain_ir.get("name").and_then(|v| v.as_str()).unwrap_or("");
|
|
710
|
+
let (caller, target) = (caller.trim().to_lowercase(), target.trim().to_lowercase());
|
|
711
|
+
|
|
712
|
+
let mut guard = client.lock().await;
|
|
713
|
+
let txn = guard.transaction().await?;
|
|
714
|
+
txn.execute("SELECT pg_advisory_xact_lock(hashtext('hecks_ordinal:' || $1))", &[&config.domain]).await?;
|
|
715
|
+
let rows = journal::read_lineage_head_all(&txn, domain, &storage_name).await?;
|
|
716
|
+
|
|
717
|
+
if !rows.iter().any(|(id, state)| id.to_lowercase() == caller && is_active_admin(state)) {
|
|
718
|
+
return Ok(DeleteOutcome::CallerNotAdmin);
|
|
719
|
+
}
|
|
720
|
+
let Some((id, state)) = rows.iter().find(|(id, _)| id.to_lowercase() == target) else {
|
|
721
|
+
return Ok(DeleteOutcome::UnknownPerson);
|
|
722
|
+
};
|
|
723
|
+
if is_deleted(state) {
|
|
724
|
+
return Ok(DeleteOutcome::Done);
|
|
725
|
+
}
|
|
726
|
+
if !is_disabled(state) {
|
|
727
|
+
return Ok(DeleteOutcome::NotDisabled);
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
let mut new_state = state.clone();
|
|
731
|
+
new_state["deleted"] = json!(true);
|
|
732
|
+
journal::append_lineage_mutation(
|
|
733
|
+
&txn,
|
|
734
|
+
config,
|
|
735
|
+
&journal::Mutation { aggregate: &aggregate_name, id, operation: "save", state: &new_state },
|
|
736
|
+
)
|
|
737
|
+
.await?;
|
|
738
|
+
txn.commit().await?;
|
|
739
|
+
Ok(DeleteOutcome::Done)
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
/// What `set_person_role` decided.
|
|
743
|
+
#[derive(Debug, PartialEq, Eq)]
|
|
744
|
+
pub enum RoleOutcome {
|
|
745
|
+
/// The person now holds the requested role, whether or not a write was needed.
|
|
746
|
+
Done,
|
|
747
|
+
/// The caller is not an active Admin or Owner.
|
|
748
|
+
CallerNotAdmin,
|
|
749
|
+
/// No person with that email exists.
|
|
750
|
+
UnknownPerson,
|
|
751
|
+
/// The change would leave no active Admin or Owner.
|
|
752
|
+
LastAdmin,
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
/// Sets `target`'s role on `caller`'s behalf; any active admin may
|
|
756
|
+
/// grant any role, Owner included, locked against concurrent demotions.
|
|
757
|
+
pub async fn set_person_role(
|
|
758
|
+
client: &Mutex<Client>,
|
|
759
|
+
config: &LineageConfig,
|
|
760
|
+
domain_ir: &Value,
|
|
761
|
+
caller: &str,
|
|
762
|
+
target: &str,
|
|
763
|
+
role: &str,
|
|
764
|
+
) -> anyhow::Result<RoleOutcome> {
|
|
765
|
+
let (aggregate_name, storage_name) = membership_aggregate(domain_ir)?;
|
|
766
|
+
let domain = domain_ir.get("name").and_then(|v| v.as_str()).unwrap_or("");
|
|
767
|
+
let (caller, target) = (caller.trim().to_lowercase(), target.trim().to_lowercase());
|
|
768
|
+
|
|
769
|
+
let mut guard = client.lock().await;
|
|
770
|
+
let txn = guard.transaction().await?;
|
|
771
|
+
txn.execute("SELECT pg_advisory_xact_lock(hashtext('hecks_ordinal:' || $1))", &[&config.domain]).await?;
|
|
772
|
+
let rows = journal::read_lineage_head_all(&txn, domain, &storage_name).await?;
|
|
773
|
+
|
|
774
|
+
if !rows.iter().any(|(id, state)| id.to_lowercase() == caller && is_active_admin(state)) {
|
|
775
|
+
return Ok(RoleOutcome::CallerNotAdmin);
|
|
776
|
+
}
|
|
777
|
+
let Some((id, state)) = rows.iter().find(|(id, _)| id.to_lowercase() == target) else {
|
|
778
|
+
return Ok(RoleOutcome::UnknownPerson);
|
|
779
|
+
};
|
|
780
|
+
if role_of(state) == Some(role) {
|
|
781
|
+
return Ok(RoleOutcome::Done);
|
|
782
|
+
}
|
|
783
|
+
if !is_admin_role(role) && is_last_active_admin(&rows, state) {
|
|
784
|
+
return Ok(RoleOutcome::LastAdmin);
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
let mut new_state = state.clone();
|
|
788
|
+
new_state["role"] = json!({"value": role});
|
|
789
|
+
journal::append_lineage_mutation(
|
|
790
|
+
&txn,
|
|
791
|
+
config,
|
|
792
|
+
&journal::Mutation { aggregate: &aggregate_name, id, operation: "save", state: &new_state },
|
|
793
|
+
)
|
|
794
|
+
.await?;
|
|
795
|
+
txn.commit().await?;
|
|
796
|
+
Ok(RoleOutcome::Done)
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
/// Whether `identity_id` holds a live Admin/Owner assignment in this
|
|
800
|
+
/// domain's authorization provider; `None` provider means never admin.
|
|
801
|
+
pub fn holds_admin(instances: &Value, identity_id: &str, provider: Option<&crate::ir::AuthorizationProvider>) -> bool {
|
|
802
|
+
let Some(provider) = provider else { return false };
|
|
803
|
+
let prefix = format!("{}#", provider.assignment_aggregate);
|
|
804
|
+
instances.as_object().is_some_and(|obj| {
|
|
805
|
+
obj.iter().any(|(key, state)| {
|
|
806
|
+
key.starts_with(&prefix)
|
|
807
|
+
&& state.get("actor_id").and_then(|v| v.get("value")).and_then(|v| v.as_str()) == Some(identity_id)
|
|
808
|
+
&& state.get("role_name").and_then(|v| v.get("value")).and_then(|v| v.as_str()).is_some_and(is_admin_role)
|
|
809
|
+
&& state.get("ends_at").map(|v| v.is_null()).unwrap_or(true)
|
|
810
|
+
})
|
|
811
|
+
})
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
fn now_secs() -> u64 {
|
|
815
|
+
SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs()
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
// `dispatch.rs`'s `occurred_at` stamping reuses this exact rendering
|
|
819
|
+
// rather than re-deriving it — see the body comment for the format
|
|
820
|
+
// guarantee both call sites depend on.
|
|
821
|
+
pub(crate) fn httpdate_now() -> String {
|
|
822
|
+
// ISO 8601 UTC, matching Ruby's Time.now.utc.iso8601 -- Governance::
|
|
823
|
+
// RoleAssignment.starts_at only needs to parse as a real instant,
|
|
824
|
+
// never re-derived from or compared against wall-clock time here.
|
|
825
|
+
let secs = now_secs();
|
|
826
|
+
let days = secs / 86_400;
|
|
827
|
+
let rem = secs % 86_400;
|
|
828
|
+
let (h, m, s) = (rem / 3600, (rem % 3600) / 60, rem % 60);
|
|
829
|
+
let (y, mo, d) = civil_from_days(days as i64);
|
|
830
|
+
format!("{y:04}-{mo:02}-{d:02}T{h:02}:{m:02}:{s:02}Z")
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
// Howard Hinnant's days-from-civil algorithm, inverted -- avoids
|
|
834
|
+
// pulling in a full date/time crate for one timestamp format.
|
|
835
|
+
fn civil_from_days(z: i64) -> (i64, u32, u32) {
|
|
836
|
+
let z = z + 719_468;
|
|
837
|
+
let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
|
|
838
|
+
let doe = (z - era * 146_097) as u64;
|
|
839
|
+
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
|
|
840
|
+
let y = yoe as i64 + era * 400;
|
|
841
|
+
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
|
842
|
+
let mp = (5 * doy + 2) / 153;
|
|
843
|
+
let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
|
|
844
|
+
let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
|
|
845
|
+
(if m <= 2 { y + 1 } else { y }, m, d)
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
fn sign(secret: &str, payload: &str) -> String {
|
|
849
|
+
use hmac::{Hmac, Mac};
|
|
850
|
+
use sha2::Sha256;
|
|
851
|
+
let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes()).expect("HMAC accepts any key length");
|
|
852
|
+
mac.update(payload.as_bytes());
|
|
853
|
+
hex_encode(&mac.finalize().into_bytes())
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
fn verify_sig(secret: &str, token: &str) -> Option<String> {
|
|
857
|
+
let (payload, sig) = token.rsplit_once('.')?;
|
|
858
|
+
if sign(secret, payload) == sig {
|
|
859
|
+
Some(payload.to_string())
|
|
860
|
+
} else {
|
|
861
|
+
None
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
fn hex_encode(bytes: &[u8]) -> String {
|
|
866
|
+
bytes.iter().map(|b| format!("{b:02x}")).collect()
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
const B64: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
|
|
870
|
+
|
|
871
|
+
fn base64_encode(input: &[u8]) -> String {
|
|
872
|
+
let mut out = String::new();
|
|
873
|
+
for chunk in input.chunks(3) {
|
|
874
|
+
let b = [chunk[0], *chunk.get(1).unwrap_or(&0), *chunk.get(2).unwrap_or(&0)];
|
|
875
|
+
let n = ((b[0] as u32) << 16) | ((b[1] as u32) << 8) | b[2] as u32;
|
|
876
|
+
out.push(B64[(n >> 18) as usize & 0x3f] as char);
|
|
877
|
+
out.push(B64[(n >> 12) as usize & 0x3f] as char);
|
|
878
|
+
if chunk.len() > 1 {
|
|
879
|
+
out.push(B64[(n >> 6) as usize & 0x3f] as char);
|
|
880
|
+
}
|
|
881
|
+
if chunk.len() > 2 {
|
|
882
|
+
out.push(B64[n as usize & 0x3f] as char);
|
|
883
|
+
}
|
|
884
|
+
}
|
|
885
|
+
out
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
fn base64_decode(input: &str) -> Vec<u8> {
|
|
889
|
+
let mut table = [255u8; 256];
|
|
890
|
+
for (i, &c) in B64.iter().enumerate() {
|
|
891
|
+
table[c as usize] = i as u8;
|
|
892
|
+
}
|
|
893
|
+
let mut out = Vec::new();
|
|
894
|
+
let mut buf = 0u32;
|
|
895
|
+
let mut bits = 0u32;
|
|
896
|
+
for c in input.bytes() {
|
|
897
|
+
let v = table[c as usize];
|
|
898
|
+
if v == 255 {
|
|
899
|
+
continue;
|
|
900
|
+
}
|
|
901
|
+
buf = (buf << 6) | v as u32;
|
|
902
|
+
bits += 6;
|
|
903
|
+
if bits >= 8 {
|
|
904
|
+
bits -= 8;
|
|
905
|
+
out.push((buf >> bits) as u8);
|
|
906
|
+
}
|
|
907
|
+
}
|
|
908
|
+
out
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
pub(crate) fn urlencode(s: &str) -> String {
|
|
912
|
+
let mut out = String::new();
|
|
913
|
+
for b in s.bytes() {
|
|
914
|
+
match b {
|
|
915
|
+
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => out.push(b as char),
|
|
916
|
+
_ => out.push_str(&format!("%{b:02X}")),
|
|
917
|
+
}
|
|
918
|
+
}
|
|
919
|
+
out
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
#[cfg(test)]
|
|
923
|
+
mod tests {
|
|
924
|
+
use super::*;
|
|
925
|
+
|
|
926
|
+
#[test]
|
|
927
|
+
fn base64_round_trips_arbitrary_bytes() {
|
|
928
|
+
for input in [b"".as_slice(), b"a", b"ab", b"abc", b"abcd", b"\x00\xff\x10hello world!"] {
|
|
929
|
+
let encoded = base64_encode(input);
|
|
930
|
+
assert!(!encoded.contains('+') && !encoded.contains('/'), "must be URL-safe: {encoded}");
|
|
931
|
+
assert_eq!(base64_decode(&encoded), input, "round trip failed for {input:?}");
|
|
932
|
+
}
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
#[test]
|
|
936
|
+
fn signed_token_verifies_with_the_right_secret_and_rejects_tampering() {
|
|
937
|
+
let token = sign_test_token("s3cret", "hello");
|
|
938
|
+
assert_eq!(verify_sig("s3cret", &token), Some("hello".to_string()));
|
|
939
|
+
assert_eq!(verify_sig("wrong-secret", &token), None);
|
|
940
|
+
|
|
941
|
+
let tampered = token.replace("hello", "hellp");
|
|
942
|
+
assert_eq!(verify_sig("s3cret", &tampered), None);
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
fn sign_test_token(secret: &str, payload: &str) -> String {
|
|
946
|
+
format!("{payload}.{}", sign(secret, payload))
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
#[test]
|
|
950
|
+
fn account_token_round_trips_and_rejects_tampering_or_the_wrong_secret() {
|
|
951
|
+
let token = account_token("s3cret", "chris@example.com", 60);
|
|
952
|
+
assert_eq!(verify_account_token("s3cret", &token).as_deref(), Some("chris@example.com"));
|
|
953
|
+
assert_eq!(verify_account_token("wrong-secret", &token), None);
|
|
954
|
+
assert_eq!(verify_account_token("s3cret", "garbage.notasignature"), None);
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
#[test]
|
|
958
|
+
fn account_token_rejects_once_expired() {
|
|
959
|
+
// ttl_secs=0 -- now_secs() + 0 is already <= now_secs() by the
|
|
960
|
+
// time verify_account_token's own now_secs() check runs.
|
|
961
|
+
let token = account_token("s3cret", "chris@example.com", 0);
|
|
962
|
+
std::thread::sleep(std::time::Duration::from_secs(1));
|
|
963
|
+
assert_eq!(verify_account_token("s3cret", &token), None);
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
// The known-answer vector packages/hecks-client's account token tests
|
|
967
|
+
// check too (test/accountToken.test.mjs): a fixed payload, so both sides
|
|
968
|
+
// agree on the exact bytes a token carries, not only that each round-trips.
|
|
969
|
+
#[test]
|
|
970
|
+
fn account_token_matches_the_known_answer_vector() {
|
|
971
|
+
let payload = r#"{"email":"chris@example.com","exp":4102444800}"#;
|
|
972
|
+
let encoded = base64_encode(payload.as_bytes());
|
|
973
|
+
assert_eq!(encoded, "eyJlbWFpbCI6ImNocmlzQGV4YW1wbGUuY29tIiwiZXhwIjo0MTAyNDQ0ODAwfQ");
|
|
974
|
+
assert_eq!(sign("s3cret", &encoded), "143ebe224b067f9744b509937358bb39a87ed30df06aaec594934b85a288cade");
|
|
975
|
+
let token = sign_test_token("s3cret", &encoded);
|
|
976
|
+
assert_eq!(verify_account_token("s3cret", &token).as_deref(), Some("chris@example.com"));
|
|
977
|
+
// account_token writes the same payload shape: keys in sorted order, no spaces.
|
|
978
|
+
let minted = account_token("s3cret", "chris@example.com", 60);
|
|
979
|
+
let (minted_payload, _) = minted.rsplit_once('.').unwrap();
|
|
980
|
+
let claims: Value = serde_json::from_slice(&base64_decode(minted_payload)).unwrap();
|
|
981
|
+
assert_eq!(String::from_utf8(base64_decode(minted_payload)).unwrap(), format!(r#"{{"email":"chris@example.com","exp":{}}}"#, claims["exp"]));
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
#[test]
|
|
985
|
+
fn the_account_cookie_defaults_when_unset_or_empty() {
|
|
986
|
+
assert_eq!(resolve_account_cookie(None).unwrap(), DEFAULT_ACCOUNT_COOKIE);
|
|
987
|
+
assert_eq!(resolve_account_cookie(Some("")).unwrap(), DEFAULT_ACCOUNT_COOKIE);
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
#[test]
|
|
991
|
+
fn the_default_account_cookie_is_the_neutral_hecks_name() {
|
|
992
|
+
assert_eq!(DEFAULT_ACCOUNT_COOKIE, "hecks_session");
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
#[test]
|
|
996
|
+
fn the_account_cookie_takes_a_configured_valid_name() {
|
|
997
|
+
assert_eq!(resolve_account_cookie(Some("hecks_session")).unwrap(), "hecks_session");
|
|
998
|
+
assert_eq!(resolve_account_cookie(Some("app-session.v2")).unwrap(), "app-session.v2");
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
#[test]
|
|
1002
|
+
fn the_account_cookie_refuses_a_name_that_could_break_the_set_cookie_header() {
|
|
1003
|
+
for bad in ["a;b", "a=b", "a b", "a\r\nSet-Cookie: x", "sessão"] {
|
|
1004
|
+
assert!(resolve_account_cookie(Some(bad)).is_err(), "{bad:?} should be refused");
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
#[test]
|
|
1009
|
+
fn session_cookie_round_trips_and_rejects_a_forged_one() {
|
|
1010
|
+
let session = Session {
|
|
1011
|
+
identity_id: "id-1".to_string(),
|
|
1012
|
+
email: "chris@example.com".to_string(),
|
|
1013
|
+
name: "Chris Young".to_string(),
|
|
1014
|
+
role: Some("Admin".to_string()),
|
|
1015
|
+
};
|
|
1016
|
+
let cookie = session_cookie("s3cret", &session);
|
|
1017
|
+
let parsed = parse_session_cookie("s3cret", &cookie).expect("should parse with the right secret");
|
|
1018
|
+
assert_eq!(parsed.identity_id, "id-1");
|
|
1019
|
+
assert_eq!(parsed.email, "chris@example.com");
|
|
1020
|
+
assert_eq!(parsed.role.as_deref(), Some("Admin"));
|
|
1021
|
+
|
|
1022
|
+
assert!(parse_session_cookie("wrong-secret", &cookie).is_none());
|
|
1023
|
+
assert!(parse_session_cookie("s3cret", "garbage.notasignature").is_none());
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
#[test]
|
|
1027
|
+
fn oauth_state_verifies_fresh_and_rejects_forged_or_expired() {
|
|
1028
|
+
let fresh = now_secs().to_string();
|
|
1029
|
+
let state = format!("{fresh}.{}", sign("s3cret", &fresh));
|
|
1030
|
+
assert!(verify_state(&state, "s3cret").is_ok());
|
|
1031
|
+
assert!(verify_state(&state, "wrong-secret").is_err());
|
|
1032
|
+
|
|
1033
|
+
let stale = (now_secs() - STATE_TTL_SECS - 1).to_string();
|
|
1034
|
+
let expired = format!("{stale}.{}", sign("s3cret", &stale));
|
|
1035
|
+
assert!(verify_state(&expired, "s3cret").is_err());
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
#[test]
|
|
1039
|
+
fn resolve_identity_finds_the_linked_external_identifier() {
|
|
1040
|
+
let instances = json!({
|
|
1041
|
+
"Identity::ExternalIdentifier#google:sub-1": {"identity_id": "id-1", "issuer": {"value": "google"}, "subject": {"value": "sub-1"}},
|
|
1042
|
+
});
|
|
1043
|
+
|
|
1044
|
+
let identity_id = resolve_identity(&instances, "google", "sub-1");
|
|
1045
|
+
assert_eq!(identity_id.as_deref(), Some("id-1"));
|
|
1046
|
+
assert!(resolve_identity(&instances, "google", "nope").is_none());
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
#[test]
|
|
1050
|
+
fn resolve_identity_reads_the_2_0_identity_field() {
|
|
1051
|
+
let instances = json!({
|
|
1052
|
+
"Identity::ExternalIdentifier#https://accounts.google.com:sub-1": {
|
|
1053
|
+
"identity": "id-2",
|
|
1054
|
+
"issuer": {"value": "https://accounts.google.com"},
|
|
1055
|
+
"subject": {"value": "sub-1"},
|
|
1056
|
+
},
|
|
1057
|
+
});
|
|
1058
|
+
assert_eq!(
|
|
1059
|
+
resolve_identity(&instances, "https://accounts.google.com", "sub-1").as_deref(),
|
|
1060
|
+
Some("id-2")
|
|
1061
|
+
);
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
#[test]
|
|
1065
|
+
fn holds_admin_reads_straight_off_instances() {
|
|
1066
|
+
let instances = json!({
|
|
1067
|
+
"Governance::RoleAssignment#id-1:Admin": {
|
|
1068
|
+
"actor_id": {"value": "id-1"}, "role_name": {"value": "Admin"}, "ends_at": null,
|
|
1069
|
+
},
|
|
1070
|
+
});
|
|
1071
|
+
|
|
1072
|
+
let provider = crate::ir::AuthorizationProvider {
|
|
1073
|
+
grant: "Governance::RoleAssignment.Assign".to_string(),
|
|
1074
|
+
assignment_aggregate: "Governance::RoleAssignment".to_string(),
|
|
1075
|
+
};
|
|
1076
|
+
assert!(holds_admin(&instances, "id-1", Some(&provider)));
|
|
1077
|
+
assert!(!holds_admin(&instances, "id-2", Some(&provider)));
|
|
1078
|
+
// No declared provider: no assignment can exist, so never admin.
|
|
1079
|
+
assert!(!holds_admin(&instances, "id-1", None));
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
#[test]
|
|
1083
|
+
fn is_last_active_admin_counts_only_admins_that_are_not_disabled() {
|
|
1084
|
+
let admin = json!({"role": {"value": "Admin"}});
|
|
1085
|
+
let disabled_admin = json!({"role": {"value": "Admin"}, "disabled": true});
|
|
1086
|
+
let member = json!({"role": {"value": "Member"}});
|
|
1087
|
+
let rows = |states: &[&Value]| -> Vec<(String, Value)> { states.iter().enumerate().map(|(i, s)| (i.to_string(), (*s).clone())).collect() };
|
|
1088
|
+
|
|
1089
|
+
assert!(is_last_active_admin(&rows(&[&admin, &member, &disabled_admin]), &admin), "the only active admin");
|
|
1090
|
+
assert!(!is_last_active_admin(&rows(&[&admin, &admin]), &admin), "another active admin remains");
|
|
1091
|
+
assert!(!is_last_active_admin(&rows(&[&admin, &disabled_admin]), &disabled_admin), "a disabled admin is not an active one");
|
|
1092
|
+
assert!(!is_last_active_admin(&rows(&[&admin, &member]), &member), "a non-admin never counts");
|
|
1093
|
+
|
|
1094
|
+
// An Owner is an admin too: it counts toward, and is protected by, the guard.
|
|
1095
|
+
let owner = json!({"role": {"value": "Owner"}});
|
|
1096
|
+
assert!(is_last_active_admin(&rows(&[&owner, &member]), &owner), "the only active admin may be an Owner");
|
|
1097
|
+
assert!(!is_last_active_admin(&rows(&[&owner, &admin]), &admin), "an Owner keeps another admin from being the last");
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
#[test]
|
|
1101
|
+
fn holds_admin_accepts_an_owner_assignment_and_refuses_other_roles() {
|
|
1102
|
+
let provider = crate::ir::AuthorizationProvider { grant: "G".to_string(), assignment_aggregate: "Governance::RoleAssignment".to_string() };
|
|
1103
|
+
let assignment = |role: &str| json!({"Governance::RoleAssignment#a": {"actor_id": {"value": "id-1"}, "role_name": {"value": role}, "ends_at": null}});
|
|
1104
|
+
assert!(holds_admin(&assignment("Admin"), "id-1", Some(&provider)));
|
|
1105
|
+
assert!(holds_admin(&assignment("Owner"), "id-1", Some(&provider)));
|
|
1106
|
+
assert!(!holds_admin(&assignment("Member"), "id-1", Some(&provider)));
|
|
1107
|
+
assert!(!holds_admin(&assignment("owner"), "id-1", Some(&provider)));
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
#[test]
|
|
1111
|
+
fn membership_names_snakes_the_bare_aggregate() {
|
|
1112
|
+
assert_eq!(membership_names("Member"), ("Member".to_string(), "member".to_string()));
|
|
1113
|
+
assert_eq!(membership_names("Membership::Person"), ("Person".to_string(), "person".to_string()));
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
#[test]
|
|
1117
|
+
fn membership_aggregate_reads_the_declared_capability_not_an_env_var() {
|
|
1118
|
+
let domain_ir = json!({
|
|
1119
|
+
"name": "Studio",
|
|
1120
|
+
"membership": {"provider": "Membership", "aggregate": "Membership::Person"},
|
|
1121
|
+
});
|
|
1122
|
+
let (aggregate, storage_name) = membership_aggregate(&domain_ir).unwrap();
|
|
1123
|
+
assert_eq!(aggregate, "Person");
|
|
1124
|
+
assert_eq!(storage_name, "person");
|
|
1125
|
+
|
|
1126
|
+
assert!(membership_aggregate(&json!({"name": "Pizzas"})).is_err());
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
// A throwaway Postgres database per test, matching the real
|
|
1130
|
+
// `head_view` naming (`qualified_name(domain, "#{storage_name}_head")`,
|
|
1131
|
+
// docs/decisions/0059) that the functions under test query directly.
|
|
1132
|
+
async fn scratch_member_db(name: &str) -> Mutex<tokio_postgres::Client> {
|
|
1133
|
+
use tokio_postgres::NoTls;
|
|
1134
|
+
let (admin, conn) = tokio_postgres::connect(&crate::test_pg::conninfo("postgres"), NoTls)
|
|
1135
|
+
.await
|
|
1136
|
+
.expect("connect to postgres");
|
|
1137
|
+
tokio::spawn(async move {
|
|
1138
|
+
let _ = conn.await;
|
|
1139
|
+
});
|
|
1140
|
+
admin.batch_execute(&format!("DROP DATABASE IF EXISTS {name} WITH (FORCE)")).await.unwrap();
|
|
1141
|
+
admin.batch_execute(&format!("CREATE DATABASE {name}")).await.unwrap();
|
|
1142
|
+
|
|
1143
|
+
let (client, conn) = tokio_postgres::connect(&crate::test_pg::conninfo(&name), NoTls)
|
|
1144
|
+
.await
|
|
1145
|
+
.expect("connect to scratch db");
|
|
1146
|
+
tokio::spawn(async move {
|
|
1147
|
+
let _ = conn.await;
|
|
1148
|
+
});
|
|
1149
|
+
// `acme_member_head` mirrors the real shape: a view over the
|
|
1150
|
+
// era-1 snapshot table (`head_compiler.rb`'s `ensure_first_head!`),
|
|
1151
|
+
// written through the era-partitioned journal first.
|
|
1152
|
+
client
|
|
1153
|
+
.batch_execute(
|
|
1154
|
+
"CREATE TABLE acme_member_head_snapshot_1 (id text PRIMARY KEY, ordinal bigint NOT NULL, state jsonb NOT NULL);
|
|
1155
|
+
CREATE VIEW acme_member_head AS SELECT id, state FROM acme_member_head_snapshot_1;
|
|
1156
|
+
CREATE TABLE hecks_journal_acme (
|
|
1157
|
+
ordinal bigserial PRIMARY KEY, era int NOT NULL, aggregate text NOT NULL,
|
|
1158
|
+
aggregate_id text NOT NULL, operation text NOT NULL, state jsonb, mirrors jsonb
|
|
1159
|
+
);",
|
|
1160
|
+
)
|
|
1161
|
+
.await
|
|
1162
|
+
.unwrap();
|
|
1163
|
+
Mutex::new(client)
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
// A minimal `ir.json`-shaped fixture: one lineage-capable aggregate
|
|
1167
|
+
// plus the `membership` key `membership_aggregate` reads.
|
|
1168
|
+
fn member_domain_ir() -> Value {
|
|
1169
|
+
json!({
|
|
1170
|
+
"name": "Acme",
|
|
1171
|
+
"lineage": {"capable_aggregates": [{"name": "Member", "storage_name": "member"}]},
|
|
1172
|
+
"membership": {"provider": "Acme", "aggregate": "Acme::Member"},
|
|
1173
|
+
})
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
#[tokio::test]
|
|
1177
|
+
async fn member_lookups_query_the_real_member_head_shape() {
|
|
1178
|
+
let domain_ir = member_domain_ir();
|
|
1179
|
+
let db = scratch_member_db("hecks_host_auth_test_member_lookups").await;
|
|
1180
|
+
{
|
|
1181
|
+
let guard = db.lock().await;
|
|
1182
|
+
guard.execute(
|
|
1183
|
+
"INSERT INTO acme_member_head_snapshot_1 (id, ordinal, state) VALUES ($1, 1, $2::jsonb), ($3, 1, $4::jsonb)",
|
|
1184
|
+
&[
|
|
1185
|
+
&"chris@example.com",
|
|
1186
|
+
&json!({"name": {"value": "Chris Young"}, "email": {"value": "chris@example.com"},
|
|
1187
|
+
"role": {"value": "Admin"}, "identity_id": {"value": "id-1"}}),
|
|
1188
|
+
&"angie@example.com",
|
|
1189
|
+
&json!({"name": {"value": "Angie Chen"}, "email": {"value": "angie@example.com"},
|
|
1190
|
+
"role": null, "identity_id": null}),
|
|
1191
|
+
],
|
|
1192
|
+
).await.unwrap();
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
let chris = member_row_by_email(&db, &domain_ir, "chris@example.com").await.unwrap().expect("should find chris");
|
|
1196
|
+
assert_eq!(chris["email"]["value"], "chris@example.com");
|
|
1197
|
+
assert!(member_row_by_email(&db, &domain_ir, "nobody@example.com").await.unwrap().is_none());
|
|
1198
|
+
|
|
1199
|
+
let session = session_for_member_by_identity(&db, &domain_ir, "id-1").await.unwrap().expect("should find the linked member");
|
|
1200
|
+
assert_eq!(session.email, "chris@example.com");
|
|
1201
|
+
assert_eq!(session.role.as_deref(), Some("Admin"));
|
|
1202
|
+
assert!(session_for_member_by_identity(&db, &domain_ir, "id-nope").await.unwrap().is_none());
|
|
1203
|
+
|
|
1204
|
+
let people = all_people(&db, &domain_ir).await.unwrap();
|
|
1205
|
+
assert_eq!(people.len(), 2);
|
|
1206
|
+
let chris = people.iter().find(|p| p["email"] == "chris@example.com").unwrap();
|
|
1207
|
+
assert_eq!(chris["linked"], true);
|
|
1208
|
+
assert_eq!(chris["granted"], true);
|
|
1209
|
+
let angie = people.iter().find(|p| p["email"] == "angie@example.com").unwrap();
|
|
1210
|
+
assert_eq!(angie["linked"], false);
|
|
1211
|
+
assert_eq!(angie["granted"], false);
|
|
1212
|
+
}
|
|
1213
|
+
|
|
1214
|
+
#[tokio::test]
|
|
1215
|
+
async fn append_member_state_writes_the_journal_and_advances_the_head_snapshot() {
|
|
1216
|
+
let domain_ir = member_domain_ir();
|
|
1217
|
+
let db = scratch_member_db("hecks_host_auth_test_append_member").await;
|
|
1218
|
+
let config = LineageConfig { domain: "Acme".to_string(), era: Some(1), mirrored: None };
|
|
1219
|
+
{
|
|
1220
|
+
let guard = db.lock().await;
|
|
1221
|
+
// ordinal 0 -- below the journal's bigserial sequence (starts
|
|
1222
|
+
// at 1). Seeding at 1 instead once collided with the journal's
|
|
1223
|
+
// first real insert and masked this guard; pins that regression.
|
|
1224
|
+
guard.execute(
|
|
1225
|
+
"INSERT INTO acme_member_head_snapshot_1 (id, ordinal, state) VALUES ($1, 0, $2::jsonb)",
|
|
1226
|
+
&[
|
|
1227
|
+
&"angie@example.com",
|
|
1228
|
+
&json!({"name": {"value": "Angie Chen"}, "email": {"value": "angie@example.com"},
|
|
1229
|
+
"role": null, "identity_id": null}),
|
|
1230
|
+
],
|
|
1231
|
+
).await.unwrap();
|
|
1232
|
+
}
|
|
1233
|
+
|
|
1234
|
+
let granted = json!({"name": {"value": "Angie Chen"}, "email": {"value": "angie@example.com"},
|
|
1235
|
+
"role": {"value": "Admin"}, "identity_id": null});
|
|
1236
|
+
append_member_state(&db, &config, &domain_ir, "angie@example.com", &granted).await.unwrap();
|
|
1237
|
+
|
|
1238
|
+
// The head view reflects the new state immediately.
|
|
1239
|
+
let after = member_row_by_email(&db, &domain_ir, "angie@example.com").await.unwrap().expect("still there");
|
|
1240
|
+
assert_eq!(after["role"]["value"], "Admin");
|
|
1241
|
+
|
|
1242
|
+
// A real journal row was appended -- not a raw update bypassing it.
|
|
1243
|
+
let guard = db.lock().await;
|
|
1244
|
+
let journal_rows = guard
|
|
1245
|
+
.query("SELECT era, aggregate, aggregate_id, operation, state FROM hecks_journal_acme", &[])
|
|
1246
|
+
.await
|
|
1247
|
+
.unwrap();
|
|
1248
|
+
assert_eq!(journal_rows.len(), 1);
|
|
1249
|
+
let row = &journal_rows[0];
|
|
1250
|
+
let era: i32 = row.get(0);
|
|
1251
|
+
let aggregate: String = row.get(1);
|
|
1252
|
+
let aggregate_id: String = row.get(2);
|
|
1253
|
+
let operation: String = row.get(3);
|
|
1254
|
+
let state: Value = row.get(4);
|
|
1255
|
+
assert_eq!(era, 1);
|
|
1256
|
+
assert_eq!(aggregate, "member");
|
|
1257
|
+
assert_eq!(aggregate_id, "angie@example.com");
|
|
1258
|
+
assert_eq!(operation, "save");
|
|
1259
|
+
assert_eq!(state["role"]["value"], "Admin");
|
|
1260
|
+
|
|
1261
|
+
// The snapshot's own ordinal advanced past the seed row's.
|
|
1262
|
+
let ordinal: i64 = guard
|
|
1263
|
+
.query_one("SELECT ordinal FROM acme_member_head_snapshot_1 WHERE id = $1", &[&"angie@example.com"])
|
|
1264
|
+
.await
|
|
1265
|
+
.unwrap()
|
|
1266
|
+
.get(0);
|
|
1267
|
+
assert!(ordinal > 0, "should have advanced past the seed row's ordinal 0");
|
|
1268
|
+
drop(guard);
|
|
1269
|
+
|
|
1270
|
+
// The ordinal-guarded upsert (`WHERE ordinal < EXCLUDED.ordinal`)
|
|
1271
|
+
// can't be exercised through normal writes, so this pins it
|
|
1272
|
+
// directly: a manual downgrade attempt is a no-op.
|
|
1273
|
+
let guard = db.lock().await;
|
|
1274
|
+
guard.execute(
|
|
1275
|
+
"INSERT INTO acme_member_head_snapshot_1 (id, ordinal, state) VALUES ($1, 1, $2::jsonb) \
|
|
1276
|
+
ON CONFLICT (id) DO UPDATE SET ordinal = EXCLUDED.ordinal, state = EXCLUDED.state \
|
|
1277
|
+
WHERE acme_member_head_snapshot_1.ordinal < EXCLUDED.ordinal",
|
|
1278
|
+
&[&"angie@example.com", &json!({"role": {"value": "SHOULD_NOT_APPLY"}})],
|
|
1279
|
+
).await.unwrap();
|
|
1280
|
+
let state: Value = guard
|
|
1281
|
+
.query_one("SELECT state FROM acme_member_head_snapshot_1 WHERE id = $1", &[&"angie@example.com"])
|
|
1282
|
+
.await
|
|
1283
|
+
.unwrap()
|
|
1284
|
+
.get(0);
|
|
1285
|
+
assert_eq!(state["role"]["value"], "Admin", "a lower ordinal must never move the snapshot backward");
|
|
1286
|
+
}
|
|
1287
|
+
}
|