@feltdb/core 0.13.0 → 0.16.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.
- package/dist/agent-runtime.d.ts +64 -2
- package/dist/agent-runtime.d.ts.map +1 -1
- package/dist/agent-runtime.js +121 -11
- package/dist/agent-state.d.ts +68 -0
- package/dist/agent-state.d.ts.map +1 -1
- package/dist/agent-state.js +210 -12
- package/dist/agent.d.ts +10 -1
- package/dist/agent.d.ts.map +1 -1
- package/dist/agent.js +10 -1
- package/dist/build-identity.json +12 -4
- package/dist/cli/commands.js +214 -10
- package/dist/cli/publish-engine.js +116 -16
- package/dist/collection.d.ts +3 -1
- package/dist/collection.d.ts.map +1 -1
- package/dist/collection.js +33 -8
- package/dist/create/build-identity.json +12 -4
- package/dist/create/cli.js +1 -1
- package/dist/create/create.js +4 -6
- package/dist/create/managed-account.js +93 -35
- package/dist/create/package-versions.js +1 -1
- package/dist/create/server-source/Cargo.lock +8 -511
- package/dist/create/server-source/Cargo.toml +2 -13
- package/dist/create/server-source/crates/feltdb-client/Cargo.toml +1 -1
- package/dist/create/server-source/crates/feltdb-server/Cargo.toml +1 -1
- package/dist/create/server-source/crates/feltdb-server/README.md +6 -1
- package/dist/create/server-source/crates/feltdb-server/src/app_state.rs +9 -1
- package/dist/create/server-source/crates/feltdb-server/src/auth.rs +85 -25
- package/dist/create/server-source/crates/feltdb-server/src/backup.rs +4 -0
- package/dist/create/server-source/crates/feltdb-server/src/console_accounts.rs +1009 -0
- package/dist/create/server-source/crates/feltdb-server/src/control_authorization.rs +441 -0
- package/dist/create/server-source/crates/feltdb-server/src/identity.rs +68 -6
- package/dist/create/server-source/crates/feltdb-server/src/key_management.rs +355 -23
- package/dist/create/server-source/crates/feltdb-server/src/lib.rs +3 -0
- package/dist/create/server-source/crates/feltdb-server/src/main.rs +1134 -96
- package/dist/create/server-source/crates/feltdb-server/src/redaction.rs +208 -0
- package/dist/create/server-source/crates/feltdb-server/src/tenancy.rs +198 -19
- package/dist/create/server-source/crates/feltdb-server/tests/transaction_precondition_contract.rs +403 -0
- package/dist/create/server-source/crates/feltdb-wasm/Cargo.toml +1 -1
- package/dist/db.d.ts +75 -10
- package/dist/db.d.ts.map +1 -1
- package/dist/db.js +168 -29
- package/dist/deployment.d.ts +19 -0
- package/dist/deployment.d.ts.map +1 -1
- package/dist/deployment.js +38 -2
- package/dist/embedded-query.d.ts.map +1 -1
- package/dist/embedded-query.js +5 -4
- package/dist/embedded-transaction.d.ts +8 -4
- package/dist/embedded-transaction.d.ts.map +1 -1
- package/dist/embedded-transaction.js +55 -19
- package/dist/error-codes.d.ts +36 -2
- package/dist/error-codes.d.ts.map +1 -1
- package/dist/error-codes.js +50 -3
- package/dist/feltdb.d.ts +7 -0
- package/dist/feltdb.d.ts.map +1 -1
- package/dist/file-db.d.ts +27 -0
- package/dist/file-db.d.ts.map +1 -1
- package/dist/file-db.js +145 -7
- package/dist/http-client.d.ts +11 -3
- package/dist/http-client.d.ts.map +1 -1
- package/dist/http-client.js +27 -14
- package/dist/http-db.d.ts +43 -1
- package/dist/http-db.d.ts.map +1 -1
- package/dist/http-db.js +287 -59
- package/dist/index.js +1 -1
- package/dist/local-state-lock.d.ts +34 -0
- package/dist/local-state-lock.d.ts.map +1 -0
- package/dist/local-state-lock.js +225 -0
- package/dist/managed-recovery.d.ts +6 -0
- package/dist/managed-recovery.d.ts.map +1 -1
- package/dist/managed-recovery.js +9 -0
- package/dist/reactive-graph.d.ts +0 -3
- package/dist/reactive-graph.d.ts.map +1 -1
- package/dist/reactive-graph.js +11 -1
- package/dist/runtime-registry.d.ts +7 -2
- package/dist/runtime-registry.d.ts.map +1 -1
- package/dist/runtime-registry.js +2 -2
- package/dist/studio/components/index.js +1 -1
- package/dist/studio/{components-BSGmHfif.js → components-CJmzntBi.js} +2 -1
- package/dist/studio/index.js +1 -1
- package/dist/studio-app/assets/{feltdb_wasm-3hDngl1h.js → feltdb_wasm-DtybUY0q.js} +1 -1
- package/dist/studio-app/assets/feltdb_wasm_bg-DsgIUwaB.wasm +0 -0
- package/dist/studio-app/assets/index-C4OffsZl.js +37 -0
- package/dist/studio-app/index.html +1 -1
- package/dist/transaction.d.ts +27 -11
- package/dist/transaction.d.ts.map +1 -1
- package/dist/transaction.js +20 -14
- package/dist/wasm/feltdb_wasm_bg.wasm +0 -0
- package/dist/wasm/package.json +1 -1
- package/dist/workload.d.ts +86 -6
- package/dist/workload.d.ts.map +1 -1
- package/dist/workload.js +51 -3
- package/dist/workspace/workspace-connection.d.ts.map +1 -1
- package/dist/workspace/workspace-connection.js +14 -3
- package/package.json +1 -1
- package/dist/create/server-source/crates/feltdb/Cargo.lock +0 -175
- package/dist/create/server-source/crates/feltdb/Cargo.toml +0 -102
- package/dist/create/server-source/crates/feltdb/LICENSE +0 -21
- package/dist/create/server-source/crates/feltdb/README.md +0 -75
- package/dist/create/server-source/crates/feltdb/benches/baselines/gate-13-redux.json +0 -370
- package/dist/create/server-source/crates/feltdb/benches/gate13_baseline.rs +0 -589
- package/dist/create/server-source/crates/feltdb/benches/gate13_phase_7_1_release_economics.rs +0 -247
- package/dist/create/server-source/crates/feltdb/benches/gate_13_redux.rs +0 -440
- package/dist/create/server-source/crates/feltdb/benches/gate_13_regression_runner.rs +0 -275
- package/dist/create/server-source/crates/feltdb/benches/gate_14a_concurrent_writer_scaling.rs +0 -381
- package/dist/create/server-source/crates/feltdb/benches/gate_14a_production_admission_revalidation.rs +0 -467
- package/dist/create/server-source/crates/feltdb/benches/gate_14a_rc2_admission_contract.rs +0 -489
- package/dist/create/server-source/crates/feltdb/benches/gate_14a_rc_root_cause.rs +0 -281
- package/dist/create/server-source/crates/feltdb/benches/gate_14a_sync1_queued_prototype.rs +0 -606
- package/dist/create/server-source/crates/feltdb/benches/gate_14a_sync_economics.rs +0 -531
- package/dist/create/server-source/crates/feltdb/benches/gate_14b_causal_backlog_scaling.rs +0 -462
- package/dist/create/server-source/crates/feltdb/benches/gate_14c_replication_contract_test.rs +0 -505
- package/dist/create/server-source/crates/feltdb/benches/gate_14c_replication_scaling.rs +0 -484
- package/dist/create/server-source/crates/feltdb/benches/gate_14d_combined_dimension_scaling.rs +0 -758
- package/dist/create/server-source/crates/feltdb/benches/phase_7_1_2_optimization_benchmark.rs +0 -421
- package/dist/create/server-source/crates/feltdb/benches/phase_7_1_3_crossover_analysis.rs +0 -329
- package/dist/create/server-source/crates/feltdb/src/acceptance_tests.rs +0 -1705
- package/dist/create/server-source/crates/feltdb/src/acquisition.rs +0 -286
- package/dist/create/server-source/crates/feltdb/src/admission.rs +0 -185
- package/dist/create/server-source/crates/feltdb/src/admission_contract_tests.rs +0 -507
- package/dist/create/server-source/crates/feltdb/src/adversarial_transport.rs +0 -539
- package/dist/create/server-source/crates/feltdb/src/agent_state.rs +0 -320
- package/dist/create/server-source/crates/feltdb/src/agent_transition_contract.rs +0 -1545
- package/dist/create/server-source/crates/feltdb/src/analytics.rs +0 -521
- package/dist/create/server-source/crates/feltdb/src/application.rs +0 -2674
- package/dist/create/server-source/crates/feltdb/src/application_runtime.rs +0 -1070
- package/dist/create/server-source/crates/feltdb/src/authority.rs +0 -479
- package/dist/create/server-source/crates/feltdb/src/authority_failover.rs +0 -847
- package/dist/create/server-source/crates/feltdb/src/authorization.rs +0 -1030
- package/dist/create/server-source/crates/feltdb/src/authorization_security_tests.rs +0 -1122
- package/dist/create/server-source/crates/feltdb/src/bin/feltdb-authority-client.rs +0 -32
- package/dist/create/server-source/crates/feltdb/src/bin/feltdb-authority.rs +0 -19
- package/dist/create/server-source/crates/feltdb/src/bin/feltdb_node.rs +0 -1474
- package/dist/create/server-source/crates/feltdb/src/capabilities/mod.rs +0 -8
- package/dist/create/server-source/crates/feltdb/src/capabilities/search.rs +0 -418
- package/dist/create/server-source/crates/feltdb/src/capabilities/vector.rs +0 -482
- package/dist/create/server-source/crates/feltdb/src/capability.rs +0 -903
- package/dist/create/server-source/crates/feltdb/src/cardinality_diagnostics.rs +0 -263
- package/dist/create/server-source/crates/feltdb/src/cardinality_endpoint.rs +0 -77
- package/dist/create/server-source/crates/feltdb/src/causal_backlog_bound.rs +0 -496
- package/dist/create/server-source/crates/feltdb/src/causal_dependency_barrier.rs +0 -2574
- package/dist/create/server-source/crates/feltdb/src/causal_dependency_barrier_phase_7_1.rs +0 -212
- package/dist/create/server-source/crates/feltdb/src/concurrency_fuzzing.rs +0 -422
- package/dist/create/server-source/crates/feltdb/src/consistency_contract.rs +0 -445
- package/dist/create/server-source/crates/feltdb/src/content_distribution.rs +0 -465
- package/dist/create/server-source/crates/feltdb/src/convergence.rs +0 -634
- package/dist/create/server-source/crates/feltdb/src/crash_atomic_boundary.rs +0 -427
- package/dist/create/server-source/crates/feltdb/src/crash_injection.rs +0 -376
- package/dist/create/server-source/crates/feltdb/src/crash_recovery_tests.rs +0 -365
- package/dist/create/server-source/crates/feltdb/src/cron.rs +0 -294
- package/dist/create/server-source/crates/feltdb/src/dedup_bound_investigation.rs +0 -483
- package/dist/create/server-source/crates/feltdb/src/distributed_indexing.rs +0 -477
- package/dist/create/server-source/crates/feltdb/src/distributed_tests.rs +0 -1003
- package/dist/create/server-source/crates/feltdb/src/distributed_transactions.rs +0 -1334
- package/dist/create/server-source/crates/feltdb/src/durability_guarantees.rs +0 -368
- package/dist/create/server-source/crates/feltdb/src/durable_dedup_set.rs +0 -231
- package/dist/create/server-source/crates/feltdb/src/durable_operation_identity.rs +0 -482
- package/dist/create/server-source/crates/feltdb/src/durable_operation_log.rs +0 -203
- package/dist/create/server-source/crates/feltdb/src/durable_sync.rs +0 -317
- package/dist/create/server-source/crates/feltdb/src/equality_index.rs +0 -595
- package/dist/create/server-source/crates/feltdb/src/execution.rs +0 -426
- package/dist/create/server-source/crates/feltdb/src/in_process_transport.rs +0 -214
- package/dist/create/server-source/crates/feltdb/src/indexing.rs +0 -776
- package/dist/create/server-source/crates/feltdb/src/lib.rs +0 -7274
- package/dist/create/server-source/crates/feltdb/src/managed_cas_tests.rs +0 -234
- package/dist/create/server-source/crates/feltdb/src/materialization.rs +0 -184
- package/dist/create/server-source/crates/feltdb/src/metrics.rs +0 -266
- package/dist/create/server-source/crates/feltdb/src/multi_node_convergence.rs +0 -310
- package/dist/create/server-source/crates/feltdb/src/multi_operation_transaction.rs +0 -736
- package/dist/create/server-source/crates/feltdb/src/observability.rs +0 -379
- package/dist/create/server-source/crates/feltdb/src/operation.rs +0 -290
- package/dist/create/server-source/crates/feltdb/src/operation_algebra.rs +0 -439
- package/dist/create/server-source/crates/feltdb/src/operation_log.rs +0 -349
- package/dist/create/server-source/crates/feltdb/src/p1_application_atomicity.rs +0 -332
- package/dist/create/server-source/crates/feltdb/src/p1_atomicity_acceptance.rs +0 -718
- package/dist/create/server-source/crates/feltdb/src/partition_reconciliation.rs +0 -487
- package/dist/create/server-source/crates/feltdb/src/peer_registry.rs +0 -166
- package/dist/create/server-source/crates/feltdb/src/permutation_scheduler.rs +0 -289
- package/dist/create/server-source/crates/feltdb/src/persistence_reality.rs +0 -566
- package/dist/create/server-source/crates/feltdb/src/phase1b_acceptance.rs +0 -3391
- package/dist/create/server-source/crates/feltdb/src/phase1c1_acceptance.rs +0 -203
- package/dist/create/server-source/crates/feltdb/src/phase1c2_acceptance.rs +0 -261
- package/dist/create/server-source/crates/feltdb/src/phase1c3_acceptance.rs +0 -502
- package/dist/create/server-source/crates/feltdb/src/phase1c_atomicity_proof.rs +0 -216
- package/dist/create/server-source/crates/feltdb/src/phase5_integration.rs +0 -303
- package/dist/create/server-source/crates/feltdb/src/phase5_scenarios.rs +0 -323
- package/dist/create/server-source/crates/feltdb/src/phase6_adversarial_scenarios.rs +0 -531
- package/dist/create/server-source/crates/feltdb/src/phase6_convergence_validator.rs +0 -406
- package/dist/create/server-source/crates/feltdb/src/phase6_persistence.rs +0 -436
- package/dist/create/server-source/crates/feltdb/src/phase_1c_real_tcp.rs +0 -387
- package/dist/create/server-source/crates/feltdb/src/phase_2a_failures.rs +0 -378
- package/dist/create/server-source/crates/feltdb/src/phase_2b_network.rs +0 -359
- package/dist/create/server-source/crates/feltdb/src/phase_2c_cascading.rs +0 -372
- package/dist/create/server-source/crates/feltdb/src/phase_3_durability.rs +0 -404
- package/dist/create/server-source/crates/feltdb/src/phase_4_baseline.rs +0 -376
- package/dist/create/server-source/crates/feltdb/src/phase_5_soak.rs +0 -461
- package/dist/create/server-source/crates/feltdb/src/policy_evaluation.rs +0 -2125
- package/dist/create/server-source/crates/feltdb/src/production_api.rs +0 -418
- package/dist/create/server-source/crates/feltdb/src/provenance.rs +0 -207
- package/dist/create/server-source/crates/feltdb/src/query_execution_diagnostics.rs +0 -126
- package/dist/create/server-source/crates/feltdb/src/query_performance.rs +0 -218
- package/dist/create/server-source/crates/feltdb/src/read_basis.rs +0 -257
- package/dist/create/server-source/crates/feltdb/src/references.rs +0 -404
- package/dist/create/server-source/crates/feltdb/src/replay_fuzzing.rs +0 -401
- package/dist/create/server-source/crates/feltdb/src/replica_acknowledgements.rs +0 -501
- package/dist/create/server-source/crates/feltdb/src/replica_membership.rs +0 -680
- package/dist/create/server-source/crates/feltdb/src/replication_manager.rs +0 -163
- package/dist/create/server-source/crates/feltdb/src/replication_protocol.rs +0 -133
- package/dist/create/server-source/crates/feltdb/src/routing.rs +0 -409
- package/dist/create/server-source/crates/feltdb/src/semantic_decision.rs +0 -2029
- package/dist/create/server-source/crates/feltdb/src/semantic_query.rs +0 -599
- package/dist/create/server-source/crates/feltdb/src/sharding.rs +0 -549
- package/dist/create/server-source/crates/feltdb/src/state_conflict_contract.rs +0 -516
- package/dist/create/server-source/crates/feltdb/src/state_contract.rs +0 -5894
- package/dist/create/server-source/crates/feltdb/src/state_diff_contract.rs +0 -222
- package/dist/create/server-source/crates/feltdb/src/state_facade.rs +0 -320
- package/dist/create/server-source/crates/feltdb/src/state_hash.rs +0 -291
- package/dist/create/server-source/crates/feltdb/src/state_model.rs +0 -4158
- package/dist/create/server-source/crates/feltdb/src/state_transition_store.rs +0 -379
- package/dist/create/server-source/crates/feltdb/src/state_trigger.rs +0 -673
- package/dist/create/server-source/crates/feltdb/src/storage.rs +0 -273
- package/dist/create/server-source/crates/feltdb/src/submission.rs +0 -471
- package/dist/create/server-source/crates/feltdb/src/sync.rs +0 -495
- package/dist/create/server-source/crates/feltdb/src/sync_contract.rs +0 -822
- package/dist/create/server-source/crates/feltdb/src/tcp_transport.rs +0 -433
- package/dist/create/server-source/crates/feltdb/src/transaction_api.rs +0 -462
- package/dist/create/server-source/crates/feltdb/src/transaction_invariants.rs +0 -965
- package/dist/create/server-source/crates/feltdb/src/transaction_preconditions.rs +0 -1120
- package/dist/create/server-source/crates/feltdb/src/transactions.rs +0 -643
- package/dist/create/server-source/crates/feltdb/src/trigger.rs +0 -390
- package/dist/create/server-source/crates/feltdb/src/trigger_contract.rs +0 -749
- package/dist/create/server-source/crates/feltdb/src/versioned_object_substrate.rs +0 -708
- package/dist/create/server-source/crates/feltdb/src/worker_mesh.rs +0 -1133
- package/dist/create/server-source/crates/feltdb/src/workflow.rs +0 -713
- package/dist/create/server-source/crates/feltdb/src/workflow_acceptance_tests.rs +0 -510
- package/dist/create/server-source/crates/feltdb/src/workflow_integration.rs +0 -354
- package/dist/create/server-source/crates/feltdb/src/workflow_runtime.rs +0 -594
- package/dist/create/server-source/crates/feltdb/src/workload.rs +0 -2886
- package/dist/create/server-source/crates/feltdb/src/workload_diagnostics.rs +0 -456
- package/dist/create/server-source/crates/feltdb/tests/agent_state_native.rs +0 -1188
- package/dist/create/server-source/crates/feltdb/tests/agent_transition_contract_conformance.rs +0 -2884
- package/dist/create/server-source/crates/feltdb/tests/authority_process.rs +0 -297
- package/dist/create/server-source/crates/feltdb/tests/authority_transaction_identity.rs +0 -534
- package/dist/create/server-source/crates/feltdb/tests/bounded_read_contract.rs +0 -132
- package/dist/create/server-source/crates/feltdb/tests/branching_evidence.rs +0 -299
- package/dist/create/server-source/crates/feltdb/tests/common/mod.rs +0 -96
- package/dist/create/server-source/crates/feltdb/tests/compaction_stall_contract.rs +0 -272
- package/dist/create/server-source/crates/feltdb/tests/crash_durability_contract.rs +0 -467
- package/dist/create/server-source/crates/feltdb/tests/current_revision_authority_evidence.rs +0 -309
- package/dist/create/server-source/crates/feltdb/tests/durable_backup_contract.rs +0 -445
- package/dist/create/server-source/crates/feltdb/tests/durable_corruption_contract.rs +0 -518
- package/dist/create/server-source/crates/feltdb/tests/durable_format_compatibility.rs +0 -392
- package/dist/create/server-source/crates/feltdb/tests/feltdb_state_boundary_tests.rs +0 -850
- package/dist/create/server-source/crates/feltdb/tests/fixtures/agent_transition_corpus.json +0 -23340
- package/dist/create/server-source/crates/feltdb/tests/fixtures/state_conflict_contract_corpus.json +0 -1916
- package/dist/create/server-source/crates/feltdb/tests/fixtures/state_diff_contract_corpus.json +0 -1878
- package/dist/create/server-source/crates/feltdb/tests/fixtures/trigger_contract_corpus.json +0 -1862
- package/dist/create/server-source/crates/feltdb/tests/generalized_version_control_evidence.rs +0 -123
- package/dist/create/server-source/crates/feltdb/tests/generalized_version_control_substrate_completion.rs +0 -299
- package/dist/create/server-source/crates/feltdb/tests/local_process_ownership.rs +0 -69
- package/dist/create/server-source/crates/feltdb/tests/managed_incident_regression.rs +0 -191
- package/dist/create/server-source/crates/feltdb/tests/operational_health_contract.rs +0 -278
- package/dist/create/server-source/crates/feltdb/tests/pr34_query_collection.rs +0 -234
- package/dist/create/server-source/crates/feltdb/tests/pr35_equality_index.rs +0 -947
- package/dist/create/server-source/crates/feltdb/tests/pr7_self_authorization_proof.rs +0 -404
- package/dist/create/server-source/crates/feltdb/tests/pr8_vocabulary_assessment.rs +0 -916
- package/dist/create/server-source/crates/feltdb/tests/pr9_phase2_boundary_tests.rs +0 -1045
- package/dist/create/server-source/crates/feltdb/tests/pr9_phase3a_path_a_tests.rs +0 -347
- package/dist/create/server-source/crates/feltdb/tests/pr9_phase3c_authorized_mutations.rs +0 -361
- package/dist/create/server-source/crates/feltdb/tests/pr9_phase3c_role_based_authorization.rs +0 -330
- package/dist/create/server-source/crates/feltdb/tests/pr9_phase3c_simple_auth_delete.rs +0 -93
- package/dist/create/server-source/crates/feltdb/tests/pr9_phase3c_team_delete_role_authorization.rs +0 -622
- package/dist/create/server-source/crates/feltdb/tests/pr9_teams_role_based_access.rs +0 -517
- package/dist/create/server-source/crates/feltdb/tests/production_certification.rs +0 -1153
- package/dist/create/server-source/crates/feltdb/tests/production_contract.rs +0 -771
- package/dist/create/server-source/crates/feltdb/tests/production_readiness_contract.rs +0 -1383
- package/dist/create/server-source/crates/feltdb/tests/reconciliation_application.rs +0 -868
- package/dist/create/server-source/crates/feltdb/tests/reconciliation_wire_format_evidence.rs +0 -221
- package/dist/create/server-source/crates/feltdb/tests/replicated_history_contract.rs +0 -417
- package/dist/create/server-source/crates/feltdb/tests/resource_scoped_revisions.rs +0 -338
- package/dist/create/server-source/crates/feltdb/tests/revision_identity_contract.rs +0 -1039
- package/dist/create/server-source/crates/feltdb/tests/revision_model_decision.rs +0 -739
- package/dist/create/server-source/crates/feltdb/tests/revision_retention_boundary_evidence.rs +0 -452
- package/dist/create/server-source/crates/feltdb/tests/saas_authorization_integration.rs +0 -81
- package/dist/create/server-source/crates/feltdb/tests/saas_invitation_lifecycle.rs +0 -438
- package/dist/create/server-source/crates/feltdb/tests/semantic_decision.rs +0 -1165
- package/dist/create/server-source/crates/feltdb/tests/semantic_query.rs +0 -767
- package/dist/create/server-source/crates/feltdb/tests/semantic_query_contract.rs +0 -363
- package/dist/create/server-source/crates/feltdb/tests/state_conflict_contract_conformance.rs +0 -1799
- package/dist/create/server-source/crates/feltdb/tests/state_diff_contract_conformance.rs +0 -1316
- package/dist/create/server-source/crates/feltdb/tests/state_model_integration.rs +0 -358
- package/dist/create/server-source/crates/feltdb/tests/state_persistence_integration.rs +0 -365
- package/dist/create/server-source/crates/feltdb/tests/state_ref_authority.rs +0 -918
- package/dist/create/server-source/crates/feltdb/tests/state_store_boundary_evidence.rs +0 -299
- package/dist/create/server-source/crates/feltdb/tests/storage_independent_versioned_object_substrate.rs +0 -328
- package/dist/create/server-source/crates/feltdb/tests/sync_divergence_evidence.rs +0 -255
- package/dist/create/server-source/crates/feltdb/tests/three_way_input_boundary_evidence.rs +0 -249
- package/dist/create/server-source/crates/feltdb/tests/trigger_contract_conformance.rs +0 -994
- package/dist/create/server-source/crates/feltdb/tests/workload_envelope_contract.rs +0 -484
- package/dist/studio-app/assets/feltdb_wasm_bg-CFQT4gCy.wasm +0 -0
- package/dist/studio-app/assets/index-Dql1hS5m.js +0 -30
|
@@ -0,0 +1,1009 @@
|
|
|
1
|
+
//! Console (Studio / www.feltdb.com) accounts.
|
|
2
|
+
//!
|
|
3
|
+
//! Before 0.15.0 the website signed a console session for any e-mail address
|
|
4
|
+
//! with any password, and derived the user id from a prefix of the identity,
|
|
5
|
+
//! so two identities could share one platform user. This store is the account
|
|
6
|
+
//! authority the website now has to ask: it verifies passwords (Argon2), binds
|
|
7
|
+
//! GitHub sign-in to the numeric GitHub id, and hands out random user ids.
|
|
8
|
+
//!
|
|
9
|
+
//! Only the website may call the account routes. Every request is signed with
|
|
10
|
+
//! HMAC-SHA256 under `FELTDB_SESSION_SECRET`, the secret the website and the
|
|
11
|
+
//! control plane already share for console session cookies
|
|
12
|
+
//! (see [`verify_console_request`]).
|
|
13
|
+
//!
|
|
14
|
+
//! Persisted in its own JSON file beside the data (`<data>.console-accounts.json`),
|
|
15
|
+
//! written to a temporary file and renamed, owner-only (0600).
|
|
16
|
+
|
|
17
|
+
use crate::tenancy::TenancyStore;
|
|
18
|
+
use argon2::{Argon2, PasswordHash, PasswordHasher, PasswordVerifier};
|
|
19
|
+
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
|
|
20
|
+
use hmac::{Hmac, Mac};
|
|
21
|
+
use password_hash::{rand_core::OsRng, SaltString};
|
|
22
|
+
use rand::{distributions::Alphanumeric, Rng};
|
|
23
|
+
use serde::{Deserialize, Serialize};
|
|
24
|
+
use sha2::Sha256;
|
|
25
|
+
use std::{
|
|
26
|
+
fs,
|
|
27
|
+
io::Write,
|
|
28
|
+
path::PathBuf,
|
|
29
|
+
sync::{Arc, OnceLock, RwLock},
|
|
30
|
+
time::{SystemTime, UNIX_EPOCH},
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
/// Minimum password length for console password accounts.
|
|
34
|
+
pub const MINIMUM_PASSWORD_LENGTH: usize = 12;
|
|
35
|
+
/// Maximum accepted clock skew between the website and the control plane.
|
|
36
|
+
pub const MAXIMUM_SIGNATURE_SKEW_SECONDS: u64 = 300;
|
|
37
|
+
pub const SIGNATURE_HEADER: &str = "x-feltdb-console-signature";
|
|
38
|
+
pub const TIMESTAMP_HEADER: &str = "x-feltdb-console-timestamp";
|
|
39
|
+
const DEVELOPMENT_SECRETS: &[&str] = &[
|
|
40
|
+
"feltdb-development-session-secret-change-me",
|
|
41
|
+
"change-me-in-production",
|
|
42
|
+
];
|
|
43
|
+
|
|
44
|
+
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
|
45
|
+
#[serde(rename_all = "snake_case")]
|
|
46
|
+
pub enum ConsoleProvider {
|
|
47
|
+
Password,
|
|
48
|
+
Github,
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
52
|
+
pub struct ConsoleAccount {
|
|
53
|
+
/// Normalized e-mail address, or `github:<numeric id>`.
|
|
54
|
+
pub identity: String,
|
|
55
|
+
pub user_id: String,
|
|
56
|
+
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
57
|
+
pub password_hash: Option<String>,
|
|
58
|
+
pub provider: ConsoleProvider,
|
|
59
|
+
pub created_at: u64,
|
|
60
|
+
/// The identity the console shows and signs into the session cookie: the
|
|
61
|
+
/// e-mail address, or `@<login>` (the most recent GitHub login seen).
|
|
62
|
+
pub display_identity: String,
|
|
63
|
+
/// How the account came to own its user id: `new`, `legacy_github_login`
|
|
64
|
+
/// (continuity binding of a pre-0.15.0 GitHub user) or `operator_claim`.
|
|
65
|
+
#[serde(default = "default_origin")]
|
|
66
|
+
pub origin: String,
|
|
67
|
+
}
|
|
68
|
+
fn default_origin() -> String {
|
|
69
|
+
"new".into()
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
#[derive(Debug, Default, Serialize, Deserialize)]
|
|
73
|
+
struct Records {
|
|
74
|
+
accounts: Vec<ConsoleAccount>,
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/// A failed account operation, with the wire code the website shows.
|
|
78
|
+
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
79
|
+
pub struct AccountError {
|
|
80
|
+
pub status: u16,
|
|
81
|
+
pub code: &'static str,
|
|
82
|
+
pub message: String,
|
|
83
|
+
}
|
|
84
|
+
impl AccountError {
|
|
85
|
+
fn new(status: u16, code: &'static str, message: impl Into<String>) -> Self {
|
|
86
|
+
Self {
|
|
87
|
+
status,
|
|
88
|
+
code,
|
|
89
|
+
message: message.into(),
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
fn internal(message: impl Into<String>) -> Self {
|
|
93
|
+
Self::new(500, "EXECUTION_FAILED", message)
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
|
98
|
+
pub struct ResolvedAccount {
|
|
99
|
+
pub user_id: String,
|
|
100
|
+
pub identity: String,
|
|
101
|
+
pub display_identity: String,
|
|
102
|
+
}
|
|
103
|
+
impl From<&ConsoleAccount> for ResolvedAccount {
|
|
104
|
+
fn from(account: &ConsoleAccount) -> Self {
|
|
105
|
+
Self {
|
|
106
|
+
user_id: account.user_id.clone(),
|
|
107
|
+
identity: account.identity.clone(),
|
|
108
|
+
display_identity: account.display_identity.clone(),
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
#[derive(Clone)]
|
|
114
|
+
pub struct ConsoleAccountStore {
|
|
115
|
+
path: PathBuf,
|
|
116
|
+
records: Arc<RwLock<Records>>,
|
|
117
|
+
/// Serializes read-modify-write operations that span the Argon2 work.
|
|
118
|
+
writer: Arc<std::sync::Mutex<()>>,
|
|
119
|
+
/// Modification time of the file as last loaded or written, so a change
|
|
120
|
+
/// made by another process (the operator `console-accounts claim` CLI) is
|
|
121
|
+
/// picked up instead of being overwritten.
|
|
122
|
+
loaded: Arc<std::sync::Mutex<Option<SystemTime>>>,
|
|
123
|
+
secret: Option<Arc<[u8]>>,
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
fn now() -> u64 {
|
|
127
|
+
SystemTime::now()
|
|
128
|
+
.duration_since(UNIX_EPOCH)
|
|
129
|
+
.unwrap_or_default()
|
|
130
|
+
.as_secs()
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/// A new console user id: `user_` and 24 random alphanumerics (about 142 bits).
|
|
134
|
+
pub fn new_user_id() -> String {
|
|
135
|
+
let random: String = rand::thread_rng()
|
|
136
|
+
.sample_iter(&Alphanumeric)
|
|
137
|
+
.take(24)
|
|
138
|
+
.map(char::from)
|
|
139
|
+
.collect();
|
|
140
|
+
format!("user_{random}")
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/// The user id the pre-0.15.0 website derived from an identity:
|
|
144
|
+
/// `'user_' + base64url(lowercase(identity)).slice(0, 18)`.
|
|
145
|
+
pub fn legacy_user_id(identity: &str) -> String {
|
|
146
|
+
let normalized = identity.trim().to_lowercase();
|
|
147
|
+
format!(
|
|
148
|
+
"user_{}",
|
|
149
|
+
URL_SAFE_NO_PAD
|
|
150
|
+
.encode(normalized)
|
|
151
|
+
.chars()
|
|
152
|
+
.take(18)
|
|
153
|
+
.collect::<String>()
|
|
154
|
+
)
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
pub fn normalize_email(email: &str) -> Result<String, AccountError> {
|
|
158
|
+
let email = email.trim().to_lowercase();
|
|
159
|
+
let valid = email.len() <= 254
|
|
160
|
+
&& !email.chars().any(char::is_whitespace)
|
|
161
|
+
&& email.split_once('@').is_some_and(|(local, domain)| {
|
|
162
|
+
!local.is_empty()
|
|
163
|
+
&& !domain.contains('@')
|
|
164
|
+
&& domain
|
|
165
|
+
.split_once('.')
|
|
166
|
+
.is_some_and(|(left, right)| !left.is_empty() && !right.is_empty())
|
|
167
|
+
});
|
|
168
|
+
if valid {
|
|
169
|
+
Ok(email)
|
|
170
|
+
} else {
|
|
171
|
+
Err(AccountError::new(
|
|
172
|
+
400,
|
|
173
|
+
"VALIDATION_ERROR",
|
|
174
|
+
"enter a valid email address",
|
|
175
|
+
))
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
fn validate_password(password: &str) -> Result<(), AccountError> {
|
|
180
|
+
if password.chars().count() < MINIMUM_PASSWORD_LENGTH {
|
|
181
|
+
return Err(AccountError::new(
|
|
182
|
+
400,
|
|
183
|
+
"PASSWORD_TOO_SHORT",
|
|
184
|
+
format!("password must be at least {MINIMUM_PASSWORD_LENGTH} characters"),
|
|
185
|
+
));
|
|
186
|
+
}
|
|
187
|
+
if password.len() > 1024 {
|
|
188
|
+
return Err(AccountError::new(
|
|
189
|
+
400,
|
|
190
|
+
"VALIDATION_ERROR",
|
|
191
|
+
"password must be at most 1024 bytes",
|
|
192
|
+
));
|
|
193
|
+
}
|
|
194
|
+
Ok(())
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
fn hash_password(password: &str) -> Result<String, AccountError> {
|
|
198
|
+
Argon2::default()
|
|
199
|
+
.hash_password(password.as_bytes(), &SaltString::generate(&mut OsRng))
|
|
200
|
+
.map(|hash| hash.to_string())
|
|
201
|
+
.map_err(|error| AccountError::internal(error.to_string()))
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
fn verify_password(password: &str, hash: &str) -> bool {
|
|
205
|
+
PasswordHash::new(hash).is_ok_and(|parsed| {
|
|
206
|
+
Argon2::default()
|
|
207
|
+
.verify_password(password.as_bytes(), &parsed)
|
|
208
|
+
.is_ok()
|
|
209
|
+
})
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/// A fixed hash with the same parameters as real ones, verified when the
|
|
213
|
+
/// e-mail is unknown so that "no such account" costs what "wrong password" does.
|
|
214
|
+
fn dummy_hash() -> &'static str {
|
|
215
|
+
static DUMMY: OnceLock<String> = OnceLock::new();
|
|
216
|
+
DUMMY.get_or_init(|| {
|
|
217
|
+
let salt = SaltString::from_b64("ZmVsdGRiY29uc29sZWR1bW15").expect("static salt");
|
|
218
|
+
Argon2::default()
|
|
219
|
+
.hash_password(b"feltdb-console-dummy-password", &salt)
|
|
220
|
+
.expect("dummy hash")
|
|
221
|
+
.to_string()
|
|
222
|
+
})
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
fn invalid_credentials() -> AccountError {
|
|
226
|
+
AccountError::new(401, "INVALID_CREDENTIALS", "invalid email or password")
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/// The secret the account routes are keyed by, if the deployment configured
|
|
230
|
+
/// one: `FELTDB_SESSION_SECRET`, at least 32 bytes, not a development value.
|
|
231
|
+
pub fn configured_secret(value: Option<String>) -> Option<Vec<u8>> {
|
|
232
|
+
value
|
|
233
|
+
.filter(|secret| secret.len() >= 32 && !DEVELOPMENT_SECRETS.contains(&secret.as_str()))
|
|
234
|
+
.map(String::into_bytes)
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/// The bytes the website signs: `METHOD\nPATH\nTIMESTAMP\nBODY`.
|
|
238
|
+
pub fn signing_input(method: &str, path: &str, timestamp: &str, body: &[u8]) -> Vec<u8> {
|
|
239
|
+
let mut input = format!("{}\n{path}\n{timestamp}\n", method.to_uppercase()).into_bytes();
|
|
240
|
+
input.extend_from_slice(body);
|
|
241
|
+
input
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
pub fn sign_console_request(
|
|
245
|
+
secret: &[u8],
|
|
246
|
+
method: &str,
|
|
247
|
+
path: &str,
|
|
248
|
+
timestamp: &str,
|
|
249
|
+
body: &[u8],
|
|
250
|
+
) -> String {
|
|
251
|
+
let mut mac = Hmac::<Sha256>::new_from_slice(secret).expect("HMAC accepts any key length");
|
|
252
|
+
mac.update(&signing_input(method, path, timestamp, body));
|
|
253
|
+
mac.finalize()
|
|
254
|
+
.into_bytes()
|
|
255
|
+
.iter()
|
|
256
|
+
.map(|byte| format!("{byte:02x}"))
|
|
257
|
+
.collect()
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
fn decode_hex(value: &str) -> Option<Vec<u8>> {
|
|
261
|
+
if value.len() % 2 != 0 {
|
|
262
|
+
return None;
|
|
263
|
+
}
|
|
264
|
+
(0..value.len())
|
|
265
|
+
.step_by(2)
|
|
266
|
+
.map(|index| u8::from_str_radix(value.get(index..index + 2)?, 16).ok())
|
|
267
|
+
.collect()
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/// Verify a website request to the account routes. `timestamp` is Unix
|
|
271
|
+
/// seconds; requests more than five minutes away from `now` are refused so a
|
|
272
|
+
/// captured request cannot be replayed indefinitely.
|
|
273
|
+
pub fn verify_console_request(
|
|
274
|
+
secret: &[u8],
|
|
275
|
+
method: &str,
|
|
276
|
+
path: &str,
|
|
277
|
+
timestamp: Option<&str>,
|
|
278
|
+
signature: Option<&str>,
|
|
279
|
+
body: &[u8],
|
|
280
|
+
now: u64,
|
|
281
|
+
) -> Result<(), AccountError> {
|
|
282
|
+
let refused = |message: &str| AccountError::new(401, "CONSOLE_SIGNATURE_INVALID", message);
|
|
283
|
+
let timestamp = timestamp.ok_or_else(|| refused("console request timestamp required"))?;
|
|
284
|
+
let signature = signature.ok_or_else(|| refused("console request signature required"))?;
|
|
285
|
+
let seconds: u64 = timestamp
|
|
286
|
+
.trim()
|
|
287
|
+
.parse()
|
|
288
|
+
.map_err(|_| refused("console request timestamp is not Unix seconds"))?;
|
|
289
|
+
if seconds.abs_diff(now) > MAXIMUM_SIGNATURE_SKEW_SECONDS {
|
|
290
|
+
return Err(refused(
|
|
291
|
+
"console request timestamp is outside the allowed skew",
|
|
292
|
+
));
|
|
293
|
+
}
|
|
294
|
+
let supplied = decode_hex(signature.trim().to_lowercase().as_str())
|
|
295
|
+
.ok_or_else(|| refused("console request signature is not hex"))?;
|
|
296
|
+
let mut mac = Hmac::<Sha256>::new_from_slice(secret).expect("HMAC accepts any key length");
|
|
297
|
+
mac.update(&signing_input(method, path, timestamp, body));
|
|
298
|
+
mac.verify_slice(&supplied)
|
|
299
|
+
.map_err(|_| refused("console request signature does not verify"))
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
impl ConsoleAccountStore {
|
|
303
|
+
pub fn load(path: impl Into<PathBuf>, secret: Option<Vec<u8>>) -> Result<Self, String> {
|
|
304
|
+
let path = path.into();
|
|
305
|
+
let records = if path.exists() {
|
|
306
|
+
serde_json::from_slice(&fs::read(&path).map_err(|error| error.to_string())?)
|
|
307
|
+
.map_err(|error| format!("{}: {error}", path.display()))?
|
|
308
|
+
} else {
|
|
309
|
+
Records::default()
|
|
310
|
+
};
|
|
311
|
+
let loaded = fs::metadata(&path).and_then(|meta| meta.modified()).ok();
|
|
312
|
+
Ok(Self {
|
|
313
|
+
path,
|
|
314
|
+
records: Arc::new(RwLock::new(records)),
|
|
315
|
+
writer: Arc::new(std::sync::Mutex::new(())),
|
|
316
|
+
loaded: Arc::new(std::sync::Mutex::new(loaded)),
|
|
317
|
+
secret: secret.map(Into::into),
|
|
318
|
+
})
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/// Re-read the file if another process changed it since we last saw it.
|
|
322
|
+
fn refresh(&self) -> Result<(), AccountError> {
|
|
323
|
+
let poisoned = || AccountError::internal("console account lock poisoned");
|
|
324
|
+
let modified = fs::metadata(&self.path)
|
|
325
|
+
.and_then(|meta| meta.modified())
|
|
326
|
+
.ok();
|
|
327
|
+
if modified.is_none() || modified == *self.loaded.lock().map_err(|_| poisoned())? {
|
|
328
|
+
return Ok(());
|
|
329
|
+
}
|
|
330
|
+
let bytes = fs::read(&self.path)
|
|
331
|
+
.map_err(|error| AccountError::internal(format!("console account store: {error}")))?;
|
|
332
|
+
let records: Records = serde_json::from_slice(&bytes)
|
|
333
|
+
.map_err(|error| AccountError::internal(format!("console account store: {error}")))?;
|
|
334
|
+
// Lock order everywhere: records, then loaded (as in `persist`).
|
|
335
|
+
let mut current = self.records.write().map_err(|_| poisoned())?;
|
|
336
|
+
*current = records;
|
|
337
|
+
*self.loaded.lock().map_err(|_| poisoned())? = modified;
|
|
338
|
+
Ok(())
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
fn find(&self, matches: impl Fn(&ConsoleAccount) -> bool) -> Option<ConsoleAccount> {
|
|
342
|
+
let lookup = |matches: &dyn Fn(&ConsoleAccount) -> bool| {
|
|
343
|
+
self.read()
|
|
344
|
+
.ok()?
|
|
345
|
+
.accounts
|
|
346
|
+
.iter()
|
|
347
|
+
.find(|account| matches(account))
|
|
348
|
+
.cloned()
|
|
349
|
+
};
|
|
350
|
+
lookup(&matches).or_else(|| {
|
|
351
|
+
self.refresh().ok()?;
|
|
352
|
+
lookup(&matches)
|
|
353
|
+
})
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/// The signing secret, or `None` when the routes must answer 503.
|
|
357
|
+
pub fn secret(&self) -> Option<&[u8]> {
|
|
358
|
+
self.secret.as_deref()
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
fn persist(&self, records: &Records) -> Result<(), AccountError> {
|
|
362
|
+
let write = || -> std::io::Result<()> {
|
|
363
|
+
if let Some(parent) = self.path.parent() {
|
|
364
|
+
fs::create_dir_all(parent)?;
|
|
365
|
+
}
|
|
366
|
+
let temporary = self.path.with_extension("json.tmp");
|
|
367
|
+
let _ = fs::remove_file(&temporary);
|
|
368
|
+
let mut options = fs::OpenOptions::new();
|
|
369
|
+
options.write(true).create_new(true);
|
|
370
|
+
#[cfg(unix)]
|
|
371
|
+
{
|
|
372
|
+
use std::os::unix::fs::OpenOptionsExt;
|
|
373
|
+
options.mode(0o600);
|
|
374
|
+
}
|
|
375
|
+
let mut file = options.open(&temporary)?;
|
|
376
|
+
file.write_all(&serde_json::to_vec_pretty(records)?)?;
|
|
377
|
+
file.sync_all()?;
|
|
378
|
+
fs::rename(&temporary, &self.path)?;
|
|
379
|
+
if let Ok(mut loaded) = self.loaded.lock() {
|
|
380
|
+
*loaded = fs::metadata(&self.path)
|
|
381
|
+
.and_then(|meta| meta.modified())
|
|
382
|
+
.ok();
|
|
383
|
+
}
|
|
384
|
+
Ok(())
|
|
385
|
+
};
|
|
386
|
+
write().map_err(|error| AccountError::internal(format!("console account store: {error}")))
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
fn read(&self) -> Result<std::sync::RwLockReadGuard<'_, Records>, AccountError> {
|
|
390
|
+
self.records
|
|
391
|
+
.read()
|
|
392
|
+
.map_err(|_| AccountError::internal("console account lock poisoned"))
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
fn insert(&self, account: ConsoleAccount) -> Result<ConsoleAccount, AccountError> {
|
|
396
|
+
let mut records = self
|
|
397
|
+
.records
|
|
398
|
+
.write()
|
|
399
|
+
.map_err(|_| AccountError::internal("console account lock poisoned"))?;
|
|
400
|
+
records.accounts.push(account.clone());
|
|
401
|
+
if let Err(error) = self.persist(&records) {
|
|
402
|
+
records.accounts.pop();
|
|
403
|
+
return Err(error);
|
|
404
|
+
}
|
|
405
|
+
Ok(account)
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
pub fn account_for_user(&self, user_id: &str) -> Option<ConsoleAccount> {
|
|
409
|
+
self.find(|account| account.user_id == user_id)
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
pub fn account_for_identity(&self, identity: &str) -> Option<ConsoleAccount> {
|
|
413
|
+
self.find(|account| account.identity == identity)
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
pub fn list(&self) -> Vec<ConsoleAccount> {
|
|
417
|
+
let _ = self.refresh();
|
|
418
|
+
self.read()
|
|
419
|
+
.map(|records| records.accounts.clone())
|
|
420
|
+
.unwrap_or_default()
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
fn register_platform_user(
|
|
424
|
+
tenancy: &TenancyStore,
|
|
425
|
+
account: &ConsoleAccount,
|
|
426
|
+
) -> Result<(), AccountError> {
|
|
427
|
+
tenancy
|
|
428
|
+
.register_platform_user(&account.user_id, &account.display_identity)
|
|
429
|
+
.map_err(AccountError::internal)
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/// Create a password account. A pre-0.15.0 platform user registered under
|
|
433
|
+
/// this e-mail (unverified, but possibly owning tenants) is never handed
|
|
434
|
+
/// to whoever signs up first: it has to be claimed by an operator.
|
|
435
|
+
pub fn password_signup(
|
|
436
|
+
&self,
|
|
437
|
+
tenancy: &TenancyStore,
|
|
438
|
+
email: &str,
|
|
439
|
+
password: &str,
|
|
440
|
+
) -> Result<ResolvedAccount, AccountError> {
|
|
441
|
+
let email = normalize_email(email)?;
|
|
442
|
+
validate_password(password)?;
|
|
443
|
+
let _writer = self
|
|
444
|
+
.writer
|
|
445
|
+
.lock()
|
|
446
|
+
.map_err(|_| AccountError::internal("console account writer poisoned"))?;
|
|
447
|
+
self.refresh()?;
|
|
448
|
+
if self.account_for_identity(&email).is_some() {
|
|
449
|
+
return Err(AccountError::new(
|
|
450
|
+
409,
|
|
451
|
+
"ACCOUNT_EXISTS",
|
|
452
|
+
"an account already exists for this email; sign in instead",
|
|
453
|
+
));
|
|
454
|
+
}
|
|
455
|
+
if !self.unclaimed_legacy_users(tenancy, &email).is_empty() {
|
|
456
|
+
return Err(AccountError::new(
|
|
457
|
+
409,
|
|
458
|
+
"ACCOUNT_CLAIM_REQUIRED",
|
|
459
|
+
"this email has a console account created before password verification; \
|
|
460
|
+
an operator must claim it",
|
|
461
|
+
));
|
|
462
|
+
}
|
|
463
|
+
let mut user_id = new_user_id();
|
|
464
|
+
while self.account_for_user(&user_id).is_some() || tenancy.platform_user_exists(&user_id) {
|
|
465
|
+
user_id = new_user_id();
|
|
466
|
+
}
|
|
467
|
+
let account = self.insert(ConsoleAccount {
|
|
468
|
+
identity: email.clone(),
|
|
469
|
+
user_id,
|
|
470
|
+
password_hash: Some(hash_password(password)?),
|
|
471
|
+
provider: ConsoleProvider::Password,
|
|
472
|
+
created_at: now(),
|
|
473
|
+
display_identity: email,
|
|
474
|
+
origin: default_origin(),
|
|
475
|
+
})?;
|
|
476
|
+
Self::register_platform_user(tenancy, &account)?;
|
|
477
|
+
Ok((&account).into())
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
pub fn password_signin(
|
|
481
|
+
&self,
|
|
482
|
+
email: &str,
|
|
483
|
+
password: &str,
|
|
484
|
+
) -> Result<ResolvedAccount, AccountError> {
|
|
485
|
+
let account = normalize_email(email)
|
|
486
|
+
.ok()
|
|
487
|
+
.and_then(|email| self.account_for_identity(&email))
|
|
488
|
+
.filter(|account| account.provider == ConsoleProvider::Password);
|
|
489
|
+
let hash = account
|
|
490
|
+
.as_ref()
|
|
491
|
+
.and_then(|account| account.password_hash.as_deref())
|
|
492
|
+
.unwrap_or_else(|| dummy_hash());
|
|
493
|
+
let verified = verify_password(password, hash);
|
|
494
|
+
match account {
|
|
495
|
+
Some(account) if verified => Ok((&account).into()),
|
|
496
|
+
_ => Err(invalid_credentials()),
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
/// Platform users the old website could have created for `email` that no
|
|
501
|
+
/// console account owns yet.
|
|
502
|
+
fn unclaimed_legacy_users(&self, tenancy: &TenancyStore, email: &str) -> Vec<String> {
|
|
503
|
+
tenancy
|
|
504
|
+
.platform_users_with_identity(email)
|
|
505
|
+
.into_iter()
|
|
506
|
+
.filter(|user_id| user_id.starts_with("user_"))
|
|
507
|
+
.filter(|user_id| self.account_for_user(user_id).is_none())
|
|
508
|
+
.collect()
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
/// Resolve the console user id for a GitHub sign-in.
|
|
512
|
+
///
|
|
513
|
+
/// An account bound to `github:<id>` wins. Otherwise a pre-0.15.0 user
|
|
514
|
+
/// whose derived id came from `@<login>` and whose recorded identity is
|
|
515
|
+
/// exactly `@<login>` keeps its data (continuity), unless another console
|
|
516
|
+
/// account already owns that id. Everything else gets a new random id.
|
|
517
|
+
/// The binding is by numeric GitHub id, so a later login rename (or a new
|
|
518
|
+
/// GitHub user taking an old login) never moves it.
|
|
519
|
+
pub fn github_resolve(
|
|
520
|
+
&self,
|
|
521
|
+
tenancy: &TenancyStore,
|
|
522
|
+
github_id: &str,
|
|
523
|
+
login: &str,
|
|
524
|
+
) -> Result<ResolvedAccount, AccountError> {
|
|
525
|
+
let github_id = github_id.trim();
|
|
526
|
+
if github_id.is_empty()
|
|
527
|
+
|| github_id.len() > 20
|
|
528
|
+
|| !github_id
|
|
529
|
+
.chars()
|
|
530
|
+
.all(|character| character.is_ascii_digit())
|
|
531
|
+
{
|
|
532
|
+
return Err(AccountError::new(
|
|
533
|
+
400,
|
|
534
|
+
"VALIDATION_ERROR",
|
|
535
|
+
"github_id must be the numeric GitHub user id",
|
|
536
|
+
));
|
|
537
|
+
}
|
|
538
|
+
let login = login.trim().to_lowercase();
|
|
539
|
+
if login.is_empty()
|
|
540
|
+
|| login.len() > 39
|
|
541
|
+
|| !login
|
|
542
|
+
.chars()
|
|
543
|
+
.all(|character| character.is_ascii_alphanumeric() || character == '-')
|
|
544
|
+
{
|
|
545
|
+
return Err(AccountError::new(
|
|
546
|
+
400,
|
|
547
|
+
"VALIDATION_ERROR",
|
|
548
|
+
"login must be a GitHub login",
|
|
549
|
+
));
|
|
550
|
+
}
|
|
551
|
+
let identity = format!("github:{github_id}");
|
|
552
|
+
let display_identity = format!("@{login}");
|
|
553
|
+
let _writer = self
|
|
554
|
+
.writer
|
|
555
|
+
.lock()
|
|
556
|
+
.map_err(|_| AccountError::internal("console account writer poisoned"))?;
|
|
557
|
+
self.refresh()?;
|
|
558
|
+
{
|
|
559
|
+
let mut records = self
|
|
560
|
+
.records
|
|
561
|
+
.write()
|
|
562
|
+
.map_err(|_| AccountError::internal("console account lock poisoned"))?;
|
|
563
|
+
if let Some(index) = records
|
|
564
|
+
.accounts
|
|
565
|
+
.iter()
|
|
566
|
+
.position(|account| account.identity == identity)
|
|
567
|
+
{
|
|
568
|
+
if records.accounts[index].display_identity != display_identity {
|
|
569
|
+
records.accounts[index].display_identity = display_identity.clone();
|
|
570
|
+
self.persist(&records)?;
|
|
571
|
+
}
|
|
572
|
+
return Ok((&records.accounts[index]).into());
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
let legacy = legacy_user_id(&display_identity);
|
|
576
|
+
let continuity = tenancy.platform_user_identity(&legacy).as_deref()
|
|
577
|
+
== Some(display_identity.as_str())
|
|
578
|
+
&& self.account_for_user(&legacy).is_none();
|
|
579
|
+
let (user_id, origin) = if continuity {
|
|
580
|
+
(legacy, "legacy_github_login".to_string())
|
|
581
|
+
} else {
|
|
582
|
+
let mut user_id = new_user_id();
|
|
583
|
+
while self.account_for_user(&user_id).is_some()
|
|
584
|
+
|| tenancy.platform_user_exists(&user_id)
|
|
585
|
+
{
|
|
586
|
+
user_id = new_user_id();
|
|
587
|
+
}
|
|
588
|
+
(user_id, default_origin())
|
|
589
|
+
};
|
|
590
|
+
let account = self.insert(ConsoleAccount {
|
|
591
|
+
identity,
|
|
592
|
+
user_id,
|
|
593
|
+
password_hash: None,
|
|
594
|
+
provider: ConsoleProvider::Github,
|
|
595
|
+
created_at: now(),
|
|
596
|
+
display_identity,
|
|
597
|
+
origin,
|
|
598
|
+
})?;
|
|
599
|
+
if !continuity {
|
|
600
|
+
Self::register_platform_user(tenancy, &account)?;
|
|
601
|
+
}
|
|
602
|
+
Ok((&account).into())
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
/// Operator claim: bind a password account for `email` to the existing
|
|
606
|
+
/// pre-0.15.0 platform user registered under that e-mail.
|
|
607
|
+
pub fn claim_password(
|
|
608
|
+
&self,
|
|
609
|
+
tenancy: &TenancyStore,
|
|
610
|
+
email: &str,
|
|
611
|
+
password: &str,
|
|
612
|
+
) -> Result<ResolvedAccount, AccountError> {
|
|
613
|
+
let email = normalize_email(email)?;
|
|
614
|
+
validate_password(password)?;
|
|
615
|
+
let _writer = self
|
|
616
|
+
.writer
|
|
617
|
+
.lock()
|
|
618
|
+
.map_err(|_| AccountError::internal("console account writer poisoned"))?;
|
|
619
|
+
self.refresh()?;
|
|
620
|
+
if self.account_for_identity(&email).is_some() {
|
|
621
|
+
return Err(AccountError::new(
|
|
622
|
+
409,
|
|
623
|
+
"ACCOUNT_EXISTS",
|
|
624
|
+
"a console account already exists for this email",
|
|
625
|
+
));
|
|
626
|
+
}
|
|
627
|
+
let candidates = self.unclaimed_legacy_users(tenancy, &email);
|
|
628
|
+
let user_id = match candidates.as_slice() {
|
|
629
|
+
[user_id] => user_id.clone(),
|
|
630
|
+
[] => {
|
|
631
|
+
return Err(AccountError::new(
|
|
632
|
+
404,
|
|
633
|
+
"NOT_FOUND",
|
|
634
|
+
"no unclaimed legacy platform user is registered under this email",
|
|
635
|
+
))
|
|
636
|
+
}
|
|
637
|
+
_ => {
|
|
638
|
+
return Err(AccountError::new(
|
|
639
|
+
409,
|
|
640
|
+
"CONFLICT",
|
|
641
|
+
format!(
|
|
642
|
+
"several unclaimed platform users are registered under this email: {}",
|
|
643
|
+
candidates.join(", ")
|
|
644
|
+
),
|
|
645
|
+
))
|
|
646
|
+
}
|
|
647
|
+
};
|
|
648
|
+
let account = self.insert(ConsoleAccount {
|
|
649
|
+
identity: email.clone(),
|
|
650
|
+
user_id,
|
|
651
|
+
password_hash: Some(hash_password(password)?),
|
|
652
|
+
provider: ConsoleProvider::Password,
|
|
653
|
+
created_at: now(),
|
|
654
|
+
display_identity: email,
|
|
655
|
+
origin: "operator_claim".into(),
|
|
656
|
+
})?;
|
|
657
|
+
Ok((&account).into())
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
#[cfg(test)]
|
|
662
|
+
mod tests {
|
|
663
|
+
use super::*;
|
|
664
|
+
|
|
665
|
+
fn stores() -> (ConsoleAccountStore, TenancyStore, PathBuf) {
|
|
666
|
+
let dir = std::env::temp_dir().join(format!("feltdb-console-accounts-{}", new_user_id()));
|
|
667
|
+
fs::create_dir_all(&dir).unwrap();
|
|
668
|
+
let accounts =
|
|
669
|
+
ConsoleAccountStore::load(dir.join("state.console-accounts.json"), None).unwrap();
|
|
670
|
+
let tenancy = TenancyStore::load(dir.join("state.platform.json")).unwrap();
|
|
671
|
+
(accounts, tenancy, dir)
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
#[test]
|
|
675
|
+
fn signup_then_signin_verifies_the_password() {
|
|
676
|
+
let (accounts, tenancy, dir) = stores();
|
|
677
|
+
let created = accounts
|
|
678
|
+
.password_signup(&tenancy, " Alice@Example.com ", "correct horse battery")
|
|
679
|
+
.unwrap();
|
|
680
|
+
assert_eq!(created.identity, "alice@example.com");
|
|
681
|
+
assert!(created.user_id.starts_with("user_") && created.user_id.len() >= 27);
|
|
682
|
+
assert_eq!(
|
|
683
|
+
tenancy.platform_user_identity(&created.user_id).as_deref(),
|
|
684
|
+
Some("alice@example.com")
|
|
685
|
+
);
|
|
686
|
+
let signed_in = accounts
|
|
687
|
+
.password_signin("alice@example.com", "correct horse battery")
|
|
688
|
+
.unwrap();
|
|
689
|
+
assert_eq!(signed_in.user_id, created.user_id);
|
|
690
|
+
assert_eq!(
|
|
691
|
+
accounts
|
|
692
|
+
.password_signin("alice@example.com", "wrong horse battery")
|
|
693
|
+
.unwrap_err()
|
|
694
|
+
.code,
|
|
695
|
+
"INVALID_CREDENTIALS"
|
|
696
|
+
);
|
|
697
|
+
assert_eq!(
|
|
698
|
+
accounts
|
|
699
|
+
.password_signin("nobody@example.com", "correct horse battery")
|
|
700
|
+
.unwrap_err()
|
|
701
|
+
.code,
|
|
702
|
+
"INVALID_CREDENTIALS"
|
|
703
|
+
);
|
|
704
|
+
assert_eq!(
|
|
705
|
+
accounts
|
|
706
|
+
.password_signup(&tenancy, "alice@example.com", "another long password")
|
|
707
|
+
.unwrap_err()
|
|
708
|
+
.code,
|
|
709
|
+
"ACCOUNT_EXISTS"
|
|
710
|
+
);
|
|
711
|
+
// Persisted, owner-only, and reloadable.
|
|
712
|
+
let path = dir.join("state.console-accounts.json");
|
|
713
|
+
#[cfg(unix)]
|
|
714
|
+
{
|
|
715
|
+
use std::os::unix::fs::PermissionsExt;
|
|
716
|
+
assert_eq!(
|
|
717
|
+
fs::metadata(&path).unwrap().permissions().mode() & 0o777,
|
|
718
|
+
0o600
|
|
719
|
+
);
|
|
720
|
+
}
|
|
721
|
+
let reloaded = ConsoleAccountStore::load(&path, None).unwrap();
|
|
722
|
+
assert_eq!(
|
|
723
|
+
reloaded
|
|
724
|
+
.password_signin("alice@example.com", "correct horse battery")
|
|
725
|
+
.unwrap()
|
|
726
|
+
.user_id,
|
|
727
|
+
created.user_id
|
|
728
|
+
);
|
|
729
|
+
fs::remove_dir_all(dir).ok();
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
#[test]
|
|
733
|
+
fn prefix_sharing_emails_get_distinct_user_ids() {
|
|
734
|
+
let (accounts, tenancy, dir) = stores();
|
|
735
|
+
let one = accounts
|
|
736
|
+
.password_signup(
|
|
737
|
+
&tenancy,
|
|
738
|
+
"abcdefghijklm-one@example.com",
|
|
739
|
+
"password-one-long",
|
|
740
|
+
)
|
|
741
|
+
.unwrap();
|
|
742
|
+
let two = accounts
|
|
743
|
+
.password_signup(
|
|
744
|
+
&tenancy,
|
|
745
|
+
"abcdefghijklm-two@example.com",
|
|
746
|
+
"password-two-long",
|
|
747
|
+
)
|
|
748
|
+
.unwrap();
|
|
749
|
+
assert_eq!(
|
|
750
|
+
legacy_user_id("abcdefghijklm-one@example.com"),
|
|
751
|
+
legacy_user_id("abcdefghijklm-two@example.com")
|
|
752
|
+
);
|
|
753
|
+
assert_ne!(one.user_id, two.user_id);
|
|
754
|
+
fs::remove_dir_all(dir).ok();
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
#[test]
|
|
758
|
+
fn password_policy_and_email_shape_are_enforced() {
|
|
759
|
+
let (accounts, tenancy, dir) = stores();
|
|
760
|
+
assert_eq!(
|
|
761
|
+
accounts
|
|
762
|
+
.password_signup(&tenancy, "short@example.com", "elevenchars")
|
|
763
|
+
.unwrap_err()
|
|
764
|
+
.code,
|
|
765
|
+
"PASSWORD_TOO_SHORT"
|
|
766
|
+
);
|
|
767
|
+
assert_eq!(
|
|
768
|
+
accounts
|
|
769
|
+
.password_signup(&tenancy, "not-an-email", "a long enough password")
|
|
770
|
+
.unwrap_err()
|
|
771
|
+
.code,
|
|
772
|
+
"VALIDATION_ERROR"
|
|
773
|
+
);
|
|
774
|
+
fs::remove_dir_all(dir).ok();
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
#[test]
|
|
778
|
+
fn a_legacy_password_user_must_be_claimed_by_an_operator() {
|
|
779
|
+
let (accounts, tenancy, dir) = stores();
|
|
780
|
+
let legacy = legacy_user_id("owner@example.com");
|
|
781
|
+
tenancy
|
|
782
|
+
.register_platform_user(&legacy, "owner@example.com")
|
|
783
|
+
.unwrap();
|
|
784
|
+
assert_eq!(
|
|
785
|
+
accounts
|
|
786
|
+
.password_signup(&tenancy, "owner@example.com", "attacker password")
|
|
787
|
+
.unwrap_err()
|
|
788
|
+
.code,
|
|
789
|
+
"ACCOUNT_CLAIM_REQUIRED"
|
|
790
|
+
);
|
|
791
|
+
let claimed = accounts
|
|
792
|
+
.claim_password(&tenancy, "owner@example.com", "the owner's password")
|
|
793
|
+
.unwrap();
|
|
794
|
+
assert_eq!(claimed.user_id, legacy);
|
|
795
|
+
assert_eq!(
|
|
796
|
+
accounts
|
|
797
|
+
.password_signin("owner@example.com", "the owner's password")
|
|
798
|
+
.unwrap()
|
|
799
|
+
.user_id,
|
|
800
|
+
legacy
|
|
801
|
+
);
|
|
802
|
+
assert_eq!(
|
|
803
|
+
accounts
|
|
804
|
+
.claim_password(&tenancy, "owner@example.com", "the owner's password")
|
|
805
|
+
.unwrap_err()
|
|
806
|
+
.code,
|
|
807
|
+
"ACCOUNT_EXISTS"
|
|
808
|
+
);
|
|
809
|
+
assert_eq!(
|
|
810
|
+
accounts
|
|
811
|
+
.claim_password(&tenancy, "stranger@example.com", "a long enough password")
|
|
812
|
+
.unwrap_err()
|
|
813
|
+
.status,
|
|
814
|
+
404
|
|
815
|
+
);
|
|
816
|
+
fs::remove_dir_all(dir).ok();
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
#[test]
|
|
820
|
+
fn github_resolve_keeps_the_genuine_legacy_owner_and_isolates_colliders() {
|
|
821
|
+
let (accounts, tenancy, dir) = stores();
|
|
822
|
+
// Two logins whose legacy ids collide (shared 13-character prefix).
|
|
823
|
+
let victim = "averylongname-victim";
|
|
824
|
+
let attacker = "averylongname-attack";
|
|
825
|
+
let legacy = legacy_user_id(&format!("@{victim}"));
|
|
826
|
+
assert_eq!(legacy, legacy_user_id(&format!("@{attacker}")));
|
|
827
|
+
tenancy
|
|
828
|
+
.register_platform_user(&legacy, &format!("@{victim}"))
|
|
829
|
+
.unwrap();
|
|
830
|
+
|
|
831
|
+
let colliding = accounts.github_resolve(&tenancy, "999", attacker).unwrap();
|
|
832
|
+
assert_ne!(colliding.user_id, legacy);
|
|
833
|
+
let genuine = accounts.github_resolve(&tenancy, "123", victim).unwrap();
|
|
834
|
+
assert_eq!(genuine.user_id, legacy);
|
|
835
|
+
assert_eq!(genuine.identity, "github:123");
|
|
836
|
+
// Stable across sign-ins and a login rename.
|
|
837
|
+
assert_eq!(
|
|
838
|
+
accounts
|
|
839
|
+
.github_resolve(&tenancy, "123", "renamed-victim")
|
|
840
|
+
.unwrap()
|
|
841
|
+
.user_id,
|
|
842
|
+
legacy
|
|
843
|
+
);
|
|
844
|
+
// A new GitHub user taking the old login cannot re-bind the legacy id.
|
|
845
|
+
let squatter = accounts.github_resolve(&tenancy, "555", victim).unwrap();
|
|
846
|
+
assert_ne!(squatter.user_id, legacy);
|
|
847
|
+
assert_eq!(
|
|
848
|
+
accounts
|
|
849
|
+
.github_resolve(&tenancy, "12a", victim)
|
|
850
|
+
.unwrap_err()
|
|
851
|
+
.code,
|
|
852
|
+
"VALIDATION_ERROR"
|
|
853
|
+
);
|
|
854
|
+
fs::remove_dir_all(dir).ok();
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
#[test]
|
|
858
|
+
fn signed_requests_verify_and_forgeries_do_not() {
|
|
859
|
+
let secret = b"0123456789abcdef0123456789abcdef";
|
|
860
|
+
let body = br#"{"email":"a@example.com","password":"x"}"#;
|
|
861
|
+
let now = 1_800_000_000;
|
|
862
|
+
let timestamp = now.to_string();
|
|
863
|
+
let path = "/v1/console/accounts/password/signin";
|
|
864
|
+
let signature = sign_console_request(secret, "POST", path, ×tamp, body);
|
|
865
|
+
assert!(verify_console_request(
|
|
866
|
+
secret,
|
|
867
|
+
"POST",
|
|
868
|
+
path,
|
|
869
|
+
Some(×tamp),
|
|
870
|
+
Some(&signature),
|
|
871
|
+
body,
|
|
872
|
+
now
|
|
873
|
+
)
|
|
874
|
+
.is_ok());
|
|
875
|
+
let refused = |method: &str,
|
|
876
|
+
path: &str,
|
|
877
|
+
timestamp: Option<&str>,
|
|
878
|
+
signature: Option<&str>,
|
|
879
|
+
body: &[u8],
|
|
880
|
+
now: u64| {
|
|
881
|
+
verify_console_request(secret, method, path, timestamp, signature, body, now)
|
|
882
|
+
.unwrap_err()
|
|
883
|
+
.code
|
|
884
|
+
};
|
|
885
|
+
assert_eq!(
|
|
886
|
+
refused("POST", path, Some(×tamp), None, body, now),
|
|
887
|
+
"CONSOLE_SIGNATURE_INVALID"
|
|
888
|
+
);
|
|
889
|
+
assert_eq!(
|
|
890
|
+
refused("POST", path, None, Some(&signature), body, now),
|
|
891
|
+
"CONSOLE_SIGNATURE_INVALID"
|
|
892
|
+
);
|
|
893
|
+
// Body, path, method and timestamp are all covered by the MAC.
|
|
894
|
+
assert!(verify_console_request(
|
|
895
|
+
secret,
|
|
896
|
+
"POST",
|
|
897
|
+
path,
|
|
898
|
+
Some(×tamp),
|
|
899
|
+
Some(&signature),
|
|
900
|
+
b"{}",
|
|
901
|
+
now
|
|
902
|
+
)
|
|
903
|
+
.is_err());
|
|
904
|
+
assert!(verify_console_request(
|
|
905
|
+
secret,
|
|
906
|
+
"POST",
|
|
907
|
+
"/v1/console/accounts/password/signup",
|
|
908
|
+
Some(×tamp),
|
|
909
|
+
Some(&signature),
|
|
910
|
+
body,
|
|
911
|
+
now
|
|
912
|
+
)
|
|
913
|
+
.is_err());
|
|
914
|
+
assert!(verify_console_request(
|
|
915
|
+
secret,
|
|
916
|
+
"PUT",
|
|
917
|
+
path,
|
|
918
|
+
Some(×tamp),
|
|
919
|
+
Some(&signature),
|
|
920
|
+
body,
|
|
921
|
+
now
|
|
922
|
+
)
|
|
923
|
+
.is_err());
|
|
924
|
+
let other = (now + 1).to_string();
|
|
925
|
+
assert!(verify_console_request(
|
|
926
|
+
secret,
|
|
927
|
+
"POST",
|
|
928
|
+
path,
|
|
929
|
+
Some(&other),
|
|
930
|
+
Some(&signature),
|
|
931
|
+
body,
|
|
932
|
+
now
|
|
933
|
+
)
|
|
934
|
+
.is_err());
|
|
935
|
+
// Another key does not verify.
|
|
936
|
+
let forged = sign_console_request(
|
|
937
|
+
b"another-secret-another-secret-xx",
|
|
938
|
+
"POST",
|
|
939
|
+
path,
|
|
940
|
+
×tamp,
|
|
941
|
+
body,
|
|
942
|
+
);
|
|
943
|
+
assert!(verify_console_request(
|
|
944
|
+
secret,
|
|
945
|
+
"POST",
|
|
946
|
+
path,
|
|
947
|
+
Some(×tamp),
|
|
948
|
+
Some(&forged),
|
|
949
|
+
body,
|
|
950
|
+
now
|
|
951
|
+
)
|
|
952
|
+
.is_err());
|
|
953
|
+
// Stale and future timestamps are refused even when correctly signed.
|
|
954
|
+
let stale = (now - MAXIMUM_SIGNATURE_SKEW_SECONDS - 1).to_string();
|
|
955
|
+
let stale_signature = sign_console_request(secret, "POST", path, &stale, body);
|
|
956
|
+
assert!(verify_console_request(
|
|
957
|
+
secret,
|
|
958
|
+
"POST",
|
|
959
|
+
path,
|
|
960
|
+
Some(&stale),
|
|
961
|
+
Some(&stale_signature),
|
|
962
|
+
body,
|
|
963
|
+
now
|
|
964
|
+
)
|
|
965
|
+
.is_err());
|
|
966
|
+
let future = (now + MAXIMUM_SIGNATURE_SKEW_SECONDS + 1).to_string();
|
|
967
|
+
let future_signature = sign_console_request(secret, "POST", path, &future, body);
|
|
968
|
+
assert!(verify_console_request(
|
|
969
|
+
secret,
|
|
970
|
+
"POST",
|
|
971
|
+
path,
|
|
972
|
+
Some(&future),
|
|
973
|
+
Some(&future_signature),
|
|
974
|
+
body,
|
|
975
|
+
now
|
|
976
|
+
)
|
|
977
|
+
.is_err());
|
|
978
|
+
assert!(verify_console_request(
|
|
979
|
+
secret,
|
|
980
|
+
"POST",
|
|
981
|
+
path,
|
|
982
|
+
Some(×tamp),
|
|
983
|
+
Some("zz"),
|
|
984
|
+
body,
|
|
985
|
+
now
|
|
986
|
+
)
|
|
987
|
+
.is_err());
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
#[test]
|
|
991
|
+
fn only_a_real_secret_enables_the_routes() {
|
|
992
|
+
assert!(configured_secret(None).is_none());
|
|
993
|
+
assert!(configured_secret(Some("short".into())).is_none());
|
|
994
|
+
assert!(
|
|
995
|
+
configured_secret(Some("feltdb-development-session-secret-change-me".into())).is_none()
|
|
996
|
+
);
|
|
997
|
+
assert!(configured_secret(Some("x".repeat(32))).is_some());
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
#[test]
|
|
1001
|
+
fn legacy_user_id_matches_the_old_website_formula() {
|
|
1002
|
+
// 'user_' + Buffer.from('@octocat').toString('base64url').slice(0, 18)
|
|
1003
|
+
assert_eq!(legacy_user_id("@OctoCat"), "user_QG9jdG9jYXQ");
|
|
1004
|
+
assert_eq!(
|
|
1005
|
+
legacy_user_id("alice@example.com"),
|
|
1006
|
+
"user_YWxpY2VAZXhhbXBsZS"
|
|
1007
|
+
);
|
|
1008
|
+
}
|
|
1009
|
+
}
|