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