From d6196a16c1eec7268e1fc5124ca89ee3d03d6d3e Mon Sep 17 00:00:00 2001 From: Brad Seiler Date: Wed, 28 Oct 2026 18:42:49 -0400 Subject: [PATCH] fix(relay): revoke invitations and restrict claims (#8237) --- .env.example | 4 + Justfile | 6 +- TESTING.md | 86 ++- crates/buzz-acp/tests/run_task.rs | 73 +- crates/buzz-db/TESTING.md | 7 + crates/buzz-db/src/error.rs | 8 + crates/buzz-db/src/runtime/migration.rs | 36 +- .../store/event_follow_up_postgres_tests.rs | 19 +- crates/buzz-db/src/store/moderation.rs | 6 +- .../buzz-db/src/store/relay_admin_actions.rs | 10 +- crates/buzz-db/src/store/relay_invite.rs | 408 +++++++++-- .../src/store/relay_invite/claim_owner.rs | 158 ++++ .../owner_admission_postgres_tests.rs | 182 +++++ .../src/store/relay_invite/revocation.rs | 51 ++ .../relay_invite/revocation_postgres_tests.rs | 645 +++++++++++++++++ .../relay_invite/upgrade_postgres_tests.rs | 673 ++++++++++++++++++ .../writer_protocol_postgres_tests.rs | 378 ++++++++++ crates/buzz-db/src/store/relay_members.rs | 61 +- crates/buzz-db/src/store/user.rs | 29 + crates/buzz-relay/src/api/bridge.rs | 13 +- crates/buzz-relay/src/api/invites.rs | 124 ++-- .../buzz-relay/src/api/invites/claim_auth.rs | 58 ++ .../postgres_tests/owner_admission_tests.rs | 243 +++++++ .../postgres_tests/restriction_tests.rs | 387 ++++++++++ crates/buzz-relay/src/api/mesh_demo.rs | 3 +- crates/buzz-relay/src/api/mod.rs | 3 + crates/buzz-relay/src/config.rs | 50 ++ crates/buzz-relay/src/handlers/ingest.rs | 11 +- crates/buzz-relay/src/invite_token.rs | 61 +- crates/buzz-relay/src/mesh_boot.rs | 109 ++- crates/buzz-relay/src/tunnel/reliable.rs | 3 + crates/buzz-relay/tests/boot_lifecycle.rs | 52 +- .../discovery/bounded_command.rs | 146 +++- .../ui/CommunitySwitcher.jsdom-test.mjs | 64 ++ .../communities/ui/CommunitySwitcher.tsx | 19 +- .../onboarding/useClaimInvite.jsdom-test.mjs | 119 ++++ .../src/features/onboarding/useClaimInvite.ts | 12 +- desktop/src/shared/api/inviteHelpers.ts | 18 + desktop/tests/e2e/community-rail.spec.ts | 83 +-- .../e2e/composer-selection-formatting.spec.ts | 53 +- migrations/0075_invite_revocation.sql | 5 + .../invites/invite_join_provider.dart | 6 + .../lib/shared/hosted/hosted_admission.dart | 6 +- mobile/lib/shared/hosted/hosted_flow.dart | 7 +- .../hosted/hosted_flow_problem_message.dart | 4 + .../lib/shared/hosted/hosted_join_policy.dart | 11 +- .../invites/invite_join_provider_test.dart | 62 ++ .../onboarding/hosted_journeys_test.dart | 62 ++ .../test/helpers/hosted_journey_fixture.dart | 4 + schema/tables/public/relay_invites.sql | 2 + scripts/reconcile-schema-after-pgschema.sql | 34 + scripts/run-tests.sh | 14 + web/src/features/invite/ui/InvitePage.tsx | 6 + web/tests/e2e/smoke.spec.ts | 60 ++ 54 files changed, 4398 insertions(+), 356 deletions(-) create mode 100644 crates/buzz-db/src/store/relay_invite/claim_owner.rs create mode 100644 crates/buzz-db/src/store/relay_invite/owner_admission_postgres_tests.rs create mode 100644 crates/buzz-db/src/store/relay_invite/revocation.rs create mode 100644 crates/buzz-db/src/store/relay_invite/revocation_postgres_tests.rs create mode 100644 crates/buzz-db/src/store/relay_invite/upgrade_postgres_tests.rs create mode 100644 crates/buzz-db/src/store/relay_invite/writer_protocol_postgres_tests.rs create mode 100644 crates/buzz-relay/src/api/invites/claim_auth.rs create mode 100644 crates/buzz-relay/src/api/invites/postgres_tests/owner_admission_tests.rs create mode 100644 crates/buzz-relay/src/api/invites/postgres_tests/restriction_tests.rs create mode 100644 desktop/src/features/communities/ui/CommunitySwitcher.jsdom-test.mjs create mode 100644 desktop/src/features/onboarding/useClaimInvite.jsdom-test.mjs create mode 100644 migrations/0075_invite_revocation.sql diff --git a/.env.example b/.env.example index 73d4d9f6ea..eaec3703c7 100644 --- a/.env.example +++ b/.env.example @@ -392,3 +392,7 @@ RUST_LOG=buzz_relay=debug,buzz_datastore=info,buzz_db=debug,buzz_auth=debug,buzz # BUZZ_NIP_FI_RESOLVER_NEGATIVE_CACHE_MAX=100000 # Concurrent live lookups; clamped below the database pool size. # BUZZ_NIP_FI_RESOLVER_MAX_INFLIGHT=8 + +# Optional absolute legacy-v1 invite cutoff, UTC RFC3339 whole seconds. +# Unset preserves natural token expiry; never changes v2 invitations. +# BUZZ_INVITE_V1_INVALID_AFTER=2026-12-01T00:00:00Z diff --git a/Justfile b/Justfile index 18b444f5b4..f1ae4b83f8 100644 --- a/Justfile +++ b/Justfile @@ -543,7 +543,7 @@ test-unit: # never connects. # `^config::` is anchored so it does not also select nip_fi_config. cargo nextest run -p buzz-relay --lib --bin buzz-relay \ - -E 'test(/^api::admin::/) + test(/^push_runtime::/) + test(/^operator_listener::tests::/) + test(/^handlers::channel_authz::/) + test(/^handlers::moderation_authz::/) + test(/^handlers::side_effects::tests::/) + test(/^handlers::coalesced_refresh::tests::/) + test(/^handlers::system_channels::tests::/) + test(/^nip11::tests::/) + test(/^storage_sweep::tests::/) + test(/^nip_fi_core::tests::/) + test(/^nip_fi_http::tests::/) + test(/^nip_fi_config::tests::/) + test(/^nip_fi_resolver::tests::/) + test(/^readiness::tests::/) + test(/^router::tests::/) + test(/^api::parse_query_tests::/) + test(/^api::profiles::/) + test(/^api::git::transport::off_mode_precedence_tests::/) + test(/^audio::join::tests::/) + test(/^audio::handler::tests::/) + test(/^nip_fi_gate::tests::/) + test(/^nip_fi_session::tests::/) + test(/^nip_fi_shadow::tests::/) + test(/^nip_fi_shadow_session::tests::/) + test(/^startup_steps::tests::/) + test(/^mesh_boot::tests::/) + test(/^telemetry::tests::/) + test(/^config::tests::/) + test(=state::tests::neither_a_confirmed_inactive_community_nor_a_failed_lookup_admits_the_socket) + test(=state::tests::periodic_revalidation_disconnects_inside_the_fenced_callback) + test(=state::tests::bare_community_disconnect_fails_closed_when_the_fence_is_unavailable) + test(=connection::tests::send_loop_sends_policy_close_when_community_is_archived) + test(=state::tests::community_disconnect_then_nip_fi_keeps_community_deleted_reason) + test(=state::tests::disconnect_community_wins_reason_losing_nip_fi_does_not_enqueue_frame) + test(=handlers::req::tests::timed_out_historical_read_deregisters_before_closed) + test(=handlers::req::tests::superseded_timeout_leaves_replacement_intact) + test(=handlers::req::tests::search_claim_retires_live_and_yields_to_replacement) + test(=handlers::req::tests::concurrent_claims_and_stale_teardowns_keep_the_last_owner) + test(=handlers::req::tests::timeout_closed_is_emitted_before_a_replacement_can_claim) + test(=handlers::req::tests::revoke_then_replacement_keeps_replacement_whole) + test(=handlers::req::tests::claims_after_connection_cleanup_are_refused) + test(=handlers::req::tests::dropped_terminal_frame_cancels_connection) + test(=handlers::req::tests::revoke_dropped_terminal_frame_cancels_connection) + test(=handlers::req::tests::count_fallback_covers_filters_that_can_match_private_deletions) + test(=handlers::req::tests::group_state_t_and_creator_tags_reach_sql_only_for_group_state_kinds) + test(=api::bridge::postgres_tests::bridge_search_mode_extension_accepts_prefix_snake_or_camel_case) + test(=api::bridge::postgres_tests::bridge_search_sort_extension_selects_newest_only_when_named) + (kind(bin) & (test(/^tests::/) + test(/^composition_tests::/) + test(/^env_filter_tests::/)) - test(/^tests::postgres_tests::/))' + -E 'test(/^api::admin::/) + test(/^push_runtime::/) + test(/^operator_listener::tests::/) + test(/^handlers::channel_authz::/) + test(/^handlers::moderation_authz::/) + test(/^handlers::side_effects::tests::/) + test(/^handlers::coalesced_refresh::tests::/) + test(/^handlers::system_channels::tests::/) + test(/^nip11::tests::/) + test(/^storage_sweep::tests::/) + test(/^nip_fi_core::tests::/) + test(/^nip_fi_http::tests::/) + test(/^nip_fi_config::tests::/) + test(/^nip_fi_resolver::tests::/) + test(/^readiness::tests::/) + test(/^router::tests::/) + test(/^api::parse_query_tests::/) + test(/^api::profiles::/) + test(/^api::git::transport::off_mode_precedence_tests::/) + test(/^audio::join::tests::/) + test(/^audio::handler::tests::/) + test(/^nip_fi_gate::tests::/) + test(/^nip_fi_session::tests::/) + test(/^nip_fi_shadow::tests::/) + test(/^nip_fi_shadow_session::tests::/) + test(/^startup_steps::tests::/) + test(/^mesh_boot::tests::/) + test(/^telemetry::tests::/) + test(/^config::tests::/) + test(=state::tests::neither_a_confirmed_inactive_community_nor_a_failed_lookup_admits_the_socket) + test(=state::tests::periodic_revalidation_disconnects_inside_the_fenced_callback) + test(=state::tests::bare_community_disconnect_fails_closed_when_the_fence_is_unavailable) + test(=connection::tests::send_loop_sends_policy_close_when_community_is_archived) + test(=state::tests::community_disconnect_then_nip_fi_keeps_community_deleted_reason) + test(=state::tests::disconnect_community_wins_reason_losing_nip_fi_does_not_enqueue_frame) + test(=handlers::req::tests::timed_out_historical_read_deregisters_before_closed) + test(=handlers::req::tests::superseded_timeout_leaves_replacement_intact) + test(=handlers::req::tests::search_claim_retires_live_and_yields_to_replacement) + test(=handlers::req::tests::concurrent_claims_and_stale_teardowns_keep_the_last_owner) + test(=handlers::req::tests::timeout_closed_is_emitted_before_a_replacement_can_claim) + test(=handlers::req::tests::revoke_then_replacement_keeps_replacement_whole) + test(=handlers::req::tests::claims_after_connection_cleanup_are_refused) + test(=handlers::req::tests::dropped_terminal_frame_cancels_connection) + test(=handlers::req::tests::revoke_dropped_terminal_frame_cancels_connection) + test(=handlers::req::tests::count_fallback_covers_filters_that_can_match_private_deletions) + test(=handlers::req::tests::group_state_t_and_creator_tags_reach_sql_only_for_group_state_kinds) + test(=api::bridge::postgres_tests::bridge_search_mode_extension_accepts_prefix_snake_or_camel_case) + test(=api::bridge::postgres_tests::bridge_search_sort_extension_selects_newest_only_when_named) + test(=api::invites::postgres_tests::restriction_tests::postgres_tests::destructive_fixture_rejects_shared_database_urls) + (kind(bin) & (test(/^tests::/) + test(/^composition_tests::/) + test(/^env_filter_tests::/)) - test(/^tests::postgres_tests::/))' # Note on audio::join::tests scope: the full suite is infra-free (no DB, # no Redis). The infra-free audio/FI regression witnesses — bootstrap # ordering barrier, CommitConfirmed arm, pending-close invisibility, @@ -624,6 +624,10 @@ test-unit: + test(=state::tests::disconnect_community_wins_reason_losing_nip_fi_does_not_enqueue_frame) + test(=state::tests::manager_disconnect_sets_reason_enqueues_frame_then_cancels) + test(/^api::nip_fi::/)' + # Invitation drain/cutoff boundary contracts are infra-free. + cargo nextest run -p buzz-relay --lib -E 'test(/^invite_token::/) + test(/^config::invite_cutoff_tests::/)' + # Retain partially consumed mesh receives across housekeeping ticks. + cargo nextest run -p buzz-relay --lib -E 'test(=mesh_boot::tests::demo_echo_retains_pending_receive_across_housekeeping_ticks)' # boot_lifecycle spawns the real relay binary and asserts its startup # lifecycle, including that buzz_startup_phase_* reaches /metrics. Its # non-ignored tests need no Postgres or Redis; the Postgres cases are diff --git a/TESTING.md b/TESTING.md index d939265c41..cc41319f64 100644 --- a/TESTING.md +++ b/TESTING.md @@ -146,6 +146,88 @@ backgrounded or you lost the terminal: `pkill -f buzz-relay`. Leaving it running will collide with the next reviewer who follows this doc on the same machine. +### Invitation compatibility and revocation + +New invitations are durable v2 bearers. A community ban permanently revokes +outstanding v2 invitations issued by that principal or its recorded agents. +Unban does not restore them; an unrestricted issuer can mint new invitations. +Existing durable rows default to legacy admission. Until the separately +reviewed historical repair processes them, any retained `community_bans` row +for a canonical issuer or its currently recorded owner permanently revokes a +legacy row, including expired, lifted, and timeout-only restrictions. The +repair clears that legacy marker after deciding the row's historical state. +Afterward, only an active ban permanently revokes it; timeouts and mutes block +claims only while active. New mints explicitly mark their rows as protocol +native. Audit and action records alone do not establish enforcement. +An issuer or verified owner timeout also blocks claims on existing invitations +while it lasts, without durable revocation. Claims resume after timeout expiry. +Claim credentials are verified without user writes. Successful claims commit +verified ownership with membership, policy evidence, and v2 consumption. +Rejected claims create no users or owner links. +Minting with a verified owner that differs from an agent's recorded owner +returns HTTP 403 `invite_owner_mismatch`; a storage failure while establishing +that link remains HTTP 503. Web, desktop, and mobile claim errors explain +`invite_restricted` as a restriction on the claiming account, or on the account +that owns it, in this community: contact a community administrator, or retry +after a temporary restriction ends. An issuer restriction returns +`invite_invalid`, so a claim never reveals the issuer's moderation state. +`invite_owner_mismatch` asks for the correct account or owner identity. Neither +error suggests a fresh link as a workaround, and neither automatically retries. +Claim and mint restriction reads use the writer inside a transaction serialized +with community restriction and agent-owner writes. Admission committed first +precedes the ban; a ban committed first prevents subsequent admission. Restriction +lookup failures deny admission, and onboarding does not require prior membership. + +Legacy v1 bearers contain no issuer identity. Unset `BUZZ_INVITE_V1_INVALID_AFTER` +preserves their natural expiry and claimant restriction checks, but cannot revoke +an individual legacy issuer's bearers. Drain for at most 30 days from the last +possible v1 issuance across **all serving and rollback versions**; do not use merge +time as the start. Full issuer-revocation coverage requires completing that drain +or an explicitly approved compatibility scope decision. + +Operators may set `BUZZ_INVITE_V1_INVALID_AFTER` to an absolute UTC RFC3339 +whole-second timestamp (for example `2026-12-01T00:00:00Z`). v1 verification +rejects at and after that instant, never extends bearer expiry, and leaves v2 +unchanged. Invalid or empty values fail configuration. Apply the same setting to +all serving pods and retain it on compatible rollbacks; rolling back to a version +without this setting's enforcement restores legacy acceptance until natural expiry. +This change does not choose or deploy a cutoff. + +The historical maintenance companion must be separately reviewed and approved +**before deploying this core change**. Both steps belong to the same controlled +rollout: leaving unrepaired rows serving indefinitely would extend historical +restriction rejection until their repair. Migration 0075 is schema-only: it adds +`revoked_at` and `legacy_admission` without rewriting existing invite rows. +Reconcile any previously applied invitation migration version with the schema +and migration history before applying it. + +There is no invitation-maintenance feature switch. Operators must prevent +invitation mint/claim, ban lifts, and **new timeout/mute restrictions** throughout +the drain/repair interval, including signed events, HTTP, administrative tools, +background jobs and direct writers. If this cannot be enforced for every writer +with existing operational controls, use a maintenance window: remove public +traffic, stop all relay instances and other writers, drain in-flight work and +verify no old writer remains. Keep them stopped while applying schema 0075 and +running the separately approved repair with its isolated operator connection. +Prepare only new-code instances; do not reopen traffic or restart other writers +until repair validation succeeds. Readers that may run before repair must use +new code, which rejects historical restrictions lazily on unrepaired legacy rows. + +The repair must report zero unrepaired invites in active communities and an +idempotent second run before reopening. Review every skipped/fenced community; +keep it closed until it is active and repaired, then recheck zero before admitting +traffic. After repair, timeouts/mutes only block temporarily; active bans still +revoke permanently. A rollback to code that ignores `revoked_at` requires +invitation admission paused, using the same traffic/writer controls if needed. +It also requires `BUZZ_AUTO_MIGRATE` off: with auto-migration on (the Helm +default), the old binary fails at startup with SQLx `VersionMissing(75)`. See +[docs/database.md](docs/database.md) Rule 5. +Legacy owner attribution uses the agent's currently recorded +`agent_owner_pubkey`; deleting an owner user row out of band clears that link +through its foreign key, so a later repair cannot recover the former +relationship. Reconcile before any external user-row cleanup that could erase +this provenance. + ### 4. Smoke test the CLI against the relay End-to-end: generate an identity, create a channel, post a message, read it @@ -190,7 +272,7 @@ relay key for authoritative replacement: ```bash export PATH="$PWD/target/release:$PATH" -export DATABASE_URL="postgres://buzz:buzz_dev@localhost:5432/buzz_roster_e2e" +export DATABASE_URL="${DATABASE_URL:?set an isolated local PostgreSQL URL}" export BUZZ_RELAY_URL="http://localhost:3030" # match the relay from step 3 export RELAY_URL="ws://localhost:3030" export BUZZ_RELAY_PRIVATE_KEY="" @@ -331,7 +413,7 @@ out of the box with `just setup` or `just relay`. Common overrides: | `BUZZ_HEALTH_PORT` | `8080` | `/_liveness`, `/_readiness` | | `BUZZ_METRICS_PORT` | `9102` | Prometheus `/metrics` | | `RELAY_URL` | `ws://localhost:3000` | Advertised in NIP-11 / NIP-42 challenges. **Note: no `BUZZ_` prefix.** | -| `DATABASE_URL` | `postgres://buzz:buzz_dev@localhost:5432/buzz` | | +| `DATABASE_URL` | Local PostgreSQL database `buzz` (development default) | | | `REDIS_URL` | `redis://localhost:6379` | | | `BUZZ_REQUIRE_AUTH_TOKEN` | `false` | When true, REST requires NIP-98 (no `X-Pubkey` fallback) | | `BUZZ_REQUIRE_RELAY_MEMBERSHIP` | `false` | When true, only pubkeys in `relay_members` can connect | diff --git a/crates/buzz-acp/tests/run_task.rs b/crates/buzz-acp/tests/run_task.rs index fbea25692a..c90227c78d 100644 --- a/crates/buzz-acp/tests/run_task.rs +++ b/crates/buzz-acp/tests/run_task.rs @@ -3,7 +3,8 @@ use serde_json::{json, Value}; use std::{ fs, - io::Write, + io::{ErrorKind, Write}, + os::unix::{io::OwnedFd, net::UnixStream}, path::PathBuf, process::{Child, Command, Output, Stdio}, time::{Duration, Instant}, @@ -97,7 +98,8 @@ fn terminal(output: &Output, code: i32, status: &str) -> Value { assert_eq!( output.status.code(), Some(code), - "stderr={} stdout={}", + "exit={:?} stderr={} stdout={}", + output.status, String::from_utf8_lossy(&output.stderr), String::from_utf8_lossy(&output.stdout) ); @@ -419,6 +421,32 @@ fn deadline_covers_initialize_session_and_turn_and_cleans_descendants() { } } +fn backpressured_stdin() -> (UnixStream, UnixStream) { + let (reader, mut writer) = UnixStream::pair().unwrap(); + writer.set_nonblocking(true).unwrap(); + // Fill before spawning: the next write cannot finish until the runner reads + // stdin, which happens after signal registration. Keep below the task limit + // and leave the writer open so read_to_end still awaits EOF when cancelled. + let mut queued = 0; + loop { + match writer.write(b" ") { + Ok(0) => panic!("stdin fixture stopped accepting bytes"), + Ok(n) => { + queued += n; + assert!(queued < 1024 * 1024 - 1, "stdin fixture exceeds task limit"); + } + Err(error) if error.kind() == ErrorKind::Interrupted => continue, + Err(error) if error.kind() == ErrorKind::WouldBlock => break, + Err(error) => panic!("cannot fill stdin fixture: {error}"), + } + } + writer.set_nonblocking(false).unwrap(); + writer + .set_write_timeout(Some(Duration::from_secs(3))) + .unwrap(); + (reader, writer) +} + #[test] fn signals_cancel_startup_turn_and_pending_stdin_without_hanging() { use nix::{ @@ -435,9 +463,20 @@ fn signals_cancel_startup_turn_and_pending_stdin_without_hanging() { command .env("TASK_AGENT_MODE", mode) .args(["--no-memory", "--task", "-"]); + let mut held_stdin = if mode == "stdin" { + let (reader, writer) = backpressured_stdin(); + command.stdin(Stdio::from(OwnedFd::from(reader))); + Some(writer) + } else { + None + }; let mut child = command.spawn().unwrap(); - let held_stdin = if mode == "stdin" { - child.stdin.take() + if let Some(writer) = held_stdin.as_mut() { + if let Err(error) = writer.write_all(b" ") { + let status = child.try_wait(); + let _ = child.kill(); + panic!("{mode} never drained pending input: {error}; status={status:?}"); + } } else { child .stdin @@ -445,24 +484,34 @@ fn signals_cancel_startup_turn_and_pending_stdin_without_hanging() { .unwrap() .write_all(f.task().to_string().as_bytes()) .unwrap(); - None - }; - let until = Instant::now() + Duration::from_secs(3); - if mode == "stdin" { - std::thread::sleep(Duration::from_millis(250)); - } else { + let until = Instant::now() + Duration::from_secs(3); let method = if mode == "turn-hang" { "session/prompt" } else { "initialize" }; while !f.wire().iter().any(|v| v["method"] == method) { - assert!(Instant::now() < until); + if Instant::now() >= until { + let status = child.try_wait(); + let _ = child.kill(); + panic!("{mode} never reached {method}: status={status:?}"); + } std::thread::sleep(Duration::from_millis(10)); } } + eprintln!( + "cancelling case={mode} signal={signal:?} pid={}", + child.id() + ); kill(Pid::from_raw(child.id() as i32), signal).unwrap(); - terminal(&wait(child), code, "cancelled"); + let result = terminal(&wait(child), code, "cancelled"); + if mode == "stdin" { + assert!(result["taskId"].is_null()); + assert!( + f.wire().is_empty(), + "incomplete input must not start an agent" + ); + } drop(held_stdin); for frame in f.wire() { if let Some(pid) = frame["pid"].as_u64() { diff --git a/crates/buzz-db/TESTING.md b/crates/buzz-db/TESTING.md index bebe09dd98..17b42b8bb9 100644 --- a/crates/buzz-db/TESTING.md +++ b/crates/buzz-db/TESTING.md @@ -63,3 +63,10 @@ membership in `pg_read_all_stats`, or an ephemeral superuser as CI uses. The runner creates one desired-state source database per invocation and clones it for ordinary tests. Migration-mode tests start empty. Cleanup retries transient disconnect races before reporting a warning. + +Invite upgrade tests start from the complete schema through migration 0074, +including channel join roles, entity deletion approval, the unlisted channel setting and the user deletion target and fences. They exercise the +invitation schema expansion (migration 0075) through both migration and +desired-state paths against populated +data, retaining a non-default channel join role. Historical repair is validated +separately from the application/schema change. diff --git a/crates/buzz-db/src/error.rs b/crates/buzz-db/src/error.rs index d0a2ffcb22..10c8487bb8 100644 --- a/crates/buzz-db/src/error.rs +++ b/crates/buzz-db/src/error.rs @@ -37,6 +37,14 @@ pub enum DbError { #[error("access denied: {0}")] AccessDenied(String), + /// Invitation admission or minting was denied by authoritative restrictions. + #[error("invitation principal is restricted")] + InviteRestricted, + + /// A verified invitation owner conflicts with the recorded owner. + #[error("invitation owner does not match the recorded owner")] + InviteOwnerMismatch, + /// JSON serialization or deserialization failed. #[error("serialization error: {0}")] Serde(#[from] serde_json::Error), diff --git a/crates/buzz-db/src/runtime/migration.rs b/crates/buzz-db/src/runtime/migration.rs index 38ed0014c4..7a1a551b7c 100644 --- a/crates/buzz-db/src/runtime/migration.rs +++ b/crates/buzz-db/src/runtime/migration.rs @@ -776,17 +776,14 @@ mod postgres_tests { let mut migrations: Vec<_> = MIGRATOR.iter().collect(); migrations.sort_by_key(|migration| migration.version); - assert_eq!(migrations.len(), 74); - assert_eq!(migrations[73].version, 74); - assert_eq!(migrations[72].version, 73); - assert_eq!(migrations[71].version, 72); - assert_eq!(migrations[70].version, 71); + assert_eq!(migrations.len(), 75); + for (index, migration) in migrations.iter().enumerate() { + assert_eq!(migration.version, (index + 1) as i64); + } assert!(migrations[70] .sql .as_str() .contains("ADD COLUMN unlisted BOOLEAN NOT NULL DEFAULT FALSE")); - assert_eq!(migrations[69].version, 70); - assert_eq!(migrations[68].version, 69); assert_eq!(migrations[67].version, 68); assert!(migrations[67] .sql @@ -804,6 +801,26 @@ mod postgres_tests { let desired_schema = crate::test_support::desired_state_schema_sql(); assert!(!desired_schema.contains("events_refresh_channel_ttl")); assert!(!desired_schema.contains("refresh_channel_ttl_after_event_insert")); + assert!(desired_schema.contains("legacy_admission BOOLEAN NOT NULL DEFAULT true")); + assert_eq!(migrations[65].version, 66); + assert!(migrations[65] + .sql + .as_str() + .contains("CREATE TABLE community_nip_fi_issuers")); + assert_eq!(migrations[74].version, 75); + let invite_migration = strip_sql_comments(migrations[74].sql.as_str()); + assert!(invite_migration.contains("SET LOCAL lock_timeout = '5s'")); + assert!(invite_migration.contains("SET LOCAL statement_timeout = '30s'")); + assert!( + invite_migration.contains("ADD COLUMN legacy_admission BOOLEAN NOT NULL DEFAULT true") + ); + assert!(invite_migration.contains("ADD COLUMN revoked_at TIMESTAMPTZ")); + assert!(!invite_migration.to_ascii_uppercase().contains("UPDATE ")); + assert!(!invite_migration + .to_ascii_uppercase() + .contains("CREATE FUNCTION")); + assert!(!desired_schema.contains("invite_admission_lock")); + assert_eq!(migrations[58].version, 59); assert!(migrations[58].sql.as_str().contains("buzz.push_enabled")); assert_eq!(migrations[59].version, 60); @@ -823,11 +840,6 @@ mod postgres_tests { .sql .as_str() .contains("CREATE FUNCTION profile_name_fields")); - assert_eq!(migrations[65].version, 66); - assert!(migrations[65] - .sql - .as_str() - .contains("CREATE TABLE community_nip_fi_issuers")); assert_eq!(migrations[55].version, 56); assert!(migrations[55] .sql diff --git a/crates/buzz-db/src/store/event_follow_up_postgres_tests.rs b/crates/buzz-db/src/store/event_follow_up_postgres_tests.rs index 68b68e20e2..e6beb22b51 100644 --- a/crates/buzz-db/src/store/event_follow_up_postgres_tests.rs +++ b/crates/buzz-db/src/store/event_follow_up_postgres_tests.rs @@ -1027,13 +1027,30 @@ async fn assert_async_isolation(db: &Db, community: CommunityId, keys: &Keys) { endpoint_grant, max_class, subscriptions, expires_at, updated_at) \ SELECT $1, author, installation_id, source_event_id, source_created_at, generation, \ active, endpoint_enabled, endpoint_hash, endpoint_grant, max_class, \ - subscriptions, expires_at, clock_timestamp() FROM push_leases WHERE community_id=$2", + subscriptions, expires_at, \ + (SELECT received_at + interval '1 second' FROM events WHERE community_id=$1 AND id=$3) \ + FROM push_leases WHERE community_id=$2", ) .bind(later.as_uuid()) .bind(community.as_uuid()) + .bind(before_enrollment.id.as_bytes().as_slice()) .execute(&mut *activation) .await .unwrap(); + let leases_strictly_newer: bool = sqlx::query_scalar( + "SELECT count(*) > 0 AND bool_and(l.updated_at > e.received_at) \ + FROM push_leases l JOIN events e ON e.community_id=l.community_id \ + WHERE l.community_id=$1 AND e.id=$2", + ) + .bind(later.as_uuid()) + .bind(before_enrollment.id.as_bytes().as_slice()) + .fetch_one(&mut *activation) + .await + .unwrap(); + assert!( + leases_strictly_newer, + "fixture leases must follow recorded receipt" + ); activation.commit().await.unwrap(); assert_eq!(match_count(&isolated, later, &before_enrollment).await, 0); assert_eq!( diff --git a/crates/buzz-db/src/store/moderation.rs b/crates/buzz-db/src/store/moderation.rs index 00bcb3af87..0452a51a8c 100644 --- a/crates/buzz-db/src/store/moderation.rs +++ b/crates/buzz-db/src/store/moderation.rs @@ -360,6 +360,7 @@ pub async fn ban_member( crate::observability::WriterOperation::Authorization, ) .await?; + crate::relay_invite::lock_admission(tx.conn(), community).await?; sqlx::query( r#" INSERT INTO community_bans ( @@ -380,8 +381,8 @@ pub async fn ban_member( .bind(actor) .execute(tx.conn()) .await?; + crate::relay_invite::revoke_banned_issuer_invites(tx.conn(), community, pubkey).await?; tx.commit().await?; - Ok(()) } @@ -548,6 +549,7 @@ pub async fn timeout_member( crate::observability::WriterOperation::Authorization, ) .await?; + crate::relay_invite::lock_admission(tx.conn(), community).await?; sqlx::query( r#" INSERT INTO community_bans ( @@ -567,8 +569,8 @@ pub async fn timeout_member( .bind(actor) .execute(tx.conn()) .await?; + crate::relay_invite::revoke_banned_issuer_invites(tx.conn(), community, pubkey).await?; tx.commit().await?; - Ok(()) } diff --git a/crates/buzz-db/src/store/relay_admin_actions.rs b/crates/buzz-db/src/store/relay_admin_actions.rs index 6c819d3d24..19c2ab4c46 100644 --- a/crates/buzz-db/src/store/relay_admin_actions.rs +++ b/crates/buzz-db/src/store/relay_admin_actions.rs @@ -473,8 +473,9 @@ pub async fn execute_ban_with_marker( crate::observability::WriterOperation::Authorization, ) .await?; + crate::relay_invite::lock_admission(tx.conn(), community_id).await?; - // Verify lease ownership first — abort without touching domain rows if the + // Verify lease ownership before domain writes — abort if the // lease is already gone. This prevents the commit entirely on a stale worker. let owned: bool = sqlx::query_scalar( r#" @@ -523,6 +524,9 @@ pub async fn execute_ban_with_marker( .execute(tx.conn()) .await?; + crate::relay_invite::revoke_banned_issuer_invites(tx.conn(), community_id, target_pubkey) + .await?; + let marker = sqlx::query( r#" UPDATE relay_admin_actions @@ -569,6 +573,7 @@ pub async fn execute_timeout_with_marker( crate::observability::WriterOperation::Authorization, ) .await?; + crate::relay_invite::lock_admission(tx.conn(), community_id).await?; let owned: bool = sqlx::query_scalar( r#" @@ -616,6 +621,9 @@ pub async fn execute_timeout_with_marker( .execute(tx.conn()) .await?; + crate::relay_invite::revoke_banned_issuer_invites(tx.conn(), community_id, target_pubkey) + .await?; + let marker = sqlx::query( r#" UPDATE relay_admin_actions diff --git a/crates/buzz-db/src/store/relay_invite.rs b/crates/buzz-db/src/store/relay_invite.rs index 242fe42a84..d1b6763571 100644 --- a/crates/buzz-db/src/store/relay_invite.rs +++ b/crates/buzz-db/src/store/relay_invite.rs @@ -30,6 +30,11 @@ use crate::error::Result; use crate::relay_members::{write_nip43_membership_snapshot, MembershipWrite}; use crate::{CommunityId, Db}; +mod claim_owner; +mod revocation; +pub use claim_owner::{claim_relay_membership_with_owner, MembershipClaimResult}; +pub(crate) use revocation::{revoke_banned_issuer_invites, revoke_restricted_agent_invites}; + /// Outcome of a v2 invite claim. Expected invalid/expired/exhausted states are /// typed variants so the relay layer can map them to distinct HTTP responses /// without inspecting database errors. @@ -56,6 +61,32 @@ pub enum ClaimOutcome { Exhausted, /// No invite row matches `(community_id, token_hash)`. Invalid, + /// The issuer's invitation was permanently revoked. + Revoked, + /// The issuer or its owner is currently banned or timed out. + IssuerRestricted, + /// The claimant or its verified owner is banned or timed out. + Restricted, +} + +/// A completed claim and whether its committed transaction first recorded an owner. +pub struct InviteClaimResult { + /// Membership or denial outcome. + pub outcome: ClaimOutcome, + /// Notify ownerless sockets only after a successful commit created the link. + pub owner_link_created: bool, + /// User rows first created by this successful transaction. + pub users_created: u64, +} + +impl From for InviteClaimResult { + fn from(outcome: ClaimOutcome) -> Self { + Self { + outcome, + owner_link_created: false, + users_created: 0, + } + } } /// A freshly minted v2 invite, including the plaintext code and metadata. @@ -93,6 +124,13 @@ fn validate_mint_inputs(ttl_secs: u64, max_uses: Option) -> Result<()> { Ok(()) } +fn is_canonical_invite_pubkey(pubkey: &str) -> bool { + pubkey.len() == 64 + && pubkey + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) +} + /// Mint a v2 invite: generate a 32-byte random secret, hash it, persist the /// row, and return the plaintext code plus metadata. /// @@ -106,6 +144,11 @@ pub async fn mint_relay_invite( max_uses: Option, ) -> Result { validate_mint_inputs(ttl_secs, max_uses)?; + if !is_canonical_invite_pubkey(created_by) { + return Err(crate::error::DbError::InvalidData( + "invalid invitation issuer".into(), + )); + } // Generate 32 random bytes and encode as base64url — this is the secret. let secret: [u8; V2_SECRET_LEN] = rand::random(); @@ -124,9 +167,14 @@ pub async fn mint_relay_invite( crate::observability::WriterOperation::Authorization, ) .await?; + lock_admission(tx.conn(), community).await?; + if principal_restricted(tx.conn(), community, created_by).await? { + return Err(crate::DbError::InviteRestricted); + } let row = sqlx::query( - "INSERT INTO relay_invites (community_id, token_hash, max_uses, expires_at, created_by) \ - VALUES ($1, $2, $3, $4, $5) \ + "INSERT INTO relay_invites \ + (community_id, token_hash, max_uses, expires_at, created_by, legacy_admission) \ + VALUES ($1, $2, $3, $4, $5, false) \ RETURNING id", ) .bind(community.as_uuid()) @@ -149,6 +197,95 @@ pub async fn mint_relay_invite( }) } +/// Lock invitation admission against community restriction and owner writes. +/// Call before reading restrictions or locking an invite row, and hold to commit. +/// A joined claim holds it through its NIP-43 membership snapshot, so mint, ban, +/// timeout and first owner link wait for that snapshot, which grows with the +/// member count. Claims reject unknown codes before taking it. +pub(crate) async fn lock_admission( + tx: &mut sqlx::PgConnection, + community: CommunityId, +) -> Result<()> { + sqlx::query( + "SELECT pg_advisory_xact_lock(\ + hashtextextended('invite-admission:' || $1::text, 0))", + ) + .bind(community.as_uuid()) + .execute(&mut *tx) + .await?; + Ok(()) +} + +/// Authoritative, transaction-local lookup; nonmembers need no user row. +pub(crate) async fn principal_restricted( + tx: &mut sqlx::PgConnection, + community: CommunityId, + pubkey: &str, +) -> Result { + if !is_canonical_invite_pubkey(pubkey) { + return Err(crate::DbError::InvalidData( + "invalid invitation principal".into(), + )); + } + let bytes = hex::decode(pubkey) + .map_err(|_| crate::DbError::InvalidData("invalid invitation principal".into()))?; + Ok(sqlx::query_scalar( + "SELECT EXISTS (SELECT 1 FROM community_bans \ + WHERE community_id = $1 AND pubkey IN (\ + SELECT $2::bytea UNION ALL \ + SELECT agent_owner_pubkey FROM users WHERE community_id = $1 AND pubkey = $2\ + ) AND ((banned AND (ban_expires_at IS NULL OR ban_expires_at > clock_timestamp())) \ + OR muted_until > clock_timestamp()))", + ) + .bind(community.as_uuid()) + .bind(bytes) + .fetch_one(&mut *tx) + .await?) +} + +/// Whether a restriction row permanently revokes an invite from this principal. +/// Unrepaired legacy rows treat every retained restriction as history; the +/// maintenance repair clears that marker after its historical decision. New +/// and repaired rows only treat a currently active ban as permanent revocation. +async fn has_revoking_restriction( + tx: &mut sqlx::PgConnection, + community: CommunityId, + pubkey: &[u8], + legacy_admission: bool, +) -> Result { + Ok(sqlx::query_scalar( + "SELECT EXISTS (SELECT 1 FROM community_bans restriction \ + WHERE restriction.community_id = $1 \ + AND (restriction.pubkey = $2 OR restriction.pubkey = ( \ + SELECT agent_owner_pubkey FROM users \ + WHERE community_id = $1 AND pubkey = $2)) \ + AND ($3 OR (restriction.banned \ + AND (restriction.ban_expires_at IS NULL \ + OR restriction.ban_expires_at > clock_timestamp()))))", + ) + .bind(community.as_uuid()) + .bind(pubkey) + .bind(legacy_admission) + .fetch_one(&mut *tx) + .await?) +} + +async fn persist_claim_revocation( + tx: &mut sqlx::PgConnection, + community: CommunityId, + invite_id: uuid::Uuid, +) -> Result<()> { + sqlx::query( + "UPDATE relay_invites SET revoked_at = clock_timestamp() \ + WHERE community_id = $1 AND id = $2 AND revoked_at IS NULL", + ) + .bind(community.as_uuid()) + .bind(invite_id) + .execute(&mut *tx) + .await?; + Ok(()) +} + fn log_claim_outcome( community: CommunityId, invite_id: Option, @@ -258,6 +395,34 @@ pub async fn claim_relay_invite( policy_version: Option<&str>, relay_keypair: &nostr::Keys, ) -> Result> { + let write = claim_relay_invite_with_owner( + pool, + community, + token_hash, + claimer_pubkey, + policy_version, + None, + relay_keypair, + ) + .await?; + Ok(MembershipWrite { + outcome: write.outcome.outcome, + snapshot: write.snapshot, + }) +} + +/// Claim an invite with a caller-verified owner while keeping restriction checks, +/// owner persistence, membership, policy evidence, invite consumption, and the +/// NIP-43 membership snapshot in one admitted transaction. +pub async fn claim_relay_invite_with_owner( + pool: &PgPool, + community: CommunityId, + token_hash: &[u8; 32], + claimer_pubkey: &str, + policy_version: Option<&str>, + verified_owner: Option<&str>, + relay_keypair: &nostr::Keys, +) -> Result> { let mut tx = crate::begin_community_write_transaction( pool, community, @@ -265,9 +430,36 @@ pub async fn claim_relay_invite( ) .await?; - // 2. SELECT FOR UPDATE — lock the invite row for the duration of this txn. + // Unknown codes are free to forge, so reject them after the tenant fence + // but before the community-wide admission lock; the locked read below + // stays authoritative. + let known: bool = sqlx::query_scalar( + "SELECT EXISTS (SELECT 1 FROM relay_invites \ + WHERE community_id = $1 AND token_hash = $2)", + ) + .bind(community.as_uuid()) + .bind(token_hash) + .fetch_one(tx.conn()) + .await?; + if !known { + tx.rollback().await?; + log_claim_outcome(community, None, "invalid", None, None); + return Ok(MembershipWrite::unchanged(ClaimOutcome::Invalid.into())); + } + + lock_admission(tx.conn(), community).await?; + if claim_owner::claimant_restricted(tx.conn(), community, claimer_pubkey, verified_owner) + .await? + { + tx.rollback().await?; + log_claim_outcome(community, None, "restricted", None, None); + return Ok(MembershipWrite::unchanged(ClaimOutcome::Restricted.into())); + } + + // `FOR UPDATE` serializes concurrent claims so exactly one claimant wins + // the final slot, while admission serializes issuer/owner restriction changes. let row = sqlx::query( - "SELECT id, max_uses, use_count, expires_at \ + "SELECT id, max_uses, use_count, expires_at, created_by, revoked_at, legacy_admission \ FROM relay_invites \ WHERE community_id = $1 AND token_hash = $2 \ FOR UPDATE", @@ -277,21 +469,16 @@ pub async fn claim_relay_invite( .fetch_optional(tx.conn()) .await?; - // 3. No matching invite. let Some(invite) = row else { tx.rollback().await?; log_claim_outcome(community, None, "invalid", None, None); - return Ok(MembershipWrite::unchanged(ClaimOutcome::Invalid)); + return Ok(MembershipWrite::unchanged(ClaimOutcome::Invalid.into())); }; let invite_id: uuid::Uuid = invite.try_get("id")?; let max_uses: Option = invite.try_get("max_uses")?; let use_count: i32 = invite.try_get("use_count")?; let expires_at: DateTime = invite.try_get("expires_at")?; - - // Expiry is checked before membership deliberately. An expired bearer must - // not authorize fresh policy-acceptance evidence, even for an existing - // member; exhausted-but-live invites remain valid for idempotent retries. if expires_at <= Utc::now() { tx.rollback().await?; log_claim_outcome( @@ -301,12 +488,76 @@ pub async fn claim_relay_invite( max_uses, Some(use_count), ); - return Ok(MembershipWrite::unchanged(ClaimOutcome::Expired)); + return Ok(MembershipWrite::unchanged(ClaimOutcome::Expired.into())); + } + + let revoked_at: Option> = invite.try_get("revoked_at")?; + let issuer: String = invite.try_get("created_by")?; + let legacy_admission: bool = invite.try_get("legacy_admission")?; + if revoked_at.is_some() { + tx.rollback().await?; + log_claim_outcome( + community, + Some(invite_id), + "revoked", + max_uses, + Some(use_count), + ); + return Ok(MembershipWrite::unchanged(ClaimOutcome::Revoked.into())); + } + if !is_canonical_invite_pubkey(&issuer) { + if legacy_admission { + persist_claim_revocation(tx.conn(), community, invite_id).await?; + tx.commit().await?; + log_claim_outcome( + community, + Some(invite_id), + "revoked", + max_uses, + Some(use_count), + ); + return Ok(MembershipWrite::unchanged(ClaimOutcome::Revoked.into())); + } + tx.rollback().await?; + log_claim_outcome( + community, + Some(invite_id), + "invalid", + max_uses, + Some(use_count), + ); + return Ok(MembershipWrite::unchanged(ClaimOutcome::Invalid.into())); + } + + let issuer_bytes = hex::decode(&issuer) + .map_err(|_| crate::DbError::InvalidData("invalid invitation issuer".into()))?; + if has_revoking_restriction(tx.conn(), community, &issuer_bytes, legacy_admission).await? { + persist_claim_revocation(tx.conn(), community, invite_id).await?; + tx.commit().await?; + log_claim_outcome( + community, + Some(invite_id), + "revoked", + max_uses, + Some(use_count), + ); + return Ok(MembershipWrite::unchanged(ClaimOutcome::Revoked.into())); + } + if principal_restricted(tx.conn(), community, &issuer).await? { + tx.rollback().await?; + log_claim_outcome( + community, + Some(invite_id), + "issuer_restricted", + max_uses, + Some(use_count), + ); + return Ok(MembershipWrite::unchanged( + ClaimOutcome::IssuerRestricted.into(), + )); } let uses_remaining = || max_uses.map(|mu| mu - use_count); - - // 5. Check existing membership. let existing = sqlx::query("SELECT 1 FROM relay_members WHERE community_id = $1 AND pubkey = $2") .bind(community.as_uuid()) @@ -315,7 +566,6 @@ pub async fn claim_relay_invite( .await?; if existing.is_some() { - // 6. Already a member — insert policy evidence but do NOT increment. if let Some(version) = policy_version { sqlx::query( "INSERT INTO join_policy_acceptances (community_id, pubkey, policy_version) \ @@ -327,6 +577,9 @@ pub async fn claim_relay_invite( .execute(tx.conn()) .await?; } + let owner = + claim_owner::persist_claim_owner(tx.conn(), community, claimer_pubkey, verified_owner) + .await?; tx.commit().await?; log_claim_outcome( community, @@ -335,30 +588,28 @@ pub async fn claim_relay_invite( max_uses, Some(use_count), ); - return Ok(MembershipWrite::unchanged(ClaimOutcome::AlreadyMember { - use_count, - uses_remaining: uses_remaining(), + return Ok(MembershipWrite::unchanged(InviteClaimResult { + outcome: ClaimOutcome::AlreadyMember { + use_count, + uses_remaining: uses_remaining(), + }, + owner_link_created: owner.link_created, + users_created: owner.users_created, })); } - // 7. Capacity check. - if let Some(mu) = max_uses { - if use_count >= mu { - tx.rollback().await?; - log_claim_outcome( - community, - Some(invite_id), - "exhausted", - max_uses, - Some(use_count), - ); - return Ok(MembershipWrite::unchanged(ClaimOutcome::Exhausted)); - } + if max_uses.is_some_and(|max_uses| use_count >= max_uses) { + tx.rollback().await?; + log_claim_outcome( + community, + Some(invite_id), + "exhausted", + max_uses, + Some(use_count), + ); + return Ok(MembershipWrite::unchanged(ClaimOutcome::Exhausted.into())); } - // 8. Insert relay member. The conflict branch covers a claimant admitted - // concurrently through a different invite: only the transaction that - // actually inserted membership may consume this invite. let inserted = sqlx::query( "INSERT INTO relay_members (community_id, pubkey, role, added_by) \ VALUES ($1, $2, 'member', 'invite') \ @@ -371,8 +622,6 @@ pub async fn claim_relay_invite( .rows_affected() > 0; - // 9. Insert join-policy acceptance evidence. This is required for both a - // new member and a claimant whose concurrent membership insert won first. if let Some(version) = policy_version { sqlx::query( "INSERT INTO join_policy_acceptances (community_id, pubkey, policy_version) \ @@ -386,6 +635,9 @@ pub async fn claim_relay_invite( } if !inserted { + let owner = + claim_owner::persist_claim_owner(tx.conn(), community, claimer_pubkey, verified_owner) + .await?; tx.commit().await?; log_claim_outcome( community, @@ -394,13 +646,16 @@ pub async fn claim_relay_invite( max_uses, Some(use_count), ); - return Ok(MembershipWrite::unchanged(ClaimOutcome::AlreadyMember { - use_count, - uses_remaining: uses_remaining(), + return Ok(MembershipWrite::unchanged(InviteClaimResult { + outcome: ClaimOutcome::AlreadyMember { + use_count, + uses_remaining: uses_remaining(), + }, + owner_link_created: owner.link_created, + users_created: owner.users_created, })); } - // 10. Increment use_count (for every new member, even unlimited). let new_use_count = use_count + 1; sqlx::query("UPDATE relay_invites SET use_count = $1 WHERE community_id = $2 AND id = $3") .bind(new_use_count) @@ -409,12 +664,13 @@ pub async fn claim_relay_invite( .execute(tx.conn()) .await?; - // 11. Write the membership list and commit. + let owner = + claim_owner::persist_claim_owner(tx.conn(), community, claimer_pubkey, verified_owner) + .await?; let snapshot = write_nip43_membership_snapshot(&mut tx, relay_keypair).await?; tx.commit().await?; let new_uses_remaining = max_uses.map(|mu| mu - new_use_count); - log_claim_outcome( community, Some(invite_id), @@ -424,9 +680,13 @@ pub async fn claim_relay_invite( ); Ok(MembershipWrite { - outcome: ClaimOutcome::Joined { - use_count: new_use_count, - uses_remaining: new_uses_remaining, + outcome: InviteClaimResult { + outcome: ClaimOutcome::Joined { + use_count: new_use_count, + uses_remaining: new_uses_remaining, + }, + owner_link_created: owner.link_created, + users_created: owner.users_created, }, snapshot: Some(snapshot), }) @@ -449,6 +709,29 @@ impl Db { mint_relay_invite(&self.pool, community, created_by, ttl_secs, max_uses).await } + /// Claim an invite and persist caller-verified ownership atomically. + #[datastore_span(name = "claim_relay_invite_with_owner", system = "postgresql")] + pub async fn claim_relay_invite_with_owner( + &self, + community: CommunityId, + token_hash: &[u8; 32], + claimer_pubkey: &str, + policy_version: Option<&str>, + verified_owner: Option<&str>, + relay_keypair: &nostr::Keys, + ) -> Result> { + claim_relay_invite_with_owner( + &self.pool, + community, + token_hash, + claimer_pubkey, + policy_version, + verified_owner, + relay_keypair, + ) + .await + } + /// Delete one bounded batch of invites expired before `cutoff`. #[datastore_span(name = "reap_expired_relay_invites", system = "postgresql")] pub async fn reap_expired_relay_invites(&self, cutoff: DateTime) -> Result { @@ -489,7 +772,7 @@ mod postgres_tests { use sqlx::PgPool; use uuid::Uuid; - async fn setup_pool() -> PgPool { + pub(super) async fn setup_pool() -> PgPool { PgPool::connect(&crate::test_support::database_url()) .await .expect("connect to test DB") @@ -524,7 +807,7 @@ mod postgres_tests { admin.close().await; } - async fn make_test_community(pool: &PgPool) -> CommunityId { + pub(super) async fn make_test_community(pool: &PgPool) -> CommunityId { let id = Uuid::new_v4(); sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)") .bind(id) @@ -561,11 +844,11 @@ mod postgres_tests { tx.commit().await.expect("commit test cleanup"); } - fn test_pubkey() -> String { + pub(super) fn test_pubkey() -> String { format!("{:064x}", Uuid::new_v4().as_u128()) } - async fn use_count(pool: &PgPool, community: CommunityId, invite_id: Uuid) -> i32 { + pub(super) async fn use_count(pool: &PgPool, community: CommunityId, invite_id: Uuid) -> i32 { sqlx::query_scalar( "SELECT use_count FROM relay_invites WHERE community_id = $1 AND id = $2", ) @@ -661,7 +944,7 @@ mod postgres_tests { .await .expect("begin quiescing"); - let error = mint_relay_invite(&pool, community, "owner", 3600, Some(1)) + let error = mint_relay_invite(&pool, community, &test_pubkey(), 3600, Some(1)) .await .expect_err("quiescing must reject invite minting"); assert!(matches!(error, crate::error::DbError::AccessDenied(_))); @@ -686,7 +969,7 @@ mod postgres_tests { let community = make_test_community(&pool).await; let first = test_pubkey(); let second = test_pubkey(); - let invite = mint_relay_invite(&pool, community, "owner", 3600, Some(1)) + let invite = mint_relay_invite(&pool, community, &test_pubkey(), 3600, Some(1)) .await .expect("mint bounded invite"); let hash = hash_v2_code(&invite.code); @@ -757,7 +1040,7 @@ mod postgres_tests { let community = make_test_community(&pool).await; let first = test_pubkey(); let second = test_pubkey(); - let invite = mint_relay_invite(&pool, community, "owner", 3600, Some(1)) + let invite = mint_relay_invite(&pool, community, &test_pubkey(), 3600, Some(1)) .await .expect("mint bounded invite"); let hash = hash_v2_code(&invite.code); @@ -806,7 +1089,7 @@ mod postgres_tests { let pool = setup_pool().await; let community_a = make_test_community(&pool).await; let community_b = make_test_community(&pool).await; - let invite = mint_relay_invite(&pool, community_a, "owner", 3600, Some(2)) + let invite = mint_relay_invite(&pool, community_a, &test_pubkey(), 3600, Some(2)) .await .expect("mint invite"); let hash = hash_v2_code(&invite.code); @@ -859,10 +1142,10 @@ mod postgres_tests { async fn retention_sweep_deletes_only_invites_older_than_cutoff() { let pool = setup_pool().await; let community = make_test_community(&pool).await; - let old = mint_relay_invite(&pool, community, "owner", 3600, Some(1)) + let old = mint_relay_invite(&pool, community, &test_pubkey(), 3600, Some(1)) .await .expect("mint old invite"); - let recent = mint_relay_invite(&pool, community, "owner", 3600, Some(1)) + let recent = mint_relay_invite(&pool, community, &test_pubkey(), 3600, Some(1)) .await .expect("mint recent invite"); let cutoff = Utc::now() - chrono::Duration::days(30); @@ -964,7 +1247,7 @@ mod postgres_tests { async fn unlimited_invites_count_each_new_member() { let pool = setup_pool().await; let community = make_test_community(&pool).await; - let invite = mint_relay_invite(&pool, community, "owner", 3600, None) + let invite = mint_relay_invite(&pool, community, &test_pubkey(), 3600, None) .await .expect("mint unlimited invite"); let hash = hash_v2_code(&invite.code); @@ -998,7 +1281,7 @@ mod postgres_tests { let pool = setup_pool().await; let community = make_test_community(&pool).await; let pubkey = test_pubkey(); - let invite = mint_relay_invite(&pool, community, "owner", 3600, Some(1)) + let invite = mint_relay_invite(&pool, community, &test_pubkey(), 3600, Some(1)) .await .expect("mint bounded invite"); let hash = hash_v2_code(&invite.code); @@ -1037,3 +1320,16 @@ mod postgres_tests { delete_test_community(&pool, community).await; } } + +#[cfg(test)] +mod revocation_postgres_tests; + +#[cfg(test)] +#[path = "relay_invite/upgrade_postgres_tests.rs"] +mod upgrade_postgres_tests; + +#[cfg(test)] +mod owner_admission_postgres_tests; + +#[cfg(test)] +mod writer_protocol_postgres_tests; diff --git a/crates/buzz-db/src/store/relay_invite/claim_owner.rs b/crates/buzz-db/src/store/relay_invite/claim_owner.rs new file mode 100644 index 0000000000..7e275b1263 --- /dev/null +++ b/crates/buzz-db/src/store/relay_invite/claim_owner.rs @@ -0,0 +1,158 @@ +use crate::error::Result; +use crate::{CommunityId, DbError}; +use sqlx::PgPool; + +/// Result of a committed legacy invitation claim. +pub struct MembershipClaimResult { + /// Whether this claim inserted membership. + pub inserted: bool, + /// Whether the same commit first recorded verified ownership. + pub owner_link_created: bool, + /// User rows first created by this successful transaction. + pub users_created: u64, +} + +/// Check existing and presented owner identities without creating a user row. +pub(super) async fn claimant_restricted( + tx: &mut sqlx::PgConnection, + community: CommunityId, + claimant: &str, + verified_owner: Option<&str>, +) -> Result { + if super::principal_restricted(tx, community, claimant).await? { + return Ok(true); + } + let Some(owner) = verified_owner else { + return Ok(false); + }; + if super::principal_restricted(tx, community, owner).await? { + return Ok(true); + } + let recorded: Option>> = sqlx::query_scalar( + "SELECT agent_owner_pubkey FROM users WHERE community_id = $1 AND pubkey = $2", + ) + .bind(community.as_uuid()) + .bind( + hex::decode(claimant) + .map_err(|_| DbError::InvalidData("invalid invitation principal".into()))?, + ) + .fetch_optional(&mut *tx) + .await?; + let owner_bytes = + hex::decode(owner).map_err(|_| DbError::InvalidData("invalid invitation owner".into()))?; + if recorded + .flatten() + .is_some_and(|existing| existing != owner_bytes) + { + return Err(DbError::InviteOwnerMismatch); + } + Ok(false) +} + +#[derive(Default)] +pub(super) struct OwnerPersistence { + pub(super) link_created: bool, + pub(super) users_created: u64, +} + +/// Called only on an admitted claim, while the admission lock is still held. +pub(super) async fn persist_claim_owner( + tx: &mut sqlx::PgConnection, + community: CommunityId, + claimant: &str, + verified_owner: Option<&str>, +) -> Result { + let Some(owner) = verified_owner else { + return Ok(OwnerPersistence::default()); + }; + let owner_bytes = + hex::decode(owner).map_err(|_| DbError::InvalidData("invalid invitation owner".into()))?; + let claimant_bytes = hex::decode(claimant) + .map_err(|_| DbError::InvalidData("invalid invitation principal".into()))?; + let mut users_created = 0; + for key in [&owner_bytes, &claimant_bytes] { + users_created += sqlx::query( + "INSERT INTO users (community_id, pubkey) VALUES ($1, $2) ON CONFLICT DO NOTHING", + ) + .bind(community.as_uuid()) + .bind(key) + .execute(&mut *tx) + .await? + .rows_affected(); + } + let link_created = sqlx::query("UPDATE users SET agent_owner_pubkey=$3 WHERE community_id=$1 AND pubkey=$2 AND agent_owner_pubkey IS NULL") + .bind(community.as_uuid()).bind(&claimant_bytes).bind(&owner_bytes).execute(&mut *tx).await?.rows_affected() > 0; + // Defense in depth: `claimant_restricted` already refused a banned owner + // under the same admission lock, so this finds nothing today. It keeps + // parity with every other owner writer if that check ever moves. + if link_created { + super::revoke_restricted_agent_invites(tx, community, &claimant_bytes, &owner_bytes) + .await?; + } + Ok(OwnerPersistence { + link_created, + users_created, + }) +} + +/// Atomically claim membership and caller-verified ownership after policy checks. +pub async fn claim_relay_membership_with_owner( + pool: &PgPool, + community: CommunityId, + pubkey: &str, + role: &str, + policy_version: Option<&str>, + verified_owner: Option<&str>, + relay_keypair: &nostr::Keys, +) -> Result> { + let mut tx = crate::begin_community_write_transaction( + pool, + community, + crate::observability::WriterOperation::Authorization, + ) + .await?; + super::lock_admission(tx.conn(), community).await?; + if claimant_restricted(tx.conn(), community, pubkey, verified_owner).await? { + return Err(DbError::InviteRestricted); + } + let inserted = sqlx::query( + "INSERT INTO relay_members (community_id, pubkey, role, added_by) \ + VALUES ($1, $2, $3, 'invite') \ + ON CONFLICT (community_id, pubkey) DO NOTHING", + ) + .bind(community.as_uuid()) + .bind(pubkey) + .bind(role) + .execute(tx.conn()) + .await? + .rows_affected() + > 0; + + if let Some(version) = policy_version { + sqlx::query( + "INSERT INTO join_policy_acceptances (community_id, pubkey, policy_version) \ + VALUES ($1, $2, $3) ON CONFLICT DO NOTHING", + ) + .bind(community.as_uuid()) + .bind(pubkey) + .bind(version) + .execute(tx.conn()) + .await?; + } + + let owner = persist_claim_owner(tx.conn(), community, pubkey, verified_owner).await?; + let snapshot = if inserted { + Some(super::write_nip43_membership_snapshot(&mut tx, relay_keypair).await?) + } else { + None + }; + tx.commit().await?; + Ok(super::MembershipWrite { + outcome: MembershipClaimResult { + inserted, + owner_link_created: owner.link_created, + users_created: owner.users_created, + }, + snapshot, + }) +} diff --git a/crates/buzz-db/src/store/relay_invite/owner_admission_postgres_tests.rs b/crates/buzz-db/src/store/relay_invite/owner_admission_postgres_tests.rs new file mode 100644 index 0000000000..ead36ec4b0 --- /dev/null +++ b/crates/buzz-db/src/store/relay_invite/owner_admission_postgres_tests.rs @@ -0,0 +1,182 @@ +use super::revocation_postgres_tests::claim_relay_invite_outcome; +use super::*; + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn failed_policy_evidence_rolls_back_verified_owner_and_membership() { + let pool = postgres_tests::setup_pool().await; + let community = postgres_tests::make_test_community(&pool).await; + let claimant = postgres_tests::test_pubkey(); + let owner = postgres_tests::test_pubkey(); + let invite = mint_relay_invite( + &pool, + community, + &postgres_tests::test_pubkey(), + 3600, + Some(1), + ) + .await + .unwrap(); + assert!(claim_relay_invite_with_owner( + &pool, + community, + &hash_v2_code(&invite.code), + &claimant, + Some("too-short"), + Some(&owner), + &nostr::Keys::generate() + ) + .await + .is_err()); + assert!(claim_relay_membership_with_owner( + &pool, + community, + &claimant, + "member", + Some("too-short"), + Some(&owner), + &nostr::Keys::generate() + ) + .await + .is_err()); + let users: i64 = sqlx::query_scalar("SELECT count(*) FROM users WHERE community_id=$1") + .bind(community.as_uuid()) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(users, 0); + assert!( + !crate::relay_members::is_relay_member(&pool, community, &claimant) + .await + .unwrap() + ); + assert_eq!( + postgres_tests::use_count(&pool, community, invite.invite_id).await, + 0 + ); +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn successful_claim_reports_only_committed_user_creation() { + let pool = postgres_tests::setup_pool().await; + let community = postgres_tests::make_test_community(&pool).await; + let claimant = postgres_tests::test_pubkey(); + let owner = postgres_tests::test_pubkey(); + let invite = mint_relay_invite( + &pool, + community, + &postgres_tests::test_pubkey(), + 3600, + Some(1), + ) + .await + .unwrap(); + let hash = hash_v2_code(&invite.code); + let first = claim_relay_invite_with_owner( + &pool, + community, + &hash, + &claimant, + None, + Some(&owner), + &nostr::Keys::generate(), + ) + .await + .unwrap(); + assert!(matches!(first.outcome.outcome, ClaimOutcome::Joined { .. })); + assert!(first.outcome.owner_link_created); + assert_eq!(first.outcome.users_created, 2); + let retry = claim_relay_invite_with_owner( + &pool, + community, + &hash, + &claimant, + None, + Some(&owner), + &nostr::Keys::generate(), + ) + .await + .unwrap(); + assert!(matches!( + retry.outcome.outcome, + ClaimOutcome::AlreadyMember { .. } + )); + assert!(!retry.outcome.owner_link_created); + assert_eq!(retry.outcome.users_created, 0); + let legacy_agent = postgres_tests::test_pubkey(); + let legacy = claim_relay_membership_with_owner( + &pool, + community, + &legacy_agent, + "member", + None, + Some(&owner), + &nostr::Keys::generate(), + ) + .await + .unwrap(); + assert!(legacy.outcome.inserted && legacy.outcome.owner_link_created); + assert_eq!(legacy.outcome.users_created, 1); + let retry = claim_relay_membership_with_owner( + &pool, + community, + &legacy_agent, + "member", + None, + Some(&owner), + &nostr::Keys::generate(), + ) + .await + .unwrap(); + assert!(!retry.outcome.inserted && !retry.outcome.owner_link_created); + assert_eq!(retry.outcome.users_created, 0); +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn issuer_timeout_denies_temporarily_without_revoking_invite() { + let pool = postgres_tests::setup_pool().await; + let community = postgres_tests::make_test_community(&pool).await; + let issuer = postgres_tests::test_pubkey(); + let claimant = postgres_tests::test_pubkey(); + let invite = mint_relay_invite(&pool, community, &issuer, 3600, Some(1)) + .await + .unwrap(); + sqlx::query("INSERT INTO community_bans (community_id,pubkey,muted_until,actor_pubkey) VALUES ($1,$2,clock_timestamp()+interval '1 hour',$2)") + .bind(community.as_uuid()).bind(hex::decode(&issuer).unwrap()).execute(&pool).await.unwrap(); + assert_eq!( + claim_relay_invite_outcome( + &pool, + community, + &hash_v2_code(&invite.code), + &claimant, + None + ) + .await + .unwrap(), + ClaimOutcome::IssuerRestricted + ); + let revoked: Option> = + sqlx::query_scalar("SELECT revoked_at FROM relay_invites WHERE community_id=$1 AND id=$2") + .bind(community.as_uuid()) + .bind(invite.invite_id) + .fetch_one(&pool) + .await + .unwrap(); + assert!(revoked.is_none()); + sqlx::query("UPDATE community_bans SET muted_until=clock_timestamp()-interval '1 second' WHERE community_id=$1") + .bind(community.as_uuid()).execute(&pool).await.unwrap(); + assert!(matches!( + claim_relay_invite_outcome( + &pool, + community, + &hash_v2_code(&invite.code), + &claimant, + None + ) + .await + .unwrap(), + ClaimOutcome::Joined { use_count: 1, .. } + )); +} diff --git a/crates/buzz-db/src/store/relay_invite/revocation.rs b/crates/buzz-db/src/store/relay_invite/revocation.rs new file mode 100644 index 0000000000..c9e17cb836 --- /dev/null +++ b/crates/buzz-db/src/store/relay_invite/revocation.rs @@ -0,0 +1,51 @@ +//! Application-owned revocation, under the admission lock and in the writer's commit. +use crate::{error::Result, CommunityId}; + +/// Durably revoke an active banned principal's invites and its verified agents' invites. +/// The caller holds the admission lock before writing restriction or invite rows. +pub(crate) async fn revoke_banned_issuer_invites( + tx: &mut sqlx::PgConnection, + community: CommunityId, + pubkey: &[u8], +) -> Result<()> { + sqlx::query( + "UPDATE relay_invites SET revoked_at = clock_timestamp() \ + WHERE community_id = $1 AND revoked_at IS NULL \ + AND (created_by = encode($2::bytea, 'hex') OR created_by IN (\ + SELECT encode(pubkey, 'hex') FROM users \ + WHERE community_id = $1 AND agent_owner_pubkey = $2\ + )) AND EXISTS (\ + SELECT 1 FROM community_bans WHERE community_id = $1 AND pubkey = $2 \ + AND banned AND (ban_expires_at IS NULL OR ban_expires_at > clock_timestamp())\ + )", + ) + .bind(community.as_uuid()) + .bind(pubkey) + .execute(&mut *tx) + .await?; + Ok(()) +} + +/// Durably revoke an agent's invites when its newly verified owner is actively banned. +/// Timeouts block admission temporarily but never durably revoke invites. +pub(crate) async fn revoke_restricted_agent_invites( + tx: &mut sqlx::PgConnection, + community: CommunityId, + agent_pubkey: &[u8], + owner_pubkey: &[u8], +) -> Result<()> { + sqlx::query( + "UPDATE relay_invites SET revoked_at = clock_timestamp() \ + WHERE community_id = $1 AND created_by = encode($2::bytea, 'hex') \ + AND revoked_at IS NULL AND EXISTS (\ + SELECT 1 FROM community_bans WHERE community_id = $1 AND pubkey = $3 \ + AND banned AND (ban_expires_at IS NULL OR ban_expires_at > clock_timestamp())\ + )", + ) + .bind(community.as_uuid()) + .bind(agent_pubkey) + .bind(owner_pubkey) + .execute(&mut *tx) + .await?; + Ok(()) +} diff --git a/crates/buzz-db/src/store/relay_invite/revocation_postgres_tests.rs b/crates/buzz-db/src/store/relay_invite/revocation_postgres_tests.rs new file mode 100644 index 0000000000..54da3cf174 --- /dev/null +++ b/crates/buzz-db/src/store/relay_invite/revocation_postgres_tests.rs @@ -0,0 +1,645 @@ +use super::*; +use crate::moderation::{ban_member, unban_member}; +use uuid::Uuid; + +pub(super) async fn claim_relay_invite_outcome( + pool: &PgPool, + community: CommunityId, + token_hash: &[u8; 32], + claimer: &str, + policy_version: Option<&str>, +) -> Result { + super::claim_relay_invite( + pool, + community, + token_hash, + claimer, + policy_version, + &nostr::Keys::generate(), + ) + .await + .map(|write| write.outcome) +} + +pub(super) async fn fixture() -> (PgPool, CommunityId, String, String) { + let pool = PgPool::connect(&crate::test_support::database_url()) + .await + .unwrap(); + let id = Uuid::new_v4(); + sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)") + .bind(id) + .bind(format!("invite-{}.example", id.simple())) + .execute(&pool) + .await + .unwrap(); + ( + pool, + CommunityId::from_uuid(id), + hex::encode([1; 32]), + hex::encode([2; 32]), + ) +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn ban_permanently_revokes_issued_invites_but_not_another_tenant() { + let (pool, community, issuer, claimant) = fixture().await; + let other_id = Uuid::new_v4(); + sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)") + .bind(other_id) + .bind(format!("other-{}.example", other_id.simple())) + .execute(&pool) + .await + .unwrap(); + let other = CommunityId::from_uuid(other_id); + let invite = mint_relay_invite(&pool, community, &issuer, 3600, None) + .await + .unwrap(); + let unaffected = mint_relay_invite(&pool, other, &issuer, 3600, None) + .await + .unwrap(); + ban_member(&pool, community, &[1; 32], &[3; 32], None, None) + .await + .unwrap(); + assert!(matches!( + mint_relay_invite(&pool, community, &issuer, 3600, None).await, + Err(crate::DbError::InviteRestricted) + )); + assert!(unban_member(&pool, community, &[1; 32], &[3; 32]) + .await + .unwrap()); + assert_eq!( + claim_relay_invite_outcome( + &pool, + community, + &hash_v2_code(&invite.code), + &claimant, + None + ) + .await + .unwrap(), + ClaimOutcome::Revoked + ); + assert!(matches!( + claim_relay_invite_outcome( + &pool, + other, + &hash_v2_code(&unaffected.code), + &claimant, + None + ) + .await + .unwrap(), + ClaimOutcome::Joined { .. } + )); + let renewed = mint_relay_invite(&pool, community, &issuer, 3600, None) + .await + .unwrap(); + assert!(matches!( + claim_relay_invite_outcome( + &pool, + community, + &hash_v2_code(&renewed.code), + &claimant, + None + ) + .await + .unwrap(), + ClaimOutcome::Joined { .. } + )); +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn nonmember_restrictions_cover_both_claim_formats_and_owner() { + let (pool, community, issuer, claimant) = fixture().await; + let invite = mint_relay_invite(&pool, community, &issuer, 3600, Some(1)) + .await + .unwrap(); + ban_member(&pool, community, &[2; 32], &[3; 32], None, None) + .await + .unwrap(); + assert_eq!( + claim_relay_invite_outcome( + &pool, + community, + &hash_v2_code(&invite.code), + &claimant, + None + ) + .await + .unwrap(), + ClaimOutcome::Restricted + ); + assert!(matches!( + crate::relay_members::claim_relay_membership( + &pool, + community, + &claimant, + "member", + None, + &nostr::Keys::generate() + ) + .await, + Err(crate::DbError::InviteRestricted) + )); + let agent = hex::encode([4; 32]); + sqlx::query("INSERT INTO users (community_id,pubkey) VALUES ($1,$2),($1,$3)") + .bind(community.as_uuid()) + .bind(&[2u8; 32][..]) + .bind(&[4u8; 32][..]) + .execute(&pool) + .await + .unwrap(); + crate::user::set_agent_owner(&pool, community, &[4; 32], &[2; 32]) + .await + .unwrap(); + assert_eq!( + claim_relay_invite_outcome(&pool, community, &hash_v2_code(&invite.code), &agent, None) + .await + .unwrap(), + ClaimOutcome::Restricted + ); + assert!( + !crate::relay_members::is_relay_member(&pool, community, &claimant) + .await + .unwrap() + ); + let count: i32 = + sqlx::query_scalar("SELECT use_count FROM relay_invites WHERE community_id=$1 AND id=$2") + .bind(community.as_uuid()) + .bind(invite.invite_id) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(count, 0); +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn issuer_agent_revocation_survives_owner_unban() { + let (pool, community, issuer, claimant) = fixture().await; + sqlx::query("INSERT INTO users (community_id,pubkey) VALUES ($1,$2),($1,$3)") + .bind(community.as_uuid()) + .bind(&[1u8; 32][..]) + .bind(&[4u8; 32][..]) + .execute(&pool) + .await + .unwrap(); + crate::user::set_agent_owner(&pool, community, &[1; 32], &[4; 32]) + .await + .unwrap(); + let invite = mint_relay_invite(&pool, community, &issuer, 3600, None) + .await + .unwrap(); + ban_member(&pool, community, &[4; 32], &[3; 32], None, None) + .await + .unwrap(); + unban_member(&pool, community, &[4; 32], &[3; 32]) + .await + .unwrap(); + assert_eq!( + claim_relay_invite_outcome( + &pool, + community, + &hash_v2_code(&invite.code), + &claimant, + None + ) + .await + .unwrap(), + ClaimOutcome::Revoked + ); +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn stalled_production_mint_ignores_lifted_history_and_later_audit_touches() { + let (pool, community, issuer, claimant) = fixture().await; + let issuer_bytes = [1u8; 32]; + sqlx::query( + "INSERT INTO community_bans (community_id, pubkey, banned, actor_pubkey) \ + VALUES ($1, $2, false, $3)", + ) + .bind(community.as_uuid()) + .bind(issuer_bytes.as_slice()) + .bind([3u8; 32].as_slice()) + .execute(&pool) + .await + .unwrap(); + + let old_action = Uuid::new_v4(); + let old_action_time = Utc::now() - chrono::Duration::days(1); + sqlx::query( + "INSERT INTO relay_admin_actions \ + (id, report_community_id, request_id, actor_pubkey, actor_role, action, \ + state, step_marker, enforcement_target_pubkey, created_at, updated_at) \ + VALUES ($1, $2, $3, $4, 'operator', 'ban', 'succeeded', \ + 'mutation_committed', $5, $6, $6)", + ) + .bind(old_action) + .bind(community.as_uuid()) + .bind(Uuid::new_v4()) + .bind([3u8; 32].as_slice()) + .bind(issuer_bytes.as_slice()) + .bind(old_action_time) + .execute(&pool) + .await + .unwrap(); + + // Hold the production admission seam so mint starts its transaction and + // stalls before insert. Touching the old action while it waits creates the + // timestamp ordering that the retired audit predicate misread as proof. + let mut admission = pool.begin().await.unwrap(); + lock_admission(&mut admission, community).await.unwrap(); + let (mint_pool, mint_name) = named_pool().await; + let mint_issuer = issuer.clone(); + let mint = tokio::spawn(async move { + mint_relay_invite(&mint_pool, community, &mint_issuer, 3600, None).await + }); + wait_for_lock(&pool, &mint_name, "advisory", &mint).await; + + sqlx::query( + "UPDATE relay_admin_actions \ + SET updated_at=clock_timestamp()+interval '1 minute' WHERE id=$1", + ) + .bind(old_action) + .execute(&pool) + .await + .unwrap(); + admission.commit().await.unwrap(); + + let invite = tokio::time::timeout(std::time::Duration::from_secs(5), mint) + .await + .unwrap() + .unwrap() + .unwrap(); + let marker: bool = sqlx::query_scalar( + "SELECT legacy_admission FROM relay_invites WHERE community_id=$1 AND id=$2", + ) + .bind(community.as_uuid()) + .bind(invite.invite_id) + .fetch_one(&pool) + .await + .unwrap(); + assert!( + !marker, + "the production mint seam marks protocol-native rows" + ); + + let old_action_touched_after_mint_started: bool = sqlx::query_scalar( + "SELECT a.updated_at >= i.created_at \ + FROM relay_admin_actions a CROSS JOIN relay_invites i \ + WHERE a.id=$1 AND i.community_id=$2 AND i.id=$3", + ) + .bind(old_action) + .bind(community.as_uuid()) + .bind(invite.invite_id) + .fetch_one(&pool) + .await + .unwrap(); + assert!( + old_action_touched_after_mint_started, + "the mint transaction began before the old action timestamp was touched" + ); + for action in ["ban", "resolve:ban"] { + sqlx::query( + "INSERT INTO moderation_actions \ + (community_id, actor_pubkey, action, target_pubkey, created_at) \ + VALUES ($1, $2, $3, $4, clock_timestamp()+interval '1 minute')", + ) + .bind(community.as_uuid()) + .bind([2u8; 32].as_slice()) + .bind(action) + .bind(issuer_bytes.as_slice()) + .execute(&pool) + .await + .unwrap(); + } + + let revoked: Option> = + sqlx::query_scalar("SELECT revoked_at FROM relay_invites WHERE community_id=$1 AND id=$2") + .bind(community.as_uuid()) + .bind(invite.invite_id) + .fetch_one(&pool) + .await + .unwrap(); + assert!( + revoked.is_none(), + "lifted history and late audits spare new rows" + ); + assert!(matches!( + claim_relay_invite_outcome( + &pool, + community, + &hash_v2_code(&invite.code), + &claimant, + None + ) + .await + .unwrap(), + ClaimOutcome::Joined { .. } + )); +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn active_ban_lazily_revokes_new_protocol_rows() { + let (pool, community, issuer, claimant) = fixture().await; + let invite = mint_relay_invite(&pool, community, &issuer, 3600, None) + .await + .unwrap(); + let legacy_admission: bool = sqlx::query_scalar( + "SELECT legacy_admission FROM relay_invites WHERE community_id=$1 AND id=$2", + ) + .bind(community.as_uuid()) + .bind(invite.invite_id) + .fetch_one(&pool) + .await + .unwrap(); + assert!(!legacy_admission); + sqlx::query( + "INSERT INTO community_bans (community_id, pubkey, banned, actor_pubkey) \ + VALUES ($1, $2, true, $3)", + ) + .bind(community.as_uuid()) + .bind([1u8; 32].as_slice()) + .bind([3u8; 32].as_slice()) + .execute(&pool) + .await + .unwrap(); + + assert_eq!( + claim_relay_invite_outcome( + &pool, + community, + &hash_v2_code(&invite.code), + &claimant, + None + ) + .await + .unwrap(), + ClaimOutcome::Revoked + ); + let revoked: bool = sqlx::query_scalar( + "SELECT revoked_at IS NOT NULL FROM relay_invites WHERE community_id=$1 AND id=$2", + ) + .bind(community.as_uuid()) + .bind(invite.invite_id) + .fetch_one(&pool) + .await + .unwrap(); + assert!(revoked, "active bans durably revoke new-protocol rows"); +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn malformed_legacy_issuers_return_a_typed_revoked_claim() { + let (pool, community, _, claimant) = fixture().await; + let issuers = [ + "not-a-pubkey".to_owned(), + hex::encode([0xabu8; 32]).to_ascii_uppercase(), + ]; + for (index, issuer) in issuers.iter().enumerate() { + let token_hash = [index as u8 + 10; 32]; + sqlx::query( + "INSERT INTO relay_invites (community_id, token_hash, created_by, expires_at) \ + VALUES ($1, $2, $3, clock_timestamp()+interval '1 day')", + ) + .bind(community.as_uuid()) + .bind(token_hash.as_slice()) + .bind(issuer) + .execute(&pool) + .await + .unwrap(); + assert_eq!( + claim_relay_invite_outcome(&pool, community, &token_hash, &claimant, None) + .await + .unwrap(), + ClaimOutcome::Revoked, + "issuer {issuer} must fail closed without a database error" + ); + } + + let token_hash = [99u8; 32]; + sqlx::query( + "INSERT INTO relay_invites \ + (community_id, token_hash, created_by, expires_at, legacy_admission) \ + VALUES ($1, $2, 'not-a-pubkey', clock_timestamp()+interval '1 day', false)", + ) + .bind(community.as_uuid()) + .bind(token_hash.as_slice()) + .execute(&pool) + .await + .unwrap(); + assert_eq!( + claim_relay_invite_outcome(&pool, community, &token_hash, &claimant, None) + .await + .unwrap(), + ClaimOutcome::Invalid, + "a malformed non-legacy issuer returns a clean invalid outcome" + ); +} + +pub(super) async fn named_pool() -> (PgPool, String) { + let name = format!("invite-race-{}", Uuid::new_v4()); + let connection_name = name.clone(); + let pool = sqlx::postgres::PgPoolOptions::new() + .max_connections(1) + .after_connect(move |conn, _| { + let name = connection_name.clone(); + Box::pin(async move { + sqlx::query("SELECT set_config('application_name',$1,false)") + .bind(name) + .execute(conn) + .await?; + Ok(()) + }) + }) + .connect(&crate::test_support::database_url()) + .await + .unwrap(); + (pool, name) +} + +pub(super) async fn wait_for_lock( + pool: &PgPool, + name: &str, + kind: &str, + task: &tokio::task::JoinHandle, +) { + tokio::time::timeout(std::time::Duration::from_secs(5), async { + loop { + let waiting: bool = sqlx::query_scalar( + "SELECT EXISTS(SELECT 1 FROM pg_locks l JOIN pg_stat_activity a ON a.pid=l.pid \ + WHERE a.application_name=$1 AND l.locktype=$2 AND NOT l.granted)", + ) + .bind(name) + .bind(kind) + .fetch_one(pool) + .await + .unwrap(); + if waiting { + break; + } + assert!(!task.is_finished(), "writer bypassed expected {kind} lock"); + tokio::task::yield_now().await; + } + }) + .await + .expect("writer must observably wait on the expected lock"); +} + +async fn queued_claim( + pool: &PgPool, + community: CommunityId, + code: &str, + claimant: String, +) -> tokio::task::JoinHandle> { + let (claim_pool, name) = named_pool().await; + let hash = hash_v2_code(code); + let claim = tokio::spawn(async move { + claim_relay_invite_outcome(&claim_pool, community, &hash, &claimant, None).await + }); + wait_for_lock(pool, &name, "advisory", &claim).await; + claim +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn claimant_ban_and_timeout_commit_before_queued_claim_without_consuming_a_use() { + for timeout in [false, true] { + let (pool, community, issuer, claimant) = fixture().await; + let invite = mint_relay_invite(&pool, community, &issuer, 3600, Some(1)) + .await + .unwrap(); + // Hold the existing restriction row so the production writer stops only + // after acquiring admission. The claim must queue behind that writer. + ban_member(&pool, community, &[2; 32], &[3; 32], None, None) + .await + .unwrap(); + unban_member(&pool, community, &[2; 32], &[3; 32]) + .await + .unwrap(); + let mut row_lock = pool.begin().await.unwrap(); + sqlx::query("SELECT pubkey FROM community_bans WHERE community_id=$1 FOR UPDATE") + .bind(community.as_uuid()) + .fetch_one(&mut *row_lock) + .await + .unwrap(); + let (writer_pool, name) = named_pool().await; + let restriction = tokio::spawn(async move { + if timeout { + crate::moderation::timeout_member( + &writer_pool, + community, + &[2; 32], + &[3; 32], + Utc::now() + chrono::Duration::hours(1), + None, + ) + .await + } else { + ban_member(&writer_pool, community, &[2; 32], &[3; 32], None, None).await + } + }); + wait_for_lock(&pool, &name, "transactionid", &restriction).await; + let claim = queued_claim(&pool, community, &invite.code, claimant.clone()).await; + row_lock.commit().await.unwrap(); + restriction.await.unwrap().unwrap(); + assert_eq!(claim.await.unwrap().unwrap(), ClaimOutcome::Restricted); + let uses: i32 = sqlx::query_scalar( + "SELECT use_count FROM relay_invites WHERE community_id=$1 AND id=$2", + ) + .bind(community.as_uuid()) + .bind(invite.invite_id) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(uses, 0); + assert!( + !crate::relay_members::is_relay_member(&pool, community, &claimant) + .await + .unwrap() + ); + } +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn owner_materialization_commits_before_queued_agent_claim() { + let (pool, community, issuer, agent) = fixture().await; + let invite = mint_relay_invite(&pool, community, &issuer, 3600, None) + .await + .unwrap(); + sqlx::query("INSERT INTO users(community_id,pubkey) VALUES($1,$2),($1,$3)") + .bind(community.as_uuid()) + .bind(vec![2u8; 32]) + .bind(vec![4u8; 32]) + .execute(&pool) + .await + .unwrap(); + ban_member(&pool, community, &[4; 32], &[3; 32], None, None) + .await + .unwrap(); + let mut row_lock = pool.begin().await.unwrap(); + sqlx::query("SELECT pubkey FROM users WHERE community_id=$1 AND pubkey=$2 FOR UPDATE") + .bind(community.as_uuid()) + .bind(vec![2u8; 32]) + .fetch_one(&mut *row_lock) + .await + .unwrap(); + let (writer_pool, name) = named_pool().await; + let owner = tokio::spawn(async move { + crate::user::set_agent_owner(&writer_pool, community, &[2; 32], &[4; 32]).await + }); + wait_for_lock(&pool, &name, "transactionid", &owner).await; + let claim = queued_claim(&pool, community, &invite.code, agent).await; + row_lock.commit().await.unwrap(); + assert!(owner.await.unwrap().unwrap()); + assert_eq!(claim.await.unwrap().unwrap(), ClaimOutcome::Restricted); +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn admission_first_then_ban_preserves_the_committed_use_and_denies_retries() { + let (pool, community, issuer, claimant) = fixture().await; + let invite = mint_relay_invite(&pool, community, &issuer, 3600, Some(2)) + .await + .unwrap(); + assert!(matches!( + claim_relay_invite_outcome( + &pool, + community, + &hash_v2_code(&invite.code), + &claimant, + None + ) + .await + .unwrap(), + ClaimOutcome::Joined { use_count: 1, .. } + )); + ban_member(&pool, community, &[2; 32], &[3; 32], None, None) + .await + .unwrap(); + assert_eq!( + claim_relay_invite_outcome( + &pool, + community, + &hash_v2_code(&invite.code), + &claimant, + None + ) + .await + .unwrap(), + ClaimOutcome::Restricted + ); + let uses: i32 = + sqlx::query_scalar("SELECT use_count FROM relay_invites WHERE community_id=$1 AND id=$2") + .bind(community.as_uuid()) + .bind(invite.invite_id) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(uses, 1); +} diff --git a/crates/buzz-db/src/store/relay_invite/upgrade_postgres_tests.rs b/crates/buzz-db/src/store/relay_invite/upgrade_postgres_tests.rs new file mode 100644 index 0000000000..fd06aa0e62 --- /dev/null +++ b/crates/buzz-db/src/store/relay_invite/upgrade_postgres_tests.rs @@ -0,0 +1,673 @@ +use super::*; +use chrono::{DateTime, Duration, Utc}; +use uuid::Uuid; + +struct LegacyInviteCase { + community: Uuid, +} + +struct RelayAdminActionFixture<'a> { + community: Uuid, + target_pubkey: &'a [u8; 32], + report_bound: bool, + state: &'a str, + step_marker: Option<&'a str>, + created_at: DateTime, + updated_at: DateTime, +} + +async fn insert_restriction( + pool: &PgPool, + community: Uuid, + pubkey: &[u8; 32], + banned: bool, + ban_expires_at: Option>, + muted_until: Option>, +) { + sqlx::query( + "INSERT INTO community_bans \ + (community_id, pubkey, banned, ban_expires_at, muted_until, actor_pubkey) \ + VALUES ($1, $2, $3, $4, $5, $6)", + ) + .bind(community) + .bind(pubkey.as_slice()) + .bind(banned) + .bind(ban_expires_at) + .bind(muted_until) + .bind([3u8; 32].as_slice()) + .execute(pool) + .await + .unwrap(); +} + +async fn insert_legacy_invite( + pool: &PgPool, + community: Uuid, + token_seed: u8, + created_by: &str, + created_at: DateTime, +) -> [u8; 32] { + let token_hash = [token_seed; 32]; + sqlx::query( + "INSERT INTO relay_invites \ + (community_id, token_hash, created_by, expires_at, created_at) \ + VALUES ($1, $2, $3, $4, $5)", + ) + .bind(community) + .bind(token_hash.as_slice()) + .bind(created_by) + .bind(created_at + Duration::days(2)) + .bind(created_at) + .execute(pool) + .await + .unwrap(); + token_hash +} + +async fn insert_moderation_action( + pool: &PgPool, + community: Uuid, + action: &str, + target_pubkey: &[u8; 32], + created_at: DateTime, +) { + sqlx::query( + "INSERT INTO moderation_actions \ + (community_id, actor_pubkey, action, target_pubkey, created_at) \ + VALUES ($1, $2, $3, $4, $5)", + ) + .bind(community) + .bind([2u8; 32].as_slice()) + .bind(action) + .bind(target_pubkey.as_slice()) + .bind(created_at) + .execute(pool) + .await + .unwrap(); +} + +async fn insert_relay_admin_action(pool: &PgPool, fixture: RelayAdminActionFixture<'_>) -> Uuid { + let RelayAdminActionFixture { + community, + target_pubkey, + report_bound, + state, + step_marker, + created_at, + updated_at, + } = fixture; + let report_id = report_bound.then(Uuid::new_v4); + if let Some(report_id) = report_id { + let mut report_event_id = [0u8; 32]; + report_event_id[..16].copy_from_slice(report_id.as_bytes()); + report_event_id[16..].copy_from_slice(report_id.as_bytes()); + sqlx::query( + "INSERT INTO moderation_reports \ + (community_id, id, report_event_id, reporter_pubkey, target_kind, \ + target_pubkey, report_type) \ + VALUES ($1, $2, $3, $4, 'pubkey', $5, 'spam')", + ) + .bind(community) + .bind(report_id) + .bind(report_event_id.as_slice()) + .bind([4u8; 32].as_slice()) + .bind(target_pubkey.as_slice()) + .execute(pool) + .await + .unwrap(); + } + + let id = Uuid::new_v4(); + let actor_pubkey = [3u8; 32]; + let cancelled_by = (state == "cancelled").then_some(actor_pubkey.as_slice()); + sqlx::query( + "INSERT INTO relay_admin_actions \ + (id, report_id, report_community_id, request_id, actor_pubkey, actor_role, \ + action, state, step_marker, cancelled_by, enforcement_target_pubkey, \ + created_at, updated_at) \ + VALUES ($1, $2, $3, $4, $5, 'operator', 'ban', $6, $7, $8, $9, $10, $11)", + ) + .bind(id) + .bind(report_id) + .bind(community) + .bind(Uuid::new_v4()) + .bind(actor_pubkey.as_slice()) + .bind(state) + .bind(step_marker) + .bind(cancelled_by) + .bind(target_pubkey.as_slice()) + .bind(created_at) + .bind(updated_at) + .execute(pool) + .await + .unwrap(); + id +} + +async fn set_deletion_state(pool: &PgPool, community: Uuid, state: &str) { + let mut tx = pool.begin().await.unwrap(); + sqlx::query( + "SELECT set_config('buzz.deletion_executor_community', $1, true), \ + set_config('buzz.deletion_fence_generation', '7', true)", + ) + .bind(community.to_string()) + .execute(&mut *tx) + .await + .unwrap(); + sqlx::query( + "UPDATE communities \ + SET deletion_state = $2, deletion_fence_generation = 7 WHERE id = $1", + ) + .bind(community) + .bind(state) + .execute(&mut *tx) + .await + .unwrap(); + tx.commit().await.unwrap(); +} + +async fn legacy_fixture() -> (PgPool, Vec) { + let pool = PgPool::connect(&crate::test_support::database_url()) + .await + .unwrap(); + // Pinned main includes the user deletion target in 0072-0073 and its fences in + // 0074; invitations expand in 0075. + crate::migration::run_migrations_through(&pool, 74) + .await + .unwrap(); + let last_migration: i64 = + sqlx::query_scalar("SELECT max(version) FROM _sqlx_migrations WHERE success") + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!( + last_migration, 74, + "fixtures start at the complete pinned-main schema" + ); + + let issuer = [1u8; 32]; + let mut cases = Vec::new(); + for index in 0u8..12 { + let community = Uuid::new_v4(); + sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)") + .bind(community) + .bind(format!("upgrade-{index}-{community}.example")) + .execute(&pool) + .await + .unwrap(); + + let created_by = match index { + 5 => { + let agent = [4u8; 32]; + let owner = [5u8; 32]; + sqlx::query("INSERT INTO users (community_id, pubkey) VALUES ($1, $2), ($1, $3)") + .bind(community) + .bind(agent.as_slice()) + .bind(owner.as_slice()) + .execute(&pool) + .await + .unwrap(); + // Seed the owner link before testing the writer-side admission lock. + sqlx::query( + "UPDATE users SET agent_owner_pubkey = $1 \ + WHERE community_id = $2 AND pubkey = $3", + ) + .bind(owner.as_slice()) + .bind(community) + .bind(agent.as_slice()) + .execute(&pool) + .await + .unwrap(); + insert_restriction(&pool, community, &owner, false, None, None).await; + hex::encode(agent) + } + 6 => "not-a-pubkey".to_owned(), + 7 => hex::encode([0xabu8; 32]).to_ascii_uppercase(), + _ => hex::encode(issuer), + }; + + match index { + 0 => insert_restriction(&pool, community, &issuer, true, None, None).await, + 1 => { + insert_restriction( + &pool, + community, + &issuer, + true, + Some(Utc::now() - Duration::hours(1)), + None, + ) + .await; + } + 2 | 4 => insert_restriction(&pool, community, &issuer, false, None, None).await, + 3 => { + insert_restriction( + &pool, + community, + &issuer, + false, + None, + Some(Utc::now() - Duration::hours(1)), + ) + .await; + } + _ => {} + } + + let old_action = if index == 10 { + Some( + insert_relay_admin_action( + &pool, + RelayAdminActionFixture { + community, + target_pubkey: &issuer, + report_bound: false, + state: "succeeded", + step_marker: Some("mutation_committed"), + created_at: Utc::now() - Duration::days(1), + updated_at: Utc::now() - Duration::days(1), + }, + ) + .await, + ) + } else { + None + }; + let created_at = if index == 4 { + Utc::now() + Duration::hours(1) + } else { + Utc::now() - Duration::hours(1) + }; + insert_legacy_invite(&pool, community, index + 1, &created_by, created_at).await; + + if index == 9 { + insert_moderation_action( + &pool, + community, + "resolve:ban", + &issuer, + Utc::now() + Duration::minutes(1), + ) + .await; + } + if index == 10 { + let after = Utc::now() + Duration::minutes(1); + insert_moderation_action(&pool, community, "ban", &issuer, after).await; + for state in ["pending", "enforcing", "failed", "cancelled"] { + let marker = + matches!(state, "enforcing" | "failed").then_some("mutation_committed"); + for report_bound in [true, false] { + insert_relay_admin_action( + &pool, + RelayAdminActionFixture { + community, + target_pubkey: &issuer, + report_bound, + state, + step_marker: marker, + created_at: after, + updated_at: after, + }, + ) + .await; + } + } + sqlx::query("UPDATE relay_admin_actions SET updated_at = $2 WHERE id = $1") + .bind(old_action.unwrap()) + .bind(Utc::now() + Duration::minutes(2)) + .execute(&pool) + .await + .unwrap(); + } + if index == 11 { + let bystander = Uuid::new_v4(); + sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)") + .bind(bystander) + .bind(format!("upgrade-bystander-{bystander}.example")) + .execute(&pool) + .await + .unwrap(); + insert_restriction(&pool, bystander, &issuer, true, None, None).await; + } + if index == 1 { + set_deletion_state(&pool, community, "quiescing").await; + } else if index == 2 { + set_deletion_state(&pool, community, "tombstone").await; + } + + cases.push(LegacyInviteCase { community }); + } + // Preserve a populated, non-default join role across both upgrade paths. + sqlx::query( + "INSERT INTO channels (community_id, name, created_by, join_role) \ + VALUES ($1, 'upgrade-guest', $2, 'guest')", + ) + .bind(cases[0].community) + .bind(issuer.as_slice()) + .execute(&pool) + .await + .unwrap(); + assert_join_role_preserved(&pool, cases[0].community).await; + (pool, cases) +} + +async fn assert_join_role_preserved(pool: &PgPool, community: Uuid) { + let column: (String, String, Option) = sqlx::query_as( + "SELECT data_type, is_nullable, column_default \ + FROM information_schema.columns \ + WHERE table_schema = 'public' AND table_name = 'channels' \ + AND column_name = 'join_role'", + ) + .fetch_one(pool) + .await + .unwrap(); + assert_eq!( + column, + ("text".into(), "NO".into(), Some("'member'::text".into())) + ); + let join_role: String = sqlx::query_scalar( + "SELECT join_role FROM channels WHERE community_id = $1 AND name = 'upgrade-guest'", + ) + .bind(community) + .fetch_one(pool) + .await + .unwrap(); + assert_eq!( + join_role, "guest", + "upgrading must preserve channel admission policy" + ); +} + +async fn invite_revocation_state(pool: &PgPool) -> Vec<(Uuid, Option>, bool)> { + sqlx::query_as( + "SELECT community_id, revoked_at, legacy_admission \ + FROM relay_invites ORDER BY community_id", + ) + .fetch_all(pool) + .await + .unwrap() +} + +async fn assert_admission_column(pool: &PgPool) { + let column: Option<(String, String, Option)> = sqlx::query_as( + "SELECT data_type, is_nullable, column_default \ + FROM information_schema.columns \ + WHERE table_schema = 'public' AND table_name = 'relay_invites' \ + AND column_name = 'legacy_admission'", + ) + .fetch_optional(pool) + .await + .unwrap(); + assert_eq!( + column, + Some(("boolean".into(), "NO".into(), Some("true".into()))) + ); + let revoked: Option<(String, String, Option)> = sqlx::query_as( + "SELECT data_type, is_nullable, column_default \ + FROM information_schema.columns \ + WHERE table_schema = 'public' AND table_name = 'relay_invites' \ + AND column_name = 'revoked_at'", + ) + .fetch_optional(pool) + .await + .unwrap(); + assert_eq!( + revoked, + Some(("timestamp with time zone".into(), "YES".into(), None)) + ); +} + +async fn apply_pgschema_to_existing_database(pool: &PgPool) { + // The pinned migration history has retired triggers on the partitioned + // events parent and auto-named deletion-request checks that the desired + // schema replaces. pgschema orders child-trigger drops before their parent + // and schedules a replacement check before dropping its same-named copy. + // Remove only those stale objects in this disposable fixture; pgschema + // still applies desired state; historical repair is a separate reviewed + // operation after deployment. + sqlx::raw_sql( + "DROP TRIGGER IF EXISTS trg_events_guard_nip_rs_hard_delete ON events; \ + DROP TRIGGER IF EXISTS trg_events_nip_rs_watermark ON events; \ + DROP TRIGGER IF EXISTS trg_events_purge_soft_deleted_nip_rs ON events; \ + DROP TRIGGER IF EXISTS trg_events_purge_soft_deleted_buzz_mesh_status ON events; \ + ALTER TABLE community_deletion_requests \ + DROP CONSTRAINT IF EXISTS community_deletion_requests_check1; \ + ALTER TABLE community_deletion_requests \ + DROP CONSTRAINT IF EXISTS community_deletion_requests_check2; \ + ALTER TABLE community_deletion_requests \ + DROP CONSTRAINT IF EXISTS community_deletion_requests_check3; \ + ALTER TABLE community_deletion_requests \ + DROP CONSTRAINT IF EXISTS community_deletion_requests_check4;", + ) + .execute(pool) + .await + .unwrap(); + + let database_url = crate::test_support::database_url(); + let options: sqlx::postgres::PgConnectOptions = database_url.parse().unwrap(); + let host = options.get_host().to_owned(); + let port = options.get_port().to_string(); + let user = options.get_username().to_owned(); + let password = database_url + .split_once("://") + .and_then(|(_, rest)| rest.split_once('@')) + .and_then(|(authority, _)| authority.split_once(':')) + .map(|(_, password)| password.to_owned()) + .or_else(|| std::env::var("PGPASSWORD").ok()) + .unwrap_or_default(); + let database: String = sqlx::query_scalar("SELECT current_database()") + .fetch_one(pool) + .await + .unwrap(); + // Run from the repository root, like every pgschema caller, so + // `.pgschemaignore` applies. + let repo_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); + let schema_file = repo_root.join("schema/schema.sql"); + let output = std::process::Command::new(repo_root.join("bin/pgschema")) + .current_dir(&repo_root) + .args([ + "apply", + "--auto-approve", + "--file", + schema_file.to_str().unwrap(), + "--host", + &host, + "--port", + &port, + "--user", + &user, + "--password", + &password, + "--db", + &database, + "--plan-host", + &host, + "--plan-port", + &port, + "--plan-user", + &user, + "--plan-password", + &password, + "--plan-db", + &database, + ]) + .output() + .unwrap(); + assert!( + output.status.success(), + "pgschema apply failed:\n{}\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr), + ); +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn migration_schema_0075_adds_invite_columns_without_rewriting_history() { + let (pool, cases) = legacy_fixture().await; + crate::migration::run_migrations(&pool).await.unwrap(); + assert_eq!( + sqlx::query_scalar::<_, i64>("SELECT max(version) FROM _sqlx_migrations WHERE success") + .fetch_one(&pool) + .await + .unwrap(), + 75 + ); + assert_admission_column(&pool).await; + assert_join_role_preserved(&pool, cases[0].community).await; + assert_eq!( + sqlx::query_scalar::<_, i64>( + "SELECT count(*) FROM relay_invites WHERE revoked_at IS NOT NULL" + ) + .fetch_one(&pool) + .await + .unwrap(), + 0, + "schema migration must not rewrite historical rows" + ); + assert_eq!( + sqlx::query_scalar::<_, i64>( + "SELECT count(*) FROM relay_invites WHERE legacy_admission IS DISTINCT FROM true" + ) + .fetch_one(&pool) + .await + .unwrap(), + 0, + "pre-migration rows remain legacy-admitted" + ); + + insert_legacy_invite( + &pool, + cases[8].community, + 100, + &hex::encode([1u8; 32]), + Utc::now(), + ) + .await; + let old_code_insert_default: bool = sqlx::query_scalar( + "SELECT legacy_admission FROM relay_invites \ + WHERE community_id=$1 AND token_hash=$2", + ) + .bind(cases[8].community) + .bind([100u8; 32].as_slice()) + .fetch_one(&pool) + .await + .unwrap(); + assert!(old_code_insert_default); +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn migration_schema_legacy_claim_lazily_revokes_history_after_a_fenced_community_reactivates() +{ + let (pool, cases) = legacy_fixture().await; + crate::migration::run_migrations(&pool).await.unwrap(); + + let community = cases[1].community; + let token_hash = [2u8; 32]; + let fenced = claim_relay_invite( + &pool, + CommunityId::from_uuid(community), + &token_hash, + &format!("{:064x}", 9), + None, + &nostr::Keys::generate(), + ) + .await + .unwrap_err(); + assert!( + matches!(fenced, crate::DbError::AccessDenied(_)), + "{fenced:?}" + ); + let revoked: bool = sqlx::query_scalar( + "SELECT revoked_at IS NOT NULL FROM relay_invites WHERE community_id = $1", + ) + .bind(community) + .fetch_one(&pool) + .await + .unwrap(); + assert!(!revoked, "quiescing tenants stay fenced and untouched"); + + set_deletion_state(&pool, community, "active").await; + assert_eq!( + claim_relay_invite( + &pool, + CommunityId::from_uuid(community), + &token_hash, + &format!("{:064x}", 9), + None, + &nostr::Keys::generate(), + ) + .await + .map(|write| write.outcome) + .unwrap(), + ClaimOutcome::Revoked + ); + let revoked: bool = sqlx::query_scalar( + "SELECT revoked_at IS NOT NULL FROM relay_invites WHERE community_id = $1", + ) + .bind(community) + .fetch_one(&pool) + .await + .unwrap(); + assert!( + revoked, + "reactivated legacy history is durably revoked on claim" + ); + let legacy_admission: bool = + sqlx::query_scalar("SELECT legacy_admission FROM relay_invites WHERE community_id = $1") + .bind(community) + .fetch_one(&pool) + .await + .unwrap(); + assert!( + legacy_admission, + "claim does not stand in for maintenance repair" + ); +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn migration_schema_desired_pgschema_reconcile_matches_populated_upgrade() { + let (pool, cases) = legacy_fixture().await; + let before: i64 = sqlx::query_scalar("SELECT count(*) FROM relay_invites") + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(before, cases.len() as i64); + + // Exercise the deployment path against populated data at the complete + // pinned-main schema through 0074, rather than reproducing its DDL by hand. + apply_pgschema_to_existing_database(&pool).await; + assert_admission_column(&pool).await; + assert_join_role_preserved(&pool, cases[0].community).await; + let before_reconcile: i64 = + sqlx::query_scalar("SELECT count(*) FROM relay_invites WHERE revoked_at IS NOT NULL") + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!( + before_reconcile, 0, + "pgschema does not execute historical DML" + ); + + let before_reconcile = invite_revocation_state(&pool).await; + let reconcile = include_str!("../../../../../scripts/reconcile-schema-after-pgschema.sql"); + sqlx::raw_sql(reconcile).execute(&pool).await.unwrap(); + assert_eq!(invite_revocation_state(&pool).await, before_reconcile); + // Reconciliation is schema-only and idempotent; no historical data is + // rewritten by the desired-state path. + sqlx::raw_sql(reconcile).execute(&pool).await.unwrap(); + assert_eq!(invite_revocation_state(&pool).await, before_reconcile); + assert_join_role_preserved(&pool, cases[0].community).await; + assert_eq!( + sqlx::query_scalar::<_, i64>( + "SELECT count(*) FROM relay_invites WHERE legacy_admission IS DISTINCT FROM true" + ) + .fetch_one(&pool) + .await + .unwrap(), + 0, + "pgschema preserves the legacy default for populated rows" + ); +} diff --git a/crates/buzz-db/src/store/relay_invite/writer_protocol_postgres_tests.rs b/crates/buzz-db/src/store/relay_invite/writer_protocol_postgres_tests.rs new file mode 100644 index 0000000000..7157267ec9 --- /dev/null +++ b/crates/buzz-db/src/store/relay_invite/writer_protocol_postgres_tests.rs @@ -0,0 +1,378 @@ +use super::revocation_postgres_tests::{ + claim_relay_invite_outcome, fixture, named_pool, wait_for_lock, +}; +use super::*; + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn authorization_owner_writer_waits_before_claim_owner_row_and_links_once() { + let (pool, community, _, claimant) = fixture().await; + let owner = hex::encode([4; 32]); + for key in [[2; 32], [4; 32]] { + crate::user::ensure_user(&pool, community, &key) + .await + .unwrap(); + } + let mut claim = pool.begin().await.unwrap(); + lock_admission(&mut claim, community).await.unwrap(); + let (writer_pool, name) = named_pool().await; + let setter = tokio::spawn(async move { + Db::from_pool(writer_pool) + .set_agent_owner_for_authorization(community, &[2; 32], &[4; 32]) + .await + }); + wait_for_lock(&pool, &name, "advisory", &setter).await; + // Exercise the production claim persistence while the setter is queued. + // If the setter locks the user first, this bounded operation cannot finish. + let written = tokio::time::timeout( + std::time::Duration::from_secs(5), + super::claim_owner::persist_claim_owner(&mut claim, community, &claimant, Some(&owner)), + ) + .await + .unwrap() + .unwrap(); + assert!(written.link_created); + claim.commit().await.unwrap(); + assert!( + !tokio::time::timeout(std::time::Duration::from_secs(5), setter) + .await + .unwrap() + .unwrap() + .unwrap() + ); + assert_eq!( + crate::user::get_agent_channel_policy(&pool, community, &[2; 32]) + .await + .unwrap() + .unwrap() + .1, + Some(vec![4; 32]) + ); +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn ensure_user_insert_and_conflict_do_not_take_admission_lock() { + let (pool, community, _, _) = fixture().await; + let mut claim = pool.begin().await.unwrap(); + lock_admission(&mut claim, community).await.unwrap(); + let (writer_pool, name) = named_pool().await; + for expected in [true, false] { + let mut table_lock = pool.begin().await.unwrap(); + sqlx::query("LOCK TABLE users IN ACCESS EXCLUSIVE MODE") + .execute(&mut *table_lock) + .await + .unwrap(); + let insert_pool = writer_pool.clone(); + let insert = tokio::spawn(async move { + crate::user::ensure_user(&insert_pool, community, &[5; 32]).await + }); + // Inspect the writer while its INSERT is in flight, not after its + // autocommit releases locks. It must reach the table behind admission. + wait_for_lock(&pool, &name, "relation", &insert).await; + let locks: i64 = sqlx::query_scalar( + "SELECT count(*) FROM pg_locks l JOIN pg_stat_activity a ON a.pid=l.pid \ + WHERE a.application_name=$1 AND l.locktype='advisory'", + ) + .bind(&name) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!( + locks, 0, + "user insert must not hold or wait on advisory locks" + ); + table_lock.commit().await.unwrap(); + assert_eq!( + tokio::time::timeout(std::time::Duration::from_secs(5), insert) + .await + .unwrap() + .unwrap() + .unwrap(), + expected + ); + } + claim.rollback().await.unwrap(); +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn linking_banned_owner_revokes_agent_invites_but_timeout_does_not() { + for banned in [true, false] { + let (pool, community, issuer, claimant) = fixture().await; + for key in [[1; 32], [4; 32]] { + crate::user::ensure_user(&pool, community, &key) + .await + .unwrap(); + } + let invite = mint_relay_invite(&pool, community, &issuer, 3600, Some(1)) + .await + .unwrap(); + if banned { + crate::moderation::ban_member(&pool, community, &[4; 32], &[3; 32], None, None) + .await + .unwrap(); + } else { + crate::moderation::timeout_member( + &pool, + community, + &[4; 32], + &[3; 32], + Utc::now() + chrono::Duration::hours(1), + None, + ) + .await + .unwrap(); + } + assert!( + crate::user::set_agent_owner(&pool, community, &[1; 32], &[4; 32]) + .await + .unwrap() + ); + let revoked: bool = + sqlx::query_scalar("SELECT revoked_at IS NOT NULL FROM relay_invites WHERE id=$1") + .bind(invite.invite_id) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(revoked, banned); + if banned { + crate::moderation::unban_member(&pool, community, &[4; 32], &[3; 32]) + .await + .unwrap(); + assert_eq!( + claim_relay_invite_outcome( + &pool, + community, + &hash_v2_code(&invite.code), + &claimant, + None + ) + .await + .unwrap(), + ClaimOutcome::Revoked + ); + } else { + assert_eq!( + claim_relay_invite_outcome( + &pool, + community, + &hash_v2_code(&invite.code), + &claimant, + None + ) + .await + .unwrap(), + ClaimOutcome::IssuerRestricted + ); + crate::moderation::untimeout_member(&pool, community, &[4; 32], &[3; 32]) + .await + .unwrap(); + assert!(matches!( + claim_relay_invite_outcome( + &pool, + community, + &hash_v2_code(&invite.code), + &claimant, + None + ) + .await + .unwrap(), + ClaimOutcome::Joined { .. } + )); + } + } +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn marker_writers_serialize_before_rows_and_commit_revocation_with_marker() { + for timeout in [false, true] { + let (pool, community, issuer, _) = fixture().await; + let invite = mint_relay_invite(&pool, community, &issuer, 3600, None) + .await + .unwrap(); + for key in [[1; 32], [4; 32]] { + crate::user::ensure_user(&pool, community, &key) + .await + .unwrap(); + } + crate::user::set_agent_owner(&pool, community, &[4; 32], &[1; 32]) + .await + .unwrap(); + let agent_invite = mint_relay_invite(&pool, community, &hex::encode([4; 32]), 3600, None) + .await + .unwrap(); + if timeout { + // A timeout must also repair revocation for an existing active ban, + // rather than revoking invites for timeout-only principals. + crate::moderation::ban_member(&pool, community, &[1; 32], &[3; 32], None, None) + .await + .unwrap(); + sqlx::query("UPDATE relay_invites SET revoked_at=NULL WHERE id IN ($1,$2)") + .bind(invite.invite_id) + .bind(agent_invite.invite_id) + .execute(&pool) + .await + .unwrap(); + } + let action = uuid::Uuid::new_v4(); + let lease = uuid::Uuid::new_v4(); + sqlx::query("INSERT INTO relay_admin_actions \ + (id, report_community_id, request_id, actor_pubkey, actor_role, action, state, \ + enforcement_target_pubkey, timeout_secs, timeout_until, action_lease_token, action_lease_expires_at) \ + VALUES ($1,$2,$1,$3,'operator',$4,'enforcing',$5, \ + CASE WHEN $6 THEN 3600 END, CASE WHEN $6 THEN clock_timestamp()+interval '1 hour' END, \ + $7,clock_timestamp()+interval '1 hour')") + .bind(action).bind(community.as_uuid()).bind(vec![3u8;32]) + .bind(if timeout {"timeout"} else {"ban"}).bind(vec![1u8;32]).bind(timeout).bind(lease) + .execute(&pool).await.unwrap(); + let mut admission = pool.begin().await.unwrap(); + lock_admission(&mut admission, community).await.unwrap(); + let (writer_pool, name) = named_pool().await; + let writer = tokio::spawn(async move { + if timeout { + crate::relay_admin_actions::execute_timeout_with_marker( + &writer_pool, + action, + lease, + community, + &[1; 32], + &[3; 32], + Utc::now() + chrono::Duration::hours(1), + None, + ) + .await + } else { + crate::relay_admin_actions::execute_ban_with_marker( + &writer_pool, + action, + lease, + community, + &[1; 32], + &[3; 32], + None, + ) + .await + } + }); + wait_for_lock(&pool, &name, "advisory", &writer).await; + // The action row must still be free while admission is held. This also + // prevents future inversion with action-row locking. + sqlx::query("SELECT id FROM relay_admin_actions WHERE id=$1 FOR UPDATE NOWAIT") + .bind(action) + .fetch_one(&mut *admission) + .await + .unwrap(); + admission.commit().await.unwrap(); + assert!(writer.await.unwrap().unwrap()); + let state: (bool, Option) = sqlx::query_as( + "SELECT i.revoked_at IS NOT NULL, a.step_marker FROM relay_invites i \ + JOIN relay_admin_actions a ON a.id=$2 WHERE i.id=$1", + ) + .bind(invite.invite_id) + .bind(action) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(state, (true, Some("mutation_committed".into()))); + let agent_revoked: bool = + sqlx::query_scalar("SELECT revoked_at IS NOT NULL FROM relay_invites WHERE id=$1") + .bind(agent_invite.invite_id) + .fetch_one(&pool) + .await + .unwrap(); + assert!(agent_revoked, "marker ban also revokes owned-agent invites"); + // Already marked: revocation and domain mutations must roll back too. + sqlx::query("UPDATE relay_invites SET revoked_at=NULL WHERE id IN ($1,$2)") + .bind(invite.invite_id) + .bind(agent_invite.invite_id) + .execute(&pool) + .await + .unwrap(); + let repeated = if timeout { + crate::relay_admin_actions::execute_timeout_with_marker( + &pool, + action, + lease, + community, + &[1; 32], + &[3; 32], + Utc::now() + chrono::Duration::hours(1), + None, + ) + .await + } else { + crate::relay_admin_actions::execute_ban_with_marker( + &pool, action, lease, community, &[1; 32], &[3; 32], None, + ) + .await + } + .unwrap(); + assert!(!repeated); + let revoked: bool = + sqlx::query_scalar("SELECT revoked_at IS NOT NULL FROM relay_invites WHERE id=$1") + .bind(invite.invite_id) + .fetch_one(&pool) + .await + .unwrap(); + assert!(!revoked, "failed marker must not commit revocation"); + } +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn linked_authorization_owner_and_missing_agent_do_not_wait_on_admission() { + let (pool, community, _, _) = fixture().await; + for key in [[2; 32], [4; 32]] { + crate::user::ensure_user(&pool, community, &key) + .await + .unwrap(); + } + assert!( + crate::user::set_agent_owner(&pool, community, &[2; 32], &[4; 32]) + .await + .unwrap() + ); + let mut claim = pool.begin().await.unwrap(); + lock_admission(&mut claim, community).await.unwrap(); + let db = Db::from_pool(pool.clone()); + for owner in [[4; 32], [6; 32]] { + assert!(!tokio::time::timeout( + std::time::Duration::from_secs(5), + db.set_agent_owner_for_authorization(community, &[2; 32], &owner) + ) + .await + .unwrap() + .unwrap()); + } + assert!(matches!( + tokio::time::timeout( + std::time::Duration::from_secs(5), + db.set_agent_owner_for_authorization(community, &[7; 32], &[4; 32]) + ) + .await + .unwrap(), + Err(crate::DbError::NotFound(_)) + )); + claim.rollback().await.unwrap(); +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn unknown_claim_code_is_rejected_without_waiting_on_admission() { + let (pool, community, _, claimant) = fixture().await; + let mut held = pool.begin().await.unwrap(); + lock_admission(&mut held, community).await.unwrap(); + // A forged code must not queue behind admission; if the claim takes the + // lock before looking the code up, this bounded call cannot finish. + let outcome = tokio::time::timeout( + std::time::Duration::from_secs(5), + claim_relay_invite_outcome(&pool, community, &[0xAB; 32], &claimant, None), + ) + .await + .expect("unknown code waited on the admission lock") + .unwrap(); + assert_eq!(outcome, ClaimOutcome::Invalid); + held.rollback().await.unwrap(); +} diff --git a/crates/buzz-db/src/store/relay_members.rs b/crates/buzz-db/src/store/relay_members.rs index 6bc4de1a95..a9f48fd8a1 100644 --- a/crates/buzz-db/src/store/relay_members.rs +++ b/crates/buzz-db/src/store/relay_members.rs @@ -201,38 +201,20 @@ pub async fn claim_relay_membership( policy_version: Option<&str>, relay_keypair: &nostr::Keys, ) -> Result> { - let mut tx = crate::begin_community_write_transaction( + let write = super::relay_invite::claim_relay_membership_with_owner( pool, community, - observability::WriterOperation::Authorization, + pubkey, + role, + policy_version, + None, + relay_keypair, ) .await?; - let inserted = sqlx::query( - "INSERT INTO relay_members (community_id, pubkey, role, added_by) \ - VALUES ($1, $2, $3, 'invite') \ - ON CONFLICT (community_id, pubkey) DO NOTHING", - ) - .bind(community.as_uuid()) - .bind(pubkey) - .bind(role) - .execute(tx.conn()) - .await? - .rows_affected() - > 0; - - if let Some(version) = policy_version { - sqlx::query( - "INSERT INTO join_policy_acceptances (community_id, pubkey, policy_version) \ - VALUES ($1, $2, $3) ON CONFLICT DO NOTHING", - ) - .bind(community.as_uuid()) - .bind(pubkey) - .bind(version) - .execute(tx.conn()) - .await?; - } - - commit_membership_write(tx, inserted, inserted, relay_keypair).await + Ok(MembershipWrite { + outcome: write.outcome.inserted, + snapshot: write.snapshot, + }) } /// Returns whether a member has persisted acceptance evidence for a policy version. @@ -1315,6 +1297,29 @@ impl Db { .await } + /// Claim legacy membership and verified ownership in one admission transaction. + #[datastore_span(name = "claim_relay_membership_with_owner", system = "postgresql")] + pub async fn claim_relay_membership_with_owner( + &self, + community: CommunityId, + pubkey: &str, + role: &str, + policy_version: Option<&str>, + verified_owner: Option<&str>, + relay_keypair: &nostr::Keys, + ) -> Result> { + super::relay_invite::claim_relay_membership_with_owner( + &self.pool, + community, + pubkey, + role, + policy_version, + verified_owner, + relay_keypair, + ) + .await + } + /// Returns whether a member has persisted acceptance evidence for a policy version. #[datastore_span(name = "has_join_policy_acceptance", system = "postgresql")] pub async fn has_join_policy_acceptance( diff --git a/crates/buzz-db/src/store/user.rs b/crates/buzz-db/src/store/user.rs index 2c161e8a02..19f80799d7 100644 --- a/crates/buzz-db/src/store/user.rs +++ b/crates/buzz-db/src/store/user.rs @@ -353,7 +353,29 @@ async fn set_agent_owner_with_operation( owner_pubkey: &[u8], operation: crate::observability::WriterOperation, ) -> Result { + let mut connection = crate::observability::acquire_writer(pool, operation).await?; + // Ownership is first-write-wins and is never cleared by supported writers. + // Already-linked agents are the common authorization path: avoid queuing + // those read-only calls behind a community's claim/restriction transaction. + let recorded: Option>> = sqlx::query_scalar( + "SELECT agent_owner_pubkey FROM users WHERE community_id = $1 AND pubkey = $2", + ) + .bind(community_id.as_uuid()) + .bind(agent_pubkey) + .fetch_optional(&mut *connection) + .await?; + match recorded { + None => { + return Err(crate::error::DbError::NotFound( + "agent pubkey not found in users table".into(), + )) + } + Some(Some(_)) => return Ok(false), + Some(None) => {} + } + drop(connection); let mut tx = crate::begin_community_write_transaction(pool, community_id, operation).await?; + crate::relay_invite::lock_admission(tx.conn(), community_id).await?; // Conditional UPDATE: only set owner if currently NULL. This makes // "first mint wins" atomic — no TOCTOU race between concurrent mints. let result = sqlx::query( @@ -382,6 +404,13 @@ async fn set_agent_owner_with_operation( tx.commit().await?; return Ok(false); } + crate::relay_invite::revoke_restricted_agent_invites( + tx.conn(), + community_id, + agent_pubkey, + owner_pubkey, + ) + .await?; tx.commit().await?; Ok(true) } diff --git a/crates/buzz-relay/src/api/bridge.rs b/crates/buzz-relay/src/api/bridge.rs index d5420096ef..2b655d79ad 100644 --- a/crates/buzz-relay/src/api/bridge.rs +++ b/crates/buzz-relay/src/api/bridge.rs @@ -9524,6 +9524,7 @@ pub(crate) mod postgres_tests { "CREATE SCHEMA {schema}; \ CREATE TABLE {schema}.users (LIKE public.users INCLUDING ALL); \ CREATE TABLE {schema}.community_bans (LIKE public.community_bans INCLUDING ALL); \ + CREATE TABLE {schema}.relay_invites (LIKE public.relay_invites INCLUDING ALL); \ CREATE TABLE {schema}.relay_members (LIKE public.relay_members INCLUDING ALL); \ CREATE TABLE {schema}.join_policy_acceptances \ (LIKE public.join_policy_acceptances INCLUDING ALL); \ @@ -9630,12 +9631,6 @@ pub(crate) mod postgres_tests { "{action}: the HTTP request recorded the owner" ); - sqlx::raw_sql(sqlx::AssertSqlSafe(format!( - "ALTER TABLE {schema}.users RENAME TO users_unavailable" - ))) - .execute(&admin) - .await - .expect("break the owner-to-agent lookup"); let owner_hex = owner.public_key().to_hex(); match action { "ban" => state @@ -9675,6 +9670,12 @@ pub(crate) mod postgres_tests { .expect("removal commits"); } } + sqlx::raw_sql(sqlx::AssertSqlSafe(format!( + "ALTER TABLE {schema}.users RENAME TO users_unavailable" + ))) + .execute(&admin) + .await + .expect("break the owner-to-agent lookup"); let revoked = state .revoke_live_access( &tenant, diff --git a/crates/buzz-relay/src/api/invites.rs b/crates/buzz-relay/src/api/invites.rs index d229b83eb1..e184a35c41 100644 --- a/crates/buzz-relay/src/api/invites.rs +++ b/crates/buzz-relay/src/api/invites.rs @@ -35,6 +35,8 @@ use crate::invite_token; use crate::state::AppState; use super::{api_error, bridge, internal_error}; +mod claim_auth; +use claim_auth::{authenticate, map_claim_error}; /// Fixed-window size for the per-pubkey claim rate limiter. pub(crate) const CLAIM_RATE_WINDOW: Duration = Duration::from_secs(60); @@ -226,40 +228,9 @@ pub async fn accept_policy( Ok(Json(serde_json::json!({ "receipt": receipt }))) } -/// Shared prelude: bind the tenant from the Host header and verify the NIP-98 -/// signature + replay for `path`. -async fn authenticate( - state: &Arc, - headers: &HeaderMap, - path: &str, - body: &[u8], -) -> Result<(buzz_core::TenantContext, nostr::PublicKey), (StatusCode, Json)> { - let raw_host = headers - .get(axum::http::header::HOST) - .and_then(|v| v.to_str().ok()) - .unwrap_or(""); - let tenant = crate::tenant::bind_community(&state.db, raw_host) - .await - .map_err(|_| { - api_error( - StatusCode::NOT_FOUND, - "relay: no community is configured for this host", - ) - })?; - - let url = bridge::nip98_expected_url(&state.config.relay_url, &tenant, path); - let bridge::VerifiedBridgeAuth { - pubkey, - event_id_bytes, - .. - } = bridge::verify_nip98_exempt_invite_claim(headers, "POST", &url, Some(body))?; - bridge::check_nip98_replay(state, &tenant, event_id_bytes).await?; - - Ok((tenant, pubkey)) -} - fn map_mint_error(error: buzz_db::DbError) -> (StatusCode, Json) { match error { + buzz_db::DbError::InviteRestricted => api_error(StatusCode::FORBIDDEN, "invite_restricted"), buzz_db::DbError::InvalidData(message) | buzz_db::DbError::DeletionSafety(message) => { api_error(StatusCode::BAD_REQUEST, &message) } @@ -346,6 +317,26 @@ async fn mint_invite_checked( return e.into_response(); } + if let Some(owner) = super::relay_members::extract_nip_oa_owner( + pubkey.as_bytes(), + super::relay_members::extract_auth_tag_header(&headers), + signed_created_at, + ) { + match super::relay_members::link_agent_owner(&state, &tenant, &pubkey, &owner).await { + super::relay_members::OwnerLink::Linked => {} + super::relay_members::OwnerLink::Conflict => { + return api_error(StatusCode::FORBIDDEN, "invite_owner_mismatch").into_response(); + } + super::relay_members::OwnerLink::Unavailable => { + return api_error( + StatusCode::SERVICE_UNAVAILABLE, + crate::handlers::auth::OWNER_LINK_ERROR, + ) + .into_response(); + } + } + } + match mint_invite_inner(&state, body, tenant, pubkey).await { Ok(json) => json.into_response(), Err(e) => e.into_response(), @@ -433,7 +424,8 @@ pub async fn claim_invite( headers: HeaderMap, body: axum::body::Bytes, ) -> Result, (StatusCode, Json)> { - let (tenant, pubkey) = authenticate(&state, &headers, "/api/invites/claim", &body).await?; + let (tenant, pubkey, verified_owner) = + authenticate(&state, &headers, "/api/invites/claim", &body).await?; if claim_rate_limited(&state, tenant.community(), &pubkey) { return Err(api_error( @@ -446,6 +438,7 @@ pub async fn claim_invite( .map_err(|e| api_error(StatusCode::BAD_REQUEST, &format!("invalid claim JSON: {e}")))?; let claimer_hex = pubkey.to_hex(); + let owner_hex = verified_owner.map(|owner| owner.to_hex()); let key = invite_token::derive_invite_key(&state.relay_keypair); // --- v2 database-backed path --- @@ -470,7 +463,7 @@ pub async fn claim_invite( let token_hash = hash_v2_code(&request.code); let write = state .db - .claim_relay_invite( + .claim_relay_invite_with_owner( tenant.community(), &token_hash, &claimer_hex, @@ -479,13 +472,18 @@ pub async fn claim_invite( .join_policy .as_ref() .map(|policy| policy.version.as_str()), + owner_hex.as_deref(), &state.relay_keypair, ) .await - .map_err(|e| internal_error(&format!("v2 invite claim: {e}")))?; + .map_err(map_claim_error)?; + metrics::counter!("buzz_users_created_total", "community" => tenant.host().to_owned()) + .increment(write.outcome.users_created); + if write.outcome.owner_link_created { + state.disconnect_unowned_agent_clusterwide(&tenant, pubkey.as_bytes()); + } fan_out_nip43_membership_list(&tenant, &state, write.snapshot).await; - - return match write.outcome { + return match write.outcome.outcome { buzz_db::relay_invite::ClaimOutcome::Joined { .. } => { tracing::info!( community = %tenant.community(), @@ -519,6 +517,16 @@ pub async fn claim_invite( buzz_db::relay_invite::ClaimOutcome::Exhausted => { Err(api_error(StatusCode::FORBIDDEN, "invite_exhausted")) } + // Deliberately indistinguishable, even for an issuer timeout whose + // link works again later: a claim never reveals the issuer's + // moderation state. + buzz_db::relay_invite::ClaimOutcome::Revoked + | buzz_db::relay_invite::ClaimOutcome::IssuerRestricted => { + Err(api_error(StatusCode::FORBIDDEN, "invite_invalid")) + } + buzz_db::relay_invite::ClaimOutcome::Restricted => { + Err(api_error(StatusCode::FORBIDDEN, "invite_restricted")) + } buzz_db::relay_invite::ClaimOutcome::Invalid => { Err(api_error(StatusCode::FORBIDDEN, "invite_invalid")) } @@ -526,16 +534,18 @@ pub async fn claim_invite( } // --- v1 HMAC path (stateless tokens, drain window) --- - let payload = invite_token::verify_invite(&key, tenant.community(), &request.code).map_err( - |e| match e { - // Expired is post-MAC: revealing it helps the UX without helping a forger. - invite_token::InviteError::Expired => { - api_error(StatusCode::FORBIDDEN, "invite_expired") - } - // Everything else stays coarse so the endpoint is a poor oracle. - _ => api_error(StatusCode::FORBIDDEN, "invite_invalid"), - }, - )?; + let payload = invite_token::verify_invite_with_cutoff( + &key, + tenant.community(), + &request.code, + state.config.invite_v1_invalid_after, + ) + .map_err(|e| match e { + // Expired is post-MAC: revealing it helps the UX without helping a forger. + invite_token::InviteError::Expired => api_error(StatusCode::FORBIDDEN, "invite_expired"), + // Everything else stays coarse so the endpoint is a poor oracle. + _ => api_error(StatusCode::FORBIDDEN, "invite_invalid"), + })?; if let Some(policy) = &state.config.join_policy { let receipt = request @@ -548,7 +558,7 @@ pub async fn claim_invite( let write = state .db - .claim_relay_membership( + .claim_relay_membership_with_owner( tenant.community(), &claimer_hex, &payload.r, @@ -557,11 +567,17 @@ pub async fn claim_invite( .join_policy .as_ref() .map(|policy| policy.version.as_str()), + owner_hex.as_deref(), &state.relay_keypair, ) .await - .map_err(|e| internal_error(&format!("invite claim insert: {e}")))?; - let was_inserted = write.outcome; + .map_err(map_claim_error)?; + metrics::counter!("buzz_users_created_total", "community" => tenant.host().to_owned()) + .increment(write.outcome.users_created); + if write.outcome.owner_link_created { + state.disconnect_unowned_agent_clusterwide(&tenant, pubkey.as_bytes()); + } + let was_inserted = write.outcome.inserted; fan_out_nip43_membership_list(&tenant, &state, write.snapshot).await; if was_inserted { @@ -611,6 +627,8 @@ fn claim_key_rate_limited( #[cfg(test)] mod postgres_tests { + mod owner_admission_tests; + mod restriction_tests; use std::sync::Arc; use std::time::Duration; @@ -1946,11 +1964,7 @@ mod postgres_tests { const NIP_FI_TEST_ISSUER: &str = "https://issuer.example"; const NIP_FI_TEST_AUDIENCE: &str = "https://relay.example"; const NIP_FI_TEST_KID: &str = "test-key-1"; - const NIP_FI_TEST_EC_PKCS8_PEM: &str = "-----BEGIN PRIVATE KEY-----\n\ - MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgcnxDM4EiirH9dHUE\n\ - WZc759TX4s5PAn8kO5ovXSnGxCWhRANCAARFb6ZnsfkqOOXyEhj3KBQphGKF4vTa\n\ - zhebbavbZ1ZoklqkF1cGg+jTO7rONAVEzXvXUWtV6CdDV+rybiVmFP2w\n\ - -----END PRIVATE KEY-----\n"; + const NIP_FI_TEST_EC_PKCS8_PEM: &str = "-----BEGIN PRIVATE KEY-----\nMIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgcnxDM4EiirH9dHUE\nWZc759TX4s5PAn8kO5ovXSnGxCWhRANCAARFb6ZnsfkqOOXyEhj3KBQphGKF4vTa\nzhebbavbZ1ZoklqkF1cGg+jTO7rONAVEzXvXUWtV6CdDV+rybiVmFP2w\n-----END PRIVATE KEY-----\n"; // sadscan:disable np.pem.1 kingfisher.privkey.2 -- public ES256 test fixture /// Clone `base` into an Enforce-mode state. With `with_verifier`, a real /// ES256 verifier over the static test key is injected so a signed diff --git a/crates/buzz-relay/src/api/invites/claim_auth.rs b/crates/buzz-relay/src/api/invites/claim_auth.rs new file mode 100644 index 0000000000..756b2ba848 --- /dev/null +++ b/crates/buzz-relay/src/api/invites/claim_auth.rs @@ -0,0 +1,58 @@ +use super::{api_error, bridge, internal_error, AppState}; +use axum::{ + http::{HeaderMap, StatusCode}, + response::Json, +}; +use serde_json::Value; +use std::sync::Arc; + +/// Verify tenant-scoped NIP-98 and optional NIP-OA provenance without user writes. +pub(super) async fn authenticate( + state: &Arc, + headers: &HeaderMap, + path: &str, + body: &[u8], +) -> Result< + ( + buzz_core::TenantContext, + nostr::PublicKey, + Option, + ), + (StatusCode, Json), +> { + let raw_host = headers + .get(axum::http::header::HOST) + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + let tenant = crate::tenant::bind_community(&state.db, raw_host) + .await + .map_err(|_| { + api_error( + StatusCode::NOT_FOUND, + "relay: no community is configured for this host", + ) + })?; + let url = bridge::nip98_expected_url(&state.config.relay_url, &tenant, path); + let bridge::VerifiedBridgeAuth { + pubkey, + event_id_bytes, + signed_created_at, + } = bridge::verify_nip98_exempt_invite_claim(headers, "POST", &url, Some(body))?; + bridge::check_nip98_replay(state, &tenant, event_id_bytes).await?; + let owner = super::super::relay_members::extract_nip_oa_owner( + pubkey.as_bytes(), + super::super::relay_members::extract_auth_tag_header(headers), + signed_created_at, + ); + Ok((tenant, pubkey, owner)) +} + +pub(super) fn map_claim_error(error: buzz_db::DbError) -> (StatusCode, Json) { + match error { + buzz_db::DbError::InviteRestricted => api_error(StatusCode::FORBIDDEN, "invite_restricted"), + buzz_db::DbError::InviteOwnerMismatch => { + api_error(StatusCode::FORBIDDEN, "invite_owner_mismatch") + } + e => internal_error(&format!("invite claim: {e}")), + } +} diff --git a/crates/buzz-relay/src/api/invites/postgres_tests/owner_admission_tests.rs b/crates/buzz-relay/src/api/invites/postgres_tests/owner_admission_tests.rs new file mode 100644 index 0000000000..ee5a490d4f --- /dev/null +++ b/crates/buzz-relay/src/api/invites/postgres_tests/owner_admission_tests.rs @@ -0,0 +1,243 @@ +mod postgres_tests { + use super::super::restriction_tests::postgres_tests::{fixture, post_with_owner_tag}; + use super::super::*; + + async fn users(state: &AppState, community: buzz_core::CommunityId) -> i64 { + sqlx::query_scalar("SELECT count(*) FROM users WHERE community_id=$1") + .bind(community.as_uuid()) + .fetch_one(state.db.pool()) + .await + .unwrap() + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn mint_rejects_a_different_verified_owner_without_retrying_or_rebinding() { + let (state, host, community, agent) = fixture().await; + let recorded_owner = Keys::generate(); + for key in [&agent, &recorded_owner] { + state + .db + .ensure_user_for_authorization(community, key.public_key().as_bytes()) + .await + .unwrap(); + } + assert!(state + .db + .set_agent_owner_for_authorization( + community, + agent.public_key().as_bytes(), + recorded_owner.public_key().as_bytes(), + ) + .await + .unwrap()); + let other_owner = Keys::generate(); + let tag = + buzz_sdk::nip_oa::compute_auth_tag(&other_owner, &agent.public_key(), "").unwrap(); + let response = post_with_owner_tag( + state.clone(), + &host, + "/api/invites", + &agent, + "{}".into(), + &tag, + ) + .await; + assert_eq!(response.status(), StatusCode::FORBIDDEN); + assert_eq!(read_json(response).await["error"], "invite_owner_mismatch"); + assert!(state + .db + .is_agent_owner( + community, + agent.public_key().as_bytes(), + recorded_owner.public_key().as_bytes(), + ) + .await + .unwrap()); + let count: i64 = + sqlx::query_scalar("SELECT count(*) FROM relay_invites WHERE community_id=$1") + .bind(community.as_uuid()) + .fetch_one(state.db.pool()) + .await + .unwrap(); + assert_eq!( + count, 0, + "mismatched credentials must not mint an invitation" + ); + + // Healthy storage and the recorded owner still admit a signed mint. + let tag = + buzz_sdk::nip_oa::compute_auth_tag(&recorded_owner, &agent.public_key(), "").unwrap(); + assert_eq!( + post_with_owner_tag(state, &host, "/api/invites", &agent, "{}".into(), &tag) + .await + .status(), + StatusCode::OK + ); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn invalid_signed_claims_do_not_materialize_fresh_owner_identities() { + let (state, host, community, _issuer) = fixture().await; + let before = users(&state, community).await; + let unknown_v2 = buzz_core::invite::encode_v2_code(&[42; 32]); + for code in ["garbage", "v2.bad", &unknown_v2] { + let owner = Keys::generate(); + let agent = Keys::generate(); + let tag = buzz_sdk::nip_oa::compute_auth_tag(&owner, &agent.public_key(), "").unwrap(); + let response = post_with_owner_tag( + state.clone(), + &host, + "/api/invites/claim", + &agent, + serde_json::json!({"code":code}).to_string(), + &tag, + ) + .await; + assert_eq!(response.status(), StatusCode::FORBIDDEN); + assert_eq!(users(&state, community).await, before); + assert!(!state + .db + .is_relay_member(community, &agent.public_key().to_hex()) + .await + .unwrap()); + } + let owner = Keys::generate(); + let agent = Keys::generate(); + let tag = buzz_sdk::nip_oa::compute_auth_tag(&owner, &agent.public_key(), "").unwrap(); + for attempt in 0..=CLAIM_RATE_LIMIT { + let response = post_with_owner_tag( + state.clone(), + &host, + "/api/invites/claim", + &agent, + serde_json::json!({"code":"garbage"}).to_string(), + &tag, + ) + .await; + assert_eq!( + response.status(), + if attempt == CLAIM_RATE_LIMIT { + StatusCode::TOO_MANY_REQUESTS + } else { + StatusCode::FORBIDDEN + } + ); + assert_eq!(users(&state, community).await, before); + } + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn verified_owner_is_committed_only_with_successful_v1_and_v2_claims() { + let (state, host, community, issuer) = fixture().await; + let v2 = mint_code( + state.clone(), + &host, + &issuer, + serde_json::json!({"max_uses":1}), + ) + .await; + let key = derive_invite_key(&state.relay_keypair); + let (v1, _) = crate::invite_token::mint_invite(&key, community, 3600); + for code in [v1, v2] { + let owner = Keys::generate(); + let agent = Keys::generate(); + let tag = buzz_sdk::nip_oa::compute_auth_tag(&owner, &agent.public_key(), "").unwrap(); + let response = post_with_owner_tag( + state.clone(), + &host, + "/api/invites/claim", + &agent, + serde_json::json!({"code":code}).to_string(), + &tag, + ) + .await; + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(read_json(response).await["status"], "joined"); + assert!(state + .db + .is_agent_owner( + community, + agent.public_key().as_bytes(), + owner.public_key().as_bytes() + ) + .await + .unwrap()); + let before = users(&state, community).await; + let response = post_with_owner_tag( + state.clone(), + &host, + "/api/invites/claim", + &agent, + serde_json::json!({"code":code}).to_string(), + &tag, + ) + .await; + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(read_json(response).await["status"], "already_member"); + assert_eq!(users(&state, community).await, before); + let impostor_owner = Keys::generate(); + let mismatched = + buzz_sdk::nip_oa::compute_auth_tag(&impostor_owner, &agent.public_key(), "") + .unwrap(); + let response = post_with_owner_tag( + state.clone(), + &host, + "/api/invites/claim", + &agent, + serde_json::json!({"code":code}).to_string(), + &mismatched, + ) + .await; + assert_eq!(response.status(), StatusCode::FORBIDDEN); + assert_eq!(read_json(response).await["error"], "invite_owner_mismatch"); + assert_eq!(users(&state, community).await, before); + } + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn banned_verified_owner_creates_no_users_in_either_claim_format() { + let (state, host, community, issuer) = fixture().await; + let v2 = mint_code( + state.clone(), + &host, + &issuer, + serde_json::json!({"max_uses":1}), + ) + .await; + let key = derive_invite_key(&state.relay_keypair); + let (v1, _) = crate::invite_token::mint_invite(&key, community, 3600); + let owner = Keys::generate(); + state + .db + .ban_community_member( + community, + owner.public_key().as_bytes(), + issuer.public_key().as_bytes(), + None, + None, + ) + .await + .unwrap(); + let before = users(&state, community).await; + for code in [v1, v2] { + let agent = Keys::generate(); + let tag = buzz_sdk::nip_oa::compute_auth_tag(&owner, &agent.public_key(), "").unwrap(); + let response = post_with_owner_tag( + state.clone(), + &host, + "/api/invites/claim", + &agent, + serde_json::json!({"code":code}).to_string(), + &tag, + ) + .await; + assert_eq!(response.status(), StatusCode::FORBIDDEN); + assert_eq!(read_json(response).await["error"], "invite_restricted"); + assert_eq!(users(&state, community).await, before); + } + } +} diff --git a/crates/buzz-relay/src/api/invites/postgres_tests/restriction_tests.rs b/crates/buzz-relay/src/api/invites/postgres_tests/restriction_tests.rs new file mode 100644 index 0000000000..029dbbadfe --- /dev/null +++ b/crates/buzz-relay/src/api/invites/postgres_tests/restriction_tests.rs @@ -0,0 +1,387 @@ +pub(super) mod postgres_tests { + use super::super::*; + + pub(in crate::api::invites::postgres_tests) async fn fixture( + ) -> (Arc, String, buzz_core::CommunityId, Keys) { + let host = format!("restricted-invite-{}.example", Uuid::new_v4().simple()); + let state = invite_test_state(&host) + .await + .expect("isolated PostgreSQL state"); + let community = state + .db + .ensure_configured_community(&host) + .await + .unwrap() + .id; + let owner = Keys::generate(); + state + .db + .add_relay_member( + community, + &owner.public_key().to_hex(), + "owner", + None, + &state.relay_keypair, + ) + .await + .map(|write| write.outcome) + .unwrap(); + (state, host, community, owner) + } + + pub(in crate::api::invites::postgres_tests) async fn post_with_owner_tag( + state: Arc, + host: &str, + path: &str, + agent: &Keys, + body: String, + auth_tag: &str, + ) -> axum::response::Response { + let auth = nip98_auth_header(agent, &format!("https://{host}{path}"), body.as_bytes()); + build_router(state) + .oneshot( + Request::builder() + .method("POST") + .uri(path) + .header(header::HOST, host) + .header(header::AUTHORIZATION, auth) + .header("x-auth-tag", auth_tag) + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from(body)) + .unwrap(), + ) + .await + .unwrap() + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn signed_nonmember_claims_reject_restrictions_without_consuming_invite() { + let (state, host, community, issuer) = fixture().await; + let code = mint_code( + state.clone(), + &host, + &issuer, + serde_json::json!({"max_uses":1}), + ) + .await; + let claimant = Keys::generate(); + state + .db + .ban_community_member( + community, + claimant.public_key().as_bytes(), + issuer.public_key().as_bytes(), + None, + None, + ) + .await + .unwrap(); + let key = derive_invite_key(&state.relay_keypair); + let (legacy, _) = crate::invite_token::mint_invite(&key, community, 3600); + for bearer in [&code, &legacy] { + let response = post_json( + state.clone(), + &host, + "/api/invites/claim", + &claimant, + serde_json::json!({"code":bearer}).to_string(), + ) + .await; + assert_eq!(response.status(), StatusCode::FORBIDDEN); + assert_eq!(read_json(response).await["error"], "invite_restricted"); + } + assert!(!state + .db + .is_relay_member(community, &claimant.public_key().to_hex()) + .await + .unwrap()); + let unrestricted = Keys::generate(); + assert_eq!( + post_json( + state, + &host, + "/api/invites/claim", + &unrestricted, + serde_json::json!({"code":code}).to_string() + ) + .await + .status(), + StatusCode::OK + ); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn verified_owner_blocks_first_agent_claim_but_forged_owner_does_not_bind() { + let (state, host, community, issuer) = fixture().await; + let code = mint_code(state.clone(), &host, &issuer, serde_json::json!({})).await; + let owner = Keys::generate(); + state + .db + .ban_community_member( + community, + owner.public_key().as_bytes(), + issuer.public_key().as_bytes(), + None, + None, + ) + .await + .unwrap(); + let agent = Keys::generate(); + let tag = buzz_sdk::nip_oa::compute_auth_tag(&owner, &agent.public_key(), "").unwrap(); + let response = post_with_owner_tag( + state.clone(), + &host, + "/api/invites/claim", + &agent, + serde_json::json!({"code":code}).to_string(), + &tag, + ) + .await; + assert_eq!(response.status(), StatusCode::FORBIDDEN); + assert_eq!(read_json(response).await["error"], "invite_restricted"); + assert!(!state + .db + .is_relay_member(community, &agent.public_key().to_hex()) + .await + .unwrap()); + for key in [&agent, &owner] { + assert!(state + .db + .get_user(community, key.public_key().as_bytes()) + .await + .unwrap() + .is_none()); + } + state + .db + .unban_community_member( + community, + owner.public_key().as_bytes(), + issuer.public_key().as_bytes(), + ) + .await + .unwrap(); + let response = post_with_owner_tag( + state.clone(), + &host, + "/api/invites/claim", + &agent, + serde_json::json!({"code":code}).to_string(), + &tag, + ) + .await; + assert_eq!(response.status(), StatusCode::OK); + assert!(state + .db + .is_agent_owner( + community, + agent.public_key().as_bytes(), + owner.public_key().as_bytes() + ) + .await + .unwrap()); + let other_agent = Keys::generate(); + // A credential signed for another key cannot bind this claimant's owner. + let response = post_with_owner_tag( + state.clone(), + &host, + "/api/invites/claim", + &other_agent, + serde_json::json!({"code":code}).to_string(), + &tag, + ) + .await; + assert_eq!(response.status(), StatusCode::OK); + assert!(!state + .db + .is_agent_owner( + community, + other_agent.public_key().as_bytes(), + owner.public_key().as_bytes() + ) + .await + .unwrap()); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn committed_issuer_ban_survives_unban_through_http() { + let (state, host, community, issuer) = fixture().await; + let code = mint_code(state.clone(), &host, &issuer, serde_json::json!({})).await; + state + .db + .ban_community_member( + community, + issuer.public_key().as_bytes(), + issuer.public_key().as_bytes(), + None, + None, + ) + .await + .unwrap(); + state + .db + .unban_community_member( + community, + issuer.public_key().as_bytes(), + issuer.public_key().as_bytes(), + ) + .await + .unwrap(); + let response = post_json( + state.clone(), + &host, + "/api/invites/claim", + &Keys::generate(), + serde_json::json!({"code":code}).to_string(), + ) + .await; + assert_eq!(response.status(), StatusCode::FORBIDDEN); + assert_eq!(read_json(response).await["error"], "invite_invalid"); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn cutoff_rejects_only_v1_and_restriction_or_owner_lookup_failure_never_admits() { + let Some(identity) = [ + "NEXTEST_RUN_ID", + "NEXTEST_BINARY_ID", + "NEXTEST_TEST_NAME", + "NEXTEST_ATTEMPT_ID", + ] + .iter() + .map(|name| std::env::var(name).ok()) + .collect::>>() else { + eprintln!("skipping destructive fixture outside postgres-isolation wrapper"); + return; + }; + let url = std::env::var("BUZZ_TEST_DATABASE_URL").unwrap_or_default(); + assert!( + wrapper_owns_database(&url, &identity.join(":")), + "refusing destructive DDL on a shared database" + ); + let (state, host, community, issuer) = fixture().await; + let code = mint_code(state.clone(), &host, &issuer, serde_json::json!({})).await; + let key = derive_invite_key(&state.relay_keypair); + let (legacy, _) = crate::invite_token::mint_invite(&key, community, 3600); + let Ok(mut state) = Arc::try_unwrap(state) else { + panic!("exclusive fixture state"); + }; + Arc::make_mut(&mut state.config).invite_v1_invalid_after = Some(0); + let state = Arc::new(state); + let response = post_json( + state.clone(), + &host, + "/api/invites/claim", + &Keys::generate(), + serde_json::json!({"code":legacy}).to_string(), + ) + .await; + assert_eq!(response.status(), StatusCode::FORBIDDEN); + assert_eq!(read_json(response).await["error"], "invite_expired"); + assert_eq!( + post_json( + state.clone(), + &host, + "/api/invites/claim", + &Keys::generate(), + serde_json::json!({"code":code}).to_string() + ) + .await + .status(), + StatusCode::OK + ); + // This test owns its per-process database; never alters a shared fixture. + sqlx::query("ALTER TABLE community_bans RENAME TO unavailable_restrictions") + .execute(state.db.pool()) + .await + .unwrap(); + let claimant = Keys::generate(); + let response = post_json( + state.clone(), + &host, + "/api/invites/claim", + &claimant, + serde_json::json!({"code":code}).to_string(), + ) + .await; + let status = response.status(); + let membership = state + .db + .is_relay_member(community, &claimant.public_key().to_hex()) + .await; + sqlx::query("ALTER TABLE unavailable_restrictions RENAME TO community_bans") + .execute(state.db.pool()) + .await + .unwrap(); + assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR); + assert!(!membership.unwrap()); + + // Fail specifically inside owner materialization, after membership and + // ban reads succeed. This fixture is wrapper-owned, as checked above. + let owner = Keys::generate(); + let tag = buzz_sdk::nip_oa::compute_auth_tag(&owner, &issuer.public_key(), "").unwrap(); + let before: i64 = sqlx::query_scalar("SELECT count(*) FROM relay_invites") + .fetch_one(state.db.pool()) + .await + .unwrap(); + // Keep restriction and membership reads healthy, but reject the new + // owner's insert in the production materialization helper. + sqlx::query( + "ALTER TABLE users ADD CONSTRAINT test_owner_write_failure CHECK (false) NOT VALID", + ) + .execute(state.db.pool()) + .await + .unwrap(); + let response = post_with_owner_tag( + state.clone(), + &host, + "/api/invites", + &issuer, + "{}".into(), + &tag, + ) + .await; + sqlx::query("ALTER TABLE users DROP CONSTRAINT test_owner_write_failure") + .execute(state.db.pool()) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE); + assert_eq!( + read_json(response).await["error"], + crate::handlers::auth::OWNER_LINK_ERROR + ); + let after: i64 = sqlx::query_scalar("SELECT count(*) FROM relay_invites") + .fetch_one(state.db.pool()) + .await + .unwrap(); + assert_eq!( + after, before, + "a storage failure must not mint an invitation" + ); + } + + fn wrapper_owns_database(url: &str, identity: &str) -> bool { + let hash = hex::encode(Sha256::digest(identity.as_bytes())); + url.rsplit('/').next() == Some(format!("buzz_nt_{}", &hash[..24]).as_str()) + } + + #[test] + fn destructive_fixture_rejects_shared_database_urls() { + let identity = "run:binary:test:attempt"; + let hash = hex::encode(Sha256::digest(identity.as_bytes())); + assert!(wrapper_owns_database( + &format!("postgres://localhost/buzz_nt_{}", &hash[..24]), + identity + )); + for url in [ + "postgres://localhost/buzz", + "postgres://localhost/buzz_nt_other", + "", + ] { + assert!(!wrapper_owns_database(url, identity)); + } + } +} diff --git a/crates/buzz-relay/src/api/mesh_demo.rs b/crates/buzz-relay/src/api/mesh_demo.rs index 8649b97671..77dc9aaed8 100644 --- a/crates/buzz-relay/src/api/mesh_demo.rs +++ b/crates/buzz-relay/src/api/mesh_demo.rs @@ -336,8 +336,9 @@ mod tests { }, ) .await; - assert_eq!(resp.status(), StatusCode::OK); + let status = resp.status(); let body = body_json(resp).await; + assert_eq!(status, StatusCode::OK, "forwarded response: {body}"); assert_eq!(body["outcome"], "forwarded"); assert_eq!(body["echoed_payload"], "mesh echo evidence"); owner_task.abort(); diff --git a/crates/buzz-relay/src/api/mod.rs b/crates/buzz-relay/src/api/mod.rs index 50946748bb..19c9220e18 100644 --- a/crates/buzz-relay/src/api/mod.rs +++ b/crates/buzz-relay/src/api/mod.rs @@ -510,6 +510,9 @@ pub mod relay_members { /// Both principals are ensured first because `agent_owner_pubkey` has a /// community-scoped foreign key. The mapping is first-write-wins; an /// existing mapping is accepted only when it names the same owner. + /// Returns `true` only when the link is recorded; an owner conflict and a + /// storage failure both return `false`. Callers that must tell denial + /// from unavailability use `link_agent_owner` directly. /// /// A mapping already confirmed in `observer_owner_cache` returns early: /// `agent_owner_pubkey` is set once and never cleared, so the writes diff --git a/crates/buzz-relay/src/config.rs b/crates/buzz-relay/src/config.rs index c0e3014409..79c439d898 100644 --- a/crates/buzz-relay/src/config.rs +++ b/crates/buzz-relay/src/config.rs @@ -379,6 +379,9 @@ pub struct Config { /// documents or age attestation are configured. pub join_policy: Option, + /// Absolute UTC Unix-second cutoff for legacy v1 invites; unset drains naturally. + pub invite_v1_invalid_after: Option, + /// Deployment-admin API and SPA configuration. Absent means the surface is disabled. pub admin: Option, @@ -415,6 +418,28 @@ fn parse_bind_addr(raw: &str) -> Result { .map_err(|e| ConfigError::InvalidBindAddr(e.to_string())) } +fn parse_invite_v1_cutoff(raw: Option<&str>) -> Result, ConfigError> { + raw.map(|value| { + chrono::DateTime::parse_from_rfc3339(value) + .ok() + .filter(|time| time.offset().local_minus_utc() == 0 && time.timestamp_subsec_nanos() == 0) + .and_then(|time| u64::try_from(time.timestamp()).ok()) + .ok_or_else(|| ConfigError::InvalidValue( + "BUZZ_INVITE_V1_INVALID_AFTER must be a UTC RFC3339 timestamp with whole seconds".into(), + )) + }).transpose() +} + +fn invite_v1_cutoff_from_env() -> Result, ConfigError> { + match std::env::var("BUZZ_INVITE_V1_INVALID_AFTER") { + Ok(raw) => parse_invite_v1_cutoff(Some(&raw)), + Err(std::env::VarError::NotPresent) => Ok(None), + Err(std::env::VarError::NotUnicode(_)) => Err(ConfigError::InvalidValue( + "BUZZ_INVITE_V1_INVALID_AFTER must be valid Unicode".into(), + )), + } +} + fn positive_u64_from_env(name: &str, default: u64) -> Result { match std::env::var(name) { Ok(raw) => raw @@ -1457,6 +1482,7 @@ impl Config { push_gateway_timeout, operator_listener_timeout, join_policy, + invite_v1_invalid_after: invite_v1_cutoff_from_env()?, admin, web_dir, serve_git_web_gui, @@ -2990,3 +3016,27 @@ mod tests { ); } } + +#[cfg(test)] +mod invite_cutoff_tests { + use super::*; + + #[test] + fn cutoff_configuration_is_optional_and_strict() { + assert_eq!(parse_invite_v1_cutoff(None).unwrap(), None); + assert_eq!( + parse_invite_v1_cutoff(Some("1970-01-01T00:01:40Z")).unwrap(), + Some(100) + ); + for raw in [ + "", + "tomorrow", + "100", + "2026-10-10T00:00:00+01:00", + "1969-12-31T23:59:59Z", + "2026-10-10T00:00:00.1Z", + ] { + assert!(parse_invite_v1_cutoff(Some(raw)).is_err(), "{raw}"); + } + } +} diff --git a/crates/buzz-relay/src/handlers/ingest.rs b/crates/buzz-relay/src/handlers/ingest.rs index c2ab9c3ba5..9c6d6f206e 100644 --- a/crates/buzz-relay/src/handlers/ingest.rs +++ b/crates/buzz-relay/src/handlers/ingest.rs @@ -7751,9 +7751,8 @@ mod postgres_tests { use sqlx::postgres::PgConnectOptions; use tokio_util::sync::CancellationToken; - // A schema with bans but no `users` table: the ban commits, and only - // the owned-agent lookup fails. It carries `communities` and the - // deletion lock key so the ban passes community write admission. + // A supported ban writer commits before the users table is taken away: + // only the subsequent owned-agent lookup fails. let db_url = crate::test_support::database_url(); let admin = sqlx::PgPool::connect(&db_url) .await @@ -7761,6 +7760,8 @@ mod postgres_tests { let schema = format!("revoke_owner_{}", Uuid::new_v4().simple()); sqlx::raw_sql(sqlx::AssertSqlSafe(format!( "CREATE SCHEMA {schema}; \ + CREATE TABLE {schema}.users (LIKE public.users INCLUDING ALL); \ + CREATE TABLE {schema}.relay_invites (LIKE public.relay_invites INCLUDING ALL); \ CREATE TABLE {schema}.community_bans (LIKE public.community_bans INCLUDING ALL); \ CREATE TABLE {schema}.communities (LIKE public.communities INCLUDING ALL); \ CREATE FUNCTION {schema}.community_deletion_lock_key(target UUID) RETURNS BIGINT \ @@ -7842,6 +7843,10 @@ mod postgres_tests { ) .await .expect("ban commits"); + sqlx::raw_sql(sqlx::AssertSqlSafe(format!("DROP TABLE {schema}.users"))) + .execute(&admin) + .await + .expect("break only the post-commit owner lookup"); let revoked = state .revoke_live_access( &tenant, diff --git a/crates/buzz-relay/src/invite_token.rs b/crates/buzz-relay/src/invite_token.rs index e271f40f0b..9886b78153 100644 --- a/crates/buzz-relay/src/invite_token.rs +++ b/crates/buzz-relay/src/invite_token.rs @@ -157,6 +157,27 @@ pub fn verify_invite( key: &[u8; 32], community: CommunityId, code: &str, +) -> Result { + verify_invite_at(key, community, code, now_unix(), None) +} + +/// Verify a legacy bearer with the configured absolute invalid-after time. +/// v2 admission never calls this verifier. +pub fn verify_invite_with_cutoff( + key: &[u8; 32], + community: CommunityId, + code: &str, + invalid_after: Option, +) -> Result { + verify_invite_at(key, community, code, now_unix(), invalid_after) +} + +fn verify_invite_at( + key: &[u8; 32], + community: CommunityId, + code: &str, + now: u64, + invalid_after: Option, ) -> Result { if code.len() > MAX_CODE_LEN { return Err(InviteError::Malformed); @@ -178,7 +199,7 @@ pub fn verify_invite( let payload: InvitePayload = serde_json::from_slice(&payload_bytes).map_err(|_| InviteError::Malformed)?; - if payload.e < now_unix() { + if payload.e < now || invalid_after.is_some_and(|cutoff| now >= cutoff) { return Err(InviteError::Expired); } if payload.c != community.as_uuid().to_string() { @@ -404,3 +425,41 @@ mod policy_acceptance_tests { assert!(verify_policy_acceptance(&[8_u8; 32], &receipt, "invite-a", "v1").is_err()); } } + +#[cfg(test)] +mod cutoff_tests { + use super::*; + + #[test] + fn cutoff_never_extends_expiry_and_rejects_at_boundary() { + let key = [42; 32]; + let community = CommunityId::from_uuid(uuid::Uuid::new_v4()); + let payload = InvitePayload { + c: community.to_string(), + r: "member".into(), + e: 200, + n: "fixture".into(), + }; + let bytes = serde_json::to_vec(&payload).unwrap(); + let code = format!( + "{}.{}", + URL_SAFE_NO_PAD.encode(&bytes), + URL_SAFE_NO_PAD.encode(sign_payload(&key, &bytes)) + ); + for (now, cutoff, allowed) in [ + (100, None, true), + (199, None, true), + (201, None, false), + (99, Some(100), true), + (100, Some(100), false), + (101, Some(100), false), + (201, Some(300), false), + ] { + assert_eq!( + verify_invite_at(&key, community, &code, now, cutoff).is_ok(), + allowed, + "now={now} cutoff={cutoff:?}" + ); + } + } +} diff --git a/crates/buzz-relay/src/mesh_boot.rs b/crates/buzz-relay/src/mesh_boot.rs index 28fe158c56..5823bfdfb2 100644 --- a/crates/buzz-relay/src/mesh_boot.rs +++ b/crates/buzz-relay/src/mesh_boot.rs @@ -422,24 +422,39 @@ pub(crate) async fn run_demo_echo( tracing::info!(%session_id, %peer, "mesh demo echo: session open"); let mut drain_tick = tokio::time::interval(std::time::Duration::from_millis(100)); loop { - let frame = tokio::select! { - _ = drain_tick.tick() => { - if shutting_down.load(Ordering::Relaxed) { - if let Some(community_id) = stream.community_id() { - if let Err(e) = stream.send_goodbye(community_id, GoodbyeReason::Draining).await { - tracing::warn!(%session_id, "mesh demo echo: draining goodbye failed: {e}"); - } else { - tracing::info!(%session_id, "mesh demo echo: sent draining goodbye"); + let frame = { + // A receive can consume bytes before waiting on Redis validation. + // Keep it alive across housekeeping ticks; only shutdown may cancel it. + let recv = stream.recv_validated(&directory); + tokio::pin!(recv); + loop { + tokio::select! { + _ = drain_tick.tick() => { + if shutting_down.load(Ordering::Relaxed) { + break None; } - } else { - let _ = stream.finish(); - tracing::info!(%session_id, "mesh demo echo: drain before community latch — closing"); } - return; + frame = &mut recv => break Some(frame), } - continue; } - frame = stream.recv_validated(&directory) => frame, + }; + // The pending receive and its mutable stream borrow have ended before + // the drain response or next echo uses the stream. + let Some(frame) = frame else { + if let Some(community_id) = stream.community_id() { + if let Err(e) = stream + .send_goodbye(community_id, GoodbyeReason::Draining) + .await + { + tracing::warn!(%session_id, "mesh demo echo: draining goodbye failed: {e}"); + } else { + tracing::info!(%session_id, "mesh demo echo: sent draining goodbye"); + } + } else { + let _ = stream.finish(); + tracing::info!(%session_id, "mesh demo echo: drain before community latch — closing"); + } + return; }; match frame { Ok(Some(ReliableFrame::Data(payload))) => { @@ -772,6 +787,72 @@ mod tests { MeshStream::new(Box::new(StubSend), Box::new(StubRecv)) } + struct PausedRecv { + started: Option>, + resume: Option>, + calls: Arc, + } + + impl StreamRecvHalf for PausedRecv { + fn recv_frame(&mut self) -> BoxFuture<'_, Result, MeshError>> { + self.calls.fetch_add(1, Ordering::Relaxed); + let started = self.started.take(); + let resume = self.resume.take(); + Box::pin(async move { + if let Some(started) = started { + let _ = started.send(()); + } + if let Some(resume) = resume { + resume.await.expect("test resumes the in-flight receive"); + } + Ok(None) + }) + } + } + + /// Housekeeping must retain a receive that already consumed transport state. + /// Returning to recv_frame after a tick loses that state and closes the stream. + #[tokio::test(start_paused = true)] + async fn demo_echo_retains_pending_receive_across_housekeeping_ticks() { + let pool = deadpool_redis::Config::from_url("redis://127.0.0.1:1") + .create_pool(Some(deadpool_redis::Runtime::Tokio1)) + .expect("test pool"); + let (started_tx, started_rx) = tokio::sync::oneshot::channel(); + let (resume_tx, resume_rx) = tokio::sync::oneshot::channel(); + let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let fenced = FencedHeader { + session_id: uuid::Uuid::new_v4(), + generation: 1, + owner_runtime_id: rid(1), + }; + let stream = MeshStream::new( + Box::new(StubSend), + Box::new(PausedRecv { + started: Some(started_tx), + resume: Some(resume_rx), + calls: Arc::clone(&calls), + }), + ); + let worker = tokio::spawn(run_demo_echo( + SessionDirectory::new(pool), + ReliableInbound { + fenced, + from: rid(2), + stream: crate::tunnel::reliable::ReliableMeshStream::new_inbound(fenced, stream), + }, + Arc::new(AtomicBool::new(false)), + )); + started_rx + .await + .expect("production consumer started receiving"); + tokio::time::advance(std::time::Duration::from_millis(300)).await; + tokio::task::yield_now().await; + assert_eq!(calls.load(Ordering::Relaxed), 1, "tick restarted a receive"); + assert!(!worker.is_finished(), "tick abandoned the pending receive"); + resume_tx.send(()).expect("receive remains alive"); + worker.await.expect("consumer completed after stream EOF"); + } + fn rid(byte: u8) -> RuntimeId { RuntimeId([byte; 32]) } diff --git a/crates/buzz-relay/src/tunnel/reliable.rs b/crates/buzz-relay/src/tunnel/reliable.rs index 5153e104df..8238d41083 100644 --- a/crates/buzz-relay/src/tunnel/reliable.rs +++ b/crates/buzz-relay/src/tunnel/reliable.rs @@ -329,6 +329,9 @@ impl ReliableMeshStream { /// pinned fenced tuple and the Redis directory. This is the reliable-stream /// equivalent of Dawn's hot-path media floor, but authoritative: stale or /// mismatched frames fail the session rather than being dropped silently. + /// This receive is not cancellation-safe: it can consume frame bytes before + /// awaiting directory validation. Preserve an in-flight receive across + /// housekeeping ticks; cancellation is only safe when terminating the stream. pub async fn recv_validated( &mut self, directory: &SessionDirectory, diff --git a/crates/buzz-relay/tests/boot_lifecycle.rs b/crates/buzz-relay/tests/boot_lifecycle.rs index 5d7dd2491f..0a63a83be8 100644 --- a/crates/buzz-relay/tests/boot_lifecycle.rs +++ b/crates/buzz-relay/tests/boot_lifecycle.rs @@ -168,20 +168,38 @@ fn wait_for_relay_metrics(process: &mut RelayProcess, port: u16) -> String { /// [`METRICS_SCRAPE_DEADLINE`]. A relay that exits first is a failure, not a /// timeout, so the panic names the real cause. fn wait_for_scraped_metric(process: &mut RelayProcess, port: u16, needle: &str) -> String { + wait_for_scraped_metrics(process, port, &[needle]) +} + +/// Waits for one scrape to contain every requested sample, bounded by +/// [`METRICS_SCRAPE_DEADLINE`]. A scrape can overlap the sampler's sequential +/// metric writes, so related series may appear in different responses. +fn wait_for_scraped_metrics(process: &mut RelayProcess, port: u16, needles: &[&str]) -> String { + let requested = needles.join(", "); + let mut last_scrape = String::new(); + let mut last_error: Option; let deadline = Instant::now() + METRICS_SCRAPE_DEADLINE; loop { assert!( process.try_wait().is_none(), - "relay exited before exporting {needle}" + "relay exited before exporting {requested}" ); - if let Ok(response) = scrape_metrics(port) { - if response.contains(needle) { - return response; + match scrape_metrics(port) { + Ok(response) => { + if needles + .iter() + .all(|needle| response.lines().any(|line| line.starts_with(needle))) + { + return response; + } + last_scrape = response; + last_error = None; } + Err(error) => last_error = Some(error.to_string()), } assert!( Instant::now() < deadline, - "relay did not export {needle} within {METRICS_SCRAPE_DEADLINE:?}" + "relay did not export {requested} within {METRICS_SCRAPE_DEADLINE:?}; last error: {last_error:?}; last scrape: {last_scrape}" ); thread::sleep(Duration::from_millis(20)); } @@ -817,10 +835,10 @@ mod postgres_tests { /// fires immediately, so the wait is bounded by /// [`METRICS_SCRAPE_DEADLINE`] and never a fixed sleep. /// - /// The completion-timestamp gauge is the needle because `sample` writes it - /// last, after the cache already serves the report: observing it proves the - /// whole cycle ran, with no window where the counters have landed but the - /// timestamp has not. + /// The sampler writes counters and histograms before the completion gauge, + /// but a concurrent `/metrics` scrape is not an atomic snapshot. Wait for + /// the actual timestamp, counter, and duration samples together so a scrape + /// that overlaps those sequential writes cannot report a false failure. #[test] #[ignore = "requires PostgreSQL"] fn startup_owns_the_dependency_sampler() { @@ -842,10 +860,14 @@ mod postgres_tests { ("REDIS_URL", &redis_url), ("BUZZ_GIT_CONFORMANCE_PROBE", "false"), ]); - let scrape = wait_for_scraped_metric( + let scrape = wait_for_scraped_metrics( &mut process, metrics_port, - "buzz_readiness_dependency_sample_completed_timestamp_seconds", + &[ + "buzz_readiness_dependency_sample_completed_timestamp_seconds ", + "buzz_readiness_dependency_checks_total{", + "buzz_readiness_check_duration_seconds_count{check=\"overall\"}", + ], ); let output = process.terminate(); let logs = format!( @@ -854,14 +876,6 @@ mod postgres_tests { String::from_utf8_lossy(&output.stderr) ); - assert!( - scrape.contains("buzz_readiness_dependency_checks_total{"), - "the same cycle must publish its per-dependency outcomes: {scrape}" - ); - assert!( - scrape.contains("buzz_readiness_check_duration_seconds"), - "a completed evaluation must publish its latency too: {scrape}" - ); assert!( !scrape.contains("buzz_readiness_checks_total{"), "no readiness probe was sent, so the sampler alone produced this: {scrape}" diff --git a/desktop/src-tauri/src/managed_agents/discovery/bounded_command.rs b/desktop/src-tauri/src/managed_agents/discovery/bounded_command.rs index 16eb01231b..70297885bb 100644 --- a/desktop/src-tauri/src/managed_agents/discovery/bounded_command.rs +++ b/desktop/src-tauri/src/managed_agents/discovery/bounded_command.rs @@ -185,11 +185,7 @@ impl BoundedChild { /// still holding the captured-output descriptors. fn kill_tree(&mut self) { #[cfg(unix)] - // SAFETY: `killpg` on the group led by the child; ignored result is - // intentional — `ESRCH` on a dead group is the success case. - unsafe { - libc::killpg(self.child.id() as i32, libc::SIGKILL); - } + kill_group(self.child.id()); #[cfg(windows)] // Closing the kill-on-close job reaps every descendant, even once the // root has exited — which `taskkill /T ` cannot. `spawn` is @@ -217,6 +213,15 @@ impl BoundedChild { } } +#[cfg(unix)] +fn kill_group(pid: u32) { + // SAFETY: `killpg` on the group led by the child; ignored result is + // intentional — `ESRCH` on a dead group is the success case. + unsafe { + libc::killpg(pid as i32, libc::SIGKILL); + } +} + /// Set a file descriptor nonblocking so a read on it returns `WouldBlock` /// instead of parking when no bytes are available. Returns `false` on any /// `fcntl` failure, which the caller treats as fail-closed. @@ -367,7 +372,10 @@ pub(crate) fn output_with_timeout(mut command: Command, timeout: Duration) -> Op .stderr(Stdio::piped()); let mut child = BoundedChild::spawn(command)?; + collect_output_with_timeout(&mut child, timeout) +} +fn collect_output_with_timeout(child: &mut BoundedChild, timeout: Duration) -> Option { let stdout_pipe = child.take_stdout(); let stderr_pipe = child.take_stderr(); @@ -577,31 +585,121 @@ mod tests { #[cfg(unix)] #[test] fn reaps_descendant_on_timeout() { - let pid_file = tempfile::NamedTempFile::new().expect("temp file for descendant pid"); - let pid_path = pid_file - .path() - .to_str() - .expect("utf-8 temp path") - .to_string(); - let script = format!( - "trap '' TERM; sleep 300 & echo $! > '{pid_path}'; \ - while :; do sleep 1; done" - ); - let mut cmd = Command::new("/bin/sh"); - cmd.args(["-c", &script]); - let result = run_watchdogged(cmd, Duration::from_millis(300), Duration::from_secs(5)); - assert!(result.is_none(), "a timed-out tree must yield None"); + // Cleanup must also run on a setup assertion or an expired outer + // watchdog. Keep independent group ownership after handing the child + // to the worker; do not clean up a successful result before asserting + // that the production collector killed the descendant itself. + struct FixtureTree { + pid: u32, + child: Option, + armed: bool, + } + impl FixtureTree { + fn disarm(&mut self) { + self.armed = false; + } + } + impl Drop for FixtureTree { + fn drop(&mut self) { + if self.armed { + kill_group(self.pid); + if let Some(child) = self.child.as_mut() { + child.reap(); + } + } + } + } - let descendant_pid: i32 = std::fs::read_to_string(&pid_path) - .expect("descendant must have written its PID") - .trim() - .parse() - .expect("descendant PID must be numeric"); + let started = Instant::now(); + let bound = Duration::from_secs(5); + let pid_file = tempfile::NamedTempFile::new().expect("temp file for descendant pid"); + let mut cmd = Command::new("/bin/sh"); + cmd.args([ + "-c", + "trap '' TERM; sleep 300 & echo $! > \"$1\"; while :; do sleep 1; done", + "timeout-fixture", + ]) + .arg(pid_file.path()) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + let child = BoundedChild::spawn(cmd).expect("spawn timeout fixture"); + let mut fixture = FixtureTree { + pid: child.child.id(), + child: Some(child), + armed: true, + }; + + let mut worker = FixtureTree { + pid: fixture.pid, + child: fixture.child.take(), + armed: true, + }; + let (tx, rx) = mpsc::channel(); + std::thread::spawn(move || { + // spawn() does not mean the shell has forked or published its child. + // Start the short *cleanup* timeout only once that precondition is + // observable, while keeping setup inside the original outer bound. + let ready_deadline = started + Duration::from_secs(3); + let descendant_pid = loop { + let recorded = + std::fs::read_to_string(pid_file.path()).expect("read descendant pid"); + if recorded.ends_with('\n') { + let pid: i32 = recorded + .trim() + .parse() + .expect("descendant PID must be numeric"); + assert!( + pid > 0 && pid_alive(pid), + "descendant {pid} must be alive before timeout" + ); + break pid; + } + let status = worker + .child + .as_mut() + .expect("owned fixture") + .try_wait() + .expect("probe fixture"); + assert!( + status.is_none(), + "fixture exited before readiness: {status:?}, pid file {recorded:?}" + ); + assert!( + Instant::now() < ready_deadline, + "fixture did not publish a complete PID within 3s: leader {}, pid file {recorded:?}", + worker.pid + ); + std::thread::sleep(DRAIN_IDLE_POLL); + }; + eprintln!( + "timeout fixture ready: leader {}, descendant {descendant_pid}, elapsed {:?}", + worker.pid, + started.elapsed() + ); + + let result = collect_output_with_timeout( + worker.child.as_mut().expect("owned fixture"), + Duration::from_millis(300), + ); + worker.disarm(); + let _ = tx.send((descendant_pid, result)); + }); + let (descendant_pid, result) = rx + .recv_timeout(bound.saturating_sub(started.elapsed())) + .unwrap_or_else(|err| { + panic!( + "ready tree did not return within {bound:?}: {err}; leader {}", + fixture.pid + ) + }); + assert!(result.is_none(), "a timed-out tree must yield None"); std::thread::sleep(Duration::from_millis(200)); assert!( !pid_alive(descendant_pid), "backgrounded descendant {descendant_pid} must be group-killed on timeout, but it survived" ); + fixture.disarm(); } // Deterministic seam regression (Thufir's finding): a drain fed a reader diff --git a/desktop/src/features/communities/ui/CommunitySwitcher.jsdom-test.mjs b/desktop/src/features/communities/ui/CommunitySwitcher.jsdom-test.mjs new file mode 100644 index 0000000000..b627493c3b --- /dev/null +++ b/desktop/src/features/communities/ui/CommunitySwitcher.jsdom-test.mjs @@ -0,0 +1,64 @@ +import assert from "node:assert/strict"; +import { mock, test } from "node:test"; +import React, { act } from "react"; +import { cleanup, fireEvent, render } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +import { CommunitySwitcher } from "./CommunitySwitcher.tsx"; + +const community = { + id: "alpha", + name: "Alpha", + relayUrl: "wss://alpha.example", + addedAt: "2026-01-01T00:00:00Z", +}; + +function renderSwitcher(t) { + const client = new QueryClient(); + client.setQueryData(["communityIcon", community.relayUrl], null); + const view = render( + React.createElement( + QueryClientProvider, + { client }, + React.createElement(CommunitySwitcher, { + activeCommunity: community, + communities: [community], + variant: "profile-menu", + onSwitchCommunity() {}, + onAddCommunity() {}, + onUpdateCommunity() {}, + async onLeaveCommunity() {}, + async onRemoveCommunityFromDevice() {}, + }), + ), + ); + t.after(() => { + cleanup(); + client.clear(); + mock.timers.reset(); + }); + mock.timers.enable({ apis: ["setTimeout"] }); + return view; +} + +test("explicitly opening community actions cancels a pending hover close", (t) => { + const view = renderSwitcher(t); + const trigger = view.getByTestId("community-switcher"); + fireEvent.mouseEnter(trigger); + fireEvent.mouseLeave(trigger); + fireEvent.click(trigger); + act(() => mock.timers.tick(160)); + assert.equal(trigger.getAttribute("aria-expanded"), "true"); + assert.ok(view.getByRole("menu", { name: "Community actions" })); +}); + +test("selecting a community action cancels a pending hover open", (t) => { + const view = renderSwitcher(t); + const trigger = view.getByTestId("community-switcher"); + fireEvent.click(trigger); + fireEvent.mouseEnter(view.getByRole("menu", { name: "Community actions" })); + fireEvent.click(view.getByRole("menuitem", { name: "Add a community" })); + act(() => mock.timers.tick(80)); + assert.equal(trigger.getAttribute("aria-expanded"), "false"); + assert.equal(view.queryByRole("menu", { name: "Community actions" }), null); +}); diff --git a/desktop/src/features/communities/ui/CommunitySwitcher.tsx b/desktop/src/features/communities/ui/CommunitySwitcher.tsx index 14e358b288..bf136e549d 100644 --- a/desktop/src/features/communities/ui/CommunitySwitcher.tsx +++ b/desktop/src/features/communities/ui/CommunitySwitcher.tsx @@ -156,13 +156,8 @@ export function CommunitySwitcher({ } function handleProfileMenuOpenChange(nextOpen: boolean) { - if (variant !== "profile-menu") { - setDropdownOpen(nextOpen); - return; - } - if (!nextOpen) { - clearProfileMenuHoverTimer(); - } + // Explicit actions take precedence over an earlier pointer transition. + clearProfileMenuHoverTimer(); setDropdownOpen(nextOpen); } @@ -318,7 +313,7 @@ export function CommunitySwitcher({