From d2c2a749431618abc243d1e434a6b3f6977cf9b4 Mon Sep 17 00:00:00 2001 From: Brad Seiler Date: Fri, 9 Oct 2026 19:36:31 -0400 Subject: [PATCH 01/34] fix(relay): revoke invitations durably and fence restricted claims Keep v1 natural drain with an optional UTC cutoff. Serialize claim and mint policy reads with restriction and owner writes. Share historical revocation repair across migration and desired-state upgrades while retaining deletion fences. Co-authored-by: Codex Signed-off-by: Brad Seiler Signed-off-by: Local Builder <1c476cb1127ea270cb900bdc3891060140bdd1173f91498030ee9b4db01c2f20@buzz.block.builderlab.xyz> --- .env.example | 4 + TESTING.md | 25 ++ crates/buzz-db/src/error.rs | 4 + crates/buzz-db/src/runtime/migration.rs | 10 +- crates/buzz-db/src/store/relay_invite.rs | 84 ++++- .../relay_invite/revocation_postgres_tests.rs | 332 ++++++++++++++++++ .../relay_invite/upgrade_postgres_tests.rs | 120 +++++++ crates/buzz-db/src/store/relay_members.rs | 4 + crates/buzz-relay/src/api/invites.rs | 71 +++- .../postgres_tests/restriction_tests.rs | 254 ++++++++++++++ crates/buzz-relay/src/config.rs | 50 +++ crates/buzz-relay/src/invite_token.rs | 61 +++- migrations/0061_invite_revocation.sql | 121 +++++++ .../functions/backfill_invite_revocations.sql | 60 ++++ schema/functions/invite_admission_lock.sql | 5 + schema/functions/lock_invite_restrictions.sql | 7 + .../revoke_banned_issuer_invites.sql | 15 + .../revoke_restricted_agent_invites.sql | 16 + schema/schema.sql | 6 + schema/tables/public/relay_invites.sql | 1 + schema/triggers/community_bans.sql | 5 + schema/triggers/users.sql | 5 + scripts/reconcile-schema-after-pgschema.sql | 16 + 23 files changed, 1253 insertions(+), 23 deletions(-) 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-relay/src/api/invites/postgres_tests/restriction_tests.rs create mode 100644 migrations/0061_invite_revocation.sql create mode 100644 schema/functions/backfill_invite_revocations.sql create mode 100644 schema/functions/invite_admission_lock.sql create mode 100644 schema/functions/lock_invite_restrictions.sql create mode 100644 schema/functions/revoke_banned_issuer_invites.sql create mode 100644 schema/functions/revoke_restricted_agent_invites.sql diff --git a/.env.example b/.env.example index 6674deec40b..7123f0353b8 100644 --- a/.env.example +++ b/.env.example @@ -372,3 +372,7 @@ RUST_LOG=buzz_relay=debug,buzz_datastore=info,buzz_db=debug,buzz_auth=debug,buzz # BUZZ_TERMS_OF_SERVICE_MARKDOWN="# Terms of Service\n\nFull terms here." # BUZZ_PRIVACY_POLICY_MARKDOWN="# Privacy Policy\n\nFull policy here." # BUZZ_AGE_ATTESTATION_REQUIRED=true + +# 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/TESTING.md b/TESTING.md index d939265c414..f4a4bde5e6b 100644 --- a/TESTING.md +++ b/TESTING.md @@ -146,6 +146,31 @@ 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. +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. + ### 4. Smoke test the CLI against the relay End-to-end: generate an identity, create a channel, post a message, read it diff --git a/crates/buzz-db/src/error.rs b/crates/buzz-db/src/error.rs index e27b319bed4..e0f3f3e27b6 100644 --- a/crates/buzz-db/src/error.rs +++ b/crates/buzz-db/src/error.rs @@ -37,6 +37,10 @@ pub enum DbError { #[error("access denied: {0}")] AccessDenied(String), + /// Invitation admission or minting was denied by authoritative restrictions. + #[error("invitation principal is restricted")] + InviteRestricted, + /// 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 7cb0879ff87..7f16dd339d6 100644 --- a/crates/buzz-db/src/runtime/migration.rs +++ b/crates/buzz-db/src/runtime/migration.rs @@ -709,7 +709,7 @@ mod postgres_tests { let mut migrations: Vec<_> = MIGRATOR.iter().collect(); migrations.sort_by_key(|migration| migration.version); - assert_eq!(migrations.len(), 66); + assert_eq!(migrations.len(), 67); assert_eq!(migrations[64].version, 65); let retire_ttl = strip_sql_comments(migrations[64].sql.as_str()); for statement in [ @@ -722,6 +722,14 @@ 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_eq!(migrations[65].version, 66); + assert!(migrations[65] + .sql + .as_str() + .contains("CREATE TABLE community_nip_fi_issuers")); + assert!(migrations + .iter() + .any(|migration| migration.sql.as_str().contains("ADD COLUMN revoked_at"))); assert_eq!(migrations[58].version, 59); assert!(migrations[58].sql.as_str().contains("buzz.push_enabled")); assert_eq!(migrations[59].version, 60); diff --git a/crates/buzz-db/src/store/relay_invite.rs b/crates/buzz-db/src/store/relay_invite.rs index b6d76fb7349..c8fb4339a11 100644 --- a/crates/buzz-db/src/store/relay_invite.rs +++ b/crates/buzz-db/src/store/relay_invite.rs @@ -55,6 +55,10 @@ pub enum ClaimOutcome { Exhausted, /// No invite row matches `(community_id, token_hash)`. Invalid, + /// The issuer's invitation was permanently revoked. + Revoked, + /// The claimant or its verified owner is banned or timed out. + Restricted, } /// A freshly minted v2 invite, including the plaintext code and metadata. @@ -123,6 +127,10 @@ 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) \ @@ -148,6 +156,46 @@ 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. +pub(crate) async fn lock_admission( + tx: &mut sqlx::PgConnection, + community: CommunityId, +) -> Result<()> { + sqlx::query("SELECT invite_admission_lock($1)") + .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 { + let bytes = hex::decode(pubkey) + .map_err(|_| crate::DbError::InvalidData("invalid invitation principal".into()))?; + if bytes.len() != 32 { + return 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?) +} + fn log_claim_outcome( community: CommunityId, invite_id: Option, @@ -263,9 +311,14 @@ pub async fn claim_relay_invite( ) .await?; + lock_admission(tx.conn(), community).await?; + if principal_restricted(tx.conn(), community, claimer_pubkey).await? { + return Ok(ClaimOutcome::Restricted); + } + // 2. SELECT FOR UPDATE — lock the invite row for the duration of this txn. let row = sqlx::query( - "SELECT id, max_uses, use_count, expires_at \ + "SELECT id, max_uses, use_count, expires_at, created_by, revoked_at \ FROM relay_invites \ WHERE community_id = $1 AND token_hash = $2 \ FOR UPDATE", @@ -302,6 +355,12 @@ pub async fn claim_relay_invite( return Ok(ClaimOutcome::Expired); } + let revoked_at: Option> = invite.try_get("revoked_at")?; + let issuer: String = invite.try_get("created_by")?; + if revoked_at.is_some() || principal_restricted(tx.conn(), community, &issuer).await? { + return Ok(ClaimOutcome::Revoked); + } + let uses_remaining = || max_uses.map(|mu| mu - use_count); // 5. Check existing membership. @@ -647,7 +706,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(_))); @@ -672,7 +731,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); @@ -718,7 +777,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); @@ -762,7 +821,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); @@ -799,10 +858,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); @@ -904,7 +963,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); @@ -930,7 +989,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); @@ -953,3 +1012,10 @@ 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; 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 00000000000..a472cab39c4 --- /dev/null +++ b/crates/buzz-db/src/store/relay_invite/revocation_postgres_tests.rs @@ -0,0 +1,332 @@ +use super::*; +use crate::moderation::{ban_member, unban_member}; +use uuid::Uuid; + +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( + &pool, + community, + &hash_v2_code(&invite.code), + &claimant, + None + ) + .await + .unwrap(), + ClaimOutcome::Revoked + ); + assert!(matches!( + claim_relay_invite( + &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( + &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( + &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) + .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(&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( + &pool, + community, + &hash_v2_code(&invite.code), + &claimant, + None + ) + .await + .unwrap(), + ClaimOutcome::Revoked + ); +} + +async fn queued_claim( + pool: &PgPool, + community: CommunityId, + code: &str, + claimant: String, +) -> tokio::task::JoinHandle> { + let name = format!("invite-race-{}", Uuid::new_v4()); + let connection_name = name.clone(); + let claim_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(); + let hash = hash_v2_code(code); + let claim = tokio::spawn(async move { + claim_relay_invite(&claim_pool, community, &hash, &claimant, None).await + }); + 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='advisory' AND NOT l.granted)") + .bind(&name).fetch_one(pool).await.unwrap(); + if waiting {break;} + assert!(!claim.is_finished(),"claim bypassed the production admission lock"); + tokio::task::yield_now().await; + } + }).await.expect("claim must observably wait on the admission lock"); + 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(); + let mut restriction = pool.begin().await.unwrap(); + sqlx::query("INSERT INTO community_bans(community_id,pubkey,banned,muted_until,actor_pubkey) VALUES($1,$2,$3,CASE WHEN $3 THEN NULL ELSE clock_timestamp()+interval '1 hour' END,$4)") + .bind(community.as_uuid()).bind(vec![2u8;32]).bind(!timeout).bind(vec![3u8;32]) + .execute(&mut *restriction).await.unwrap(); + let claim = queued_claim(&pool, community, &invite.code, claimant.clone()).await; + restriction.commit().await.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 owner = pool.begin().await.unwrap(); + sqlx::query("UPDATE users SET agent_owner_pubkey=$3 WHERE community_id=$1 AND pubkey=$2") + .bind(community.as_uuid()) + .bind(vec![2u8; 32]) + .bind(vec![4u8; 32]) + .execute(&mut *owner) + .await + .unwrap(); + let claim = queued_claim(&pool, community, &invite.code, agent).await; + owner.commit().await.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( + &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( + &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 00000000000..0bc964b7f01 --- /dev/null +++ b/crates/buzz-db/src/store/relay_invite/upgrade_postgres_tests.rs @@ -0,0 +1,120 @@ +use super::*; +use uuid::Uuid; + +async fn legacy_fixture() -> (PgPool, Vec) { + let pool = PgPool::connect(&crate::test_support::database_url()) + .await + .unwrap(); + crate::migration::run_migrations_through(&pool, 60) + .await + .unwrap(); + let mut communities = Vec::new(); + for index in 0..4 { + 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(); + sqlx::query("INSERT INTO relay_invites(community_id,token_hash,created_by,expires_at,created_at) VALUES($1,$2,$3,now()+interval '1 day',now()-interval '1 hour')") + .bind(community).bind(vec![index as u8;32]).bind(hex::encode([1;32])) + .execute(&pool).await.unwrap(); + if index != 3 { + // Historical accepted ban after issuance, already lifted by upgrade. + sqlx::query("INSERT INTO moderation_actions(community_id,actor_pubkey,action,target_pubkey) VALUES($1,$2,'ban',$3)") + .bind(community).bind(vec![2u8;32]).bind(vec![1u8;32]) + .execute(&pool).await.unwrap(); + } + if index == 1 || index == 2 { + sqlx::query( + "UPDATE communities SET deletion_state=$2,deletion_fence_generation=7 WHERE id=$1", + ) + .bind(community) + .bind(if index == 1 { "quiescing" } else { "tombstone" }) + .execute(&pool) + .await + .unwrap(); + } + communities.push(community); + } + (pool, communities) +} + +async fn assert_repaired_and_fenced(pool: &PgPool, communities: &[Uuid]) { + for (index, community) in communities.iter().enumerate() { + 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_eq!(revoked, index != 3, "tenant {index}"); + } + assert_eq!( + sqlx::query_scalar::<_, i64>("SELECT backfill_invite_revocations()") + .fetch_one(pool) + .await + .unwrap(), + 0, + "repair is idempotent" + ); + // The repair restores context; ordinary writes stay fenced afterward. + let error = sqlx::query("UPDATE relay_invites SET use_count=use_count+1 WHERE community_id=$1") + .bind(communities[1]) + .execute(pool) + .await + .unwrap_err(); + assert!( + error.to_string().contains("community write fenced"), + "{error}" + ); + let triggers: i64=sqlx::query_scalar("SELECT count(*) FROM pg_trigger WHERE NOT tgisinternal AND tgname IN ('invite_restriction_lock','invite_issuer_revocation','invite_owner_lock','invite_owner_revocation','community_write_fence_relay_invites')") + .fetch_one(pool).await.unwrap(); + assert_eq!(triggers, 5); +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn migration_schema_backfill_repairs_history_in_active_and_deletion_fenced_tenants() { + let (pool, communities) = legacy_fixture().await; + crate::migration::run_migrations(&pool).await.unwrap(); + assert_repaired_and_fenced(&pool, &communities).await; +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn migration_schema_desired_upgrade_reconciles_the_same_populated_history() { + let (pool, communities) = legacy_fixture().await; + // Apply the actual desired-state function/trigger sources to the populated + // old schema, then the actual pgschema reconciliation entry point. + sqlx::raw_sql("ALTER TABLE relay_invites ADD COLUMN revoked_at TIMESTAMPTZ") + .execute(&pool) + .await + .unwrap(); + for sql in [ + include_str!("../../../../../schema/functions/invite_admission_lock.sql"), + include_str!("../../../../../schema/functions/lock_invite_restrictions.sql"), + include_str!("../../../../../schema/functions/revoke_banned_issuer_invites.sql"), + include_str!("../../../../../schema/functions/revoke_restricted_agent_invites.sql"), + include_str!("../../../../../schema/functions/backfill_invite_revocations.sql"), + ] { + sqlx::raw_sql(sql).execute(&pool).await.unwrap(); + } + sqlx::raw_sql("CREATE TRIGGER invite_restriction_lock BEFORE INSERT OR UPDATE ON community_bans FOR EACH ROW EXECUTE FUNCTION lock_invite_restrictions(); CREATE TRIGGER invite_issuer_revocation AFTER INSERT OR UPDATE ON community_bans FOR EACH ROW EXECUTE FUNCTION revoke_banned_issuer_invites(); CREATE TRIGGER invite_owner_lock BEFORE INSERT OR UPDATE OF agent_owner_pubkey ON users FOR EACH ROW EXECUTE FUNCTION lock_invite_restrictions(); CREATE TRIGGER invite_owner_revocation AFTER INSERT OR UPDATE OF agent_owner_pubkey ON users FOR EACH ROW EXECUTE FUNCTION revoke_restricted_agent_invites();") + .execute(&pool).await.unwrap(); + let still_unrepaired: i64 = + sqlx::query_scalar("SELECT count(*) FROM relay_invites WHERE revoked_at IS NULL") + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(still_unrepaired, 4, "DDL alone cannot repair history"); + sqlx::raw_sql(include_str!( + "../../../../../scripts/reconcile-schema-after-pgschema.sql" + )) + .execute(&pool) + .await + .unwrap(); + assert_repaired_and_fenced(&pool, &communities).await; +} diff --git a/crates/buzz-db/src/store/relay_members.rs b/crates/buzz-db/src/store/relay_members.rs index 6b21c7ce01a..66d3785d59c 100644 --- a/crates/buzz-db/src/store/relay_members.rs +++ b/crates/buzz-db/src/store/relay_members.rs @@ -188,6 +188,10 @@ pub async fn claim_relay_membership( observability::WriterOperation::Authorization, ) .await?; + super::relay_invite::lock_admission(tx.conn(), community).await?; + if super::relay_invite::principal_restricted(tx.conn(), community, pubkey).await? { + return Err(DbError::InviteRestricted); + } let inserted = sqlx::query( "INSERT INTO relay_members (community_id, pubkey, role, added_by) \ VALUES ($1, $2, $3, 'invite') \ diff --git a/crates/buzz-relay/src/api/invites.rs b/crates/buzz-relay/src/api/invites.rs index bff1df0fcc8..d06ca664a6c 100644 --- a/crates/buzz-relay/src/api/invites.rs +++ b/crates/buzz-relay/src/api/invites.rs @@ -251,15 +251,42 @@ async fn authenticate( 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?; + materialize_invite_owner(state, &tenant, &pubkey, headers, signed_created_at).await?; Ok((tenant, pubkey)) } +async fn materialize_invite_owner( + state: &AppState, + tenant: &buzz_core::TenantContext, + pubkey: &nostr::PublicKey, + headers: &HeaderMap, + signed_created_at: Option, +) -> Result<(), (StatusCode, Json)> { + // The claim remains membership-exempt. Only cryptographically verified + // owner provenance is persisted, so the transactional restriction lookup + // also covers an agent onboarding for the first time. + 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, + ) { + if !super::relay_members::materialize_nip_oa_owner(state, tenant, pubkey, &owner).await { + return Err(api_error( + StatusCode::SERVICE_UNAVAILABLE, + "restriction lookup unavailable", + )); + } + } + Ok(()) +} + 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) } @@ -342,6 +369,12 @@ async fn mint_invite_checked( return e.into_response(); } + if let Err(error) = + materialize_invite_owner(&state, &tenant, &pubkey, &headers, signed_created_at).await + { + return error.into_response(); + } + match mint_invite_inner(&state, body, tenant, pubkey).await { Ok(json) => json.into_response(), Err(e) => e.into_response(), @@ -516,6 +549,12 @@ pub async fn claim_invite( buzz_db::relay_invite::ClaimOutcome::Exhausted => { Err(api_error(StatusCode::FORBIDDEN, "invite_exhausted")) } + buzz_db::relay_invite::ClaimOutcome::Revoked => { + 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")) } @@ -523,16 +562,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 @@ -556,7 +597,12 @@ pub async fn claim_invite( .map(|policy| policy.version.as_str()), ) .await - .map_err(|e| internal_error(&format!("invite claim insert: {e}")))?; + .map_err(|e| match e { + buzz_db::DbError::InviteRestricted => { + api_error(StatusCode::FORBIDDEN, "invite_restricted") + } + e => internal_error(&format!("invite claim insert: {e}")), + })?; if was_inserted { tracing::info!( @@ -608,6 +654,7 @@ fn claim_key_rate_limited( #[cfg(test)] mod postgres_tests { + mod restriction_tests; use std::sync::Arc; use std::time::Duration; 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 00000000000..0f1a69d9b2c --- /dev/null +++ b/crates/buzz-relay/src/api/invites/postgres_tests/restriction_tests.rs @@ -0,0 +1,254 @@ +use super::*; + +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) + .await + .unwrap(); + (state, host, community, owner) +} + +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()); + 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_lookup_failure_never_admits() { + 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 mut state = Arc::try_unwrap(state) + .ok() + .expect("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; + assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR); + assert!(!state + .db + .is_relay_member(community, &claimant.public_key().to_hex()) + .await + .unwrap()); +} diff --git a/crates/buzz-relay/src/config.rs b/crates/buzz-relay/src/config.rs index 129d2792edf..49be08ede0f 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, @@ -410,6 +413,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 @@ -1439,6 +1464,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, @@ -2891,3 +2917,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/invite_token.rs b/crates/buzz-relay/src/invite_token.rs index e271f40f0b5..9886b781530 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/migrations/0061_invite_revocation.sql b/migrations/0061_invite_revocation.sql new file mode 100644 index 00000000000..6555200622b --- /dev/null +++ b/migrations/0061_invite_revocation.sql @@ -0,0 +1,121 @@ +ALTER TABLE relay_invites ADD COLUMN revoked_at TIMESTAMPTZ; + +-- Serialize invitation admission with restriction and ownership changes per tenant. +CREATE FUNCTION invite_admission_lock(community UUID) RETURNS VOID +LANGUAGE sql AS $$ + SELECT pg_advisory_xact_lock(hashtextextended('invite-admission:' || community::text, 0)); +$$; + +CREATE FUNCTION lock_invite_restrictions() RETURNS TRIGGER +LANGUAGE plpgsql AS $$ +BEGIN + PERFORM invite_admission_lock(NEW.community_id); + RETURN NEW; +END +$$; + +-- Revocation is durable: neither unban nor expiry restores an issued bearer. +CREATE FUNCTION revoke_banned_issuer_invites() RETURNS TRIGGER +LANGUAGE plpgsql AS $$ +BEGIN + IF NEW.banned AND (NEW.ban_expires_at IS NULL OR NEW.ban_expires_at > clock_timestamp()) THEN + UPDATE relay_invites SET revoked_at = clock_timestamp() + WHERE community_id = NEW.community_id AND revoked_at IS NULL + AND (created_by = encode(NEW.pubkey, 'hex') OR created_by IN ( + SELECT encode(pubkey, 'hex') FROM users + WHERE community_id = NEW.community_id AND agent_owner_pubkey = NEW.pubkey + )); + END IF; + RETURN NEW; +END +$$; + +-- Learning a verified owner must not revive invites issued by its restricted agent. +CREATE FUNCTION revoke_restricted_agent_invites() RETURNS TRIGGER +LANGUAGE plpgsql AS $$ +BEGIN + IF NEW.agent_owner_pubkey IS NOT NULL AND EXISTS ( + SELECT 1 FROM community_bans WHERE community_id = NEW.community_id + AND pubkey = NEW.agent_owner_pubkey AND banned + AND (ban_expires_at IS NULL OR ban_expires_at > clock_timestamp()) + ) THEN + UPDATE relay_invites SET revoked_at = clock_timestamp() + WHERE community_id = NEW.community_id AND created_by = encode(NEW.pubkey, 'hex') + AND revoked_at IS NULL; + END IF; + RETURN NEW; +END +$$; + +CREATE TRIGGER invite_restriction_lock BEFORE INSERT OR UPDATE ON community_bans +FOR EACH ROW EXECUTE FUNCTION lock_invite_restrictions(); +CREATE TRIGGER invite_issuer_revocation AFTER INSERT OR UPDATE ON community_bans +FOR EACH ROW EXECUTE FUNCTION revoke_banned_issuer_invites(); + +CREATE TRIGGER invite_owner_lock BEFORE INSERT OR UPDATE OF agent_owner_pubkey ON users +FOR EACH ROW EXECUTE FUNCTION lock_invite_restrictions(); +CREATE TRIGGER invite_owner_revocation AFTER INSERT OR UPDATE OF agent_owner_pubkey ON users +FOR EACH ROW EXECUTE FUNCTION revoke_restricted_agent_invites(); + +-- Include active bans and recorded prior bans, so lifting a ban before this +-- migration cannot restore an invite issued before that ban. Scope every owner +-- and audit lookup to the invitation's community. +-- Schema migrations hold the exclusive schema-destruction lock. Retain the +-- universal tenant fence too: acquire its shared lock, then read the current +-- generation before applying this narrowly scoped historical security repair. +-- A quiescing tenant may return to active, so skipping it could revive bearers. +CREATE FUNCTION backfill_invite_revocations() RETURNS BIGINT +LANGUAGE plpgsql AS $$ +DECLARE + target RECORD; + affected BIGINT; + total BIGINT := 0; + current_generation BIGINT; + prior_community TEXT := current_setting('buzz.deletion_executor_community', true); + prior_generation TEXT := current_setting('buzz.deletion_fence_generation', true); +BEGIN + FOR target IN SELECT id FROM communities + WHERE EXISTS (SELECT 1 FROM relay_invites WHERE community_id = communities.id) + ORDER BY id + LOOP + PERFORM pg_advisory_xact_lock_shared(community_deletion_lock_key(target.id)); + SELECT deletion_fence_generation INTO STRICT current_generation + FROM communities WHERE id = target.id; + PERFORM set_config('buzz.deletion_executor_community', target.id::text, true); + PERFORM set_config('buzz.deletion_fence_generation', current_generation::text, true); + UPDATE relay_invites i SET revoked_at = clock_timestamp() + WHERE i.community_id = target.id AND i.revoked_at IS NULL AND EXISTS ( + SELECT 1 FROM ( + SELECT CASE WHEN i.created_by ~ '^[0-9a-f]{64}$' + THEN decode(i.created_by, 'hex') END AS pubkey + UNION ALL + SELECT agent_owner_pubkey FROM users + WHERE community_id = i.community_id AND encode(pubkey, 'hex') = i.created_by + AND agent_owner_pubkey IS NOT NULL + ) principal + WHERE EXISTS ( + SELECT 1 FROM community_bans b WHERE b.community_id = i.community_id + AND b.pubkey = principal.pubkey AND b.banned + AND (b.ban_expires_at IS NULL OR b.ban_expires_at > clock_timestamp()) + ) OR EXISTS ( + SELECT 1 FROM moderation_actions a WHERE a.community_id = i.community_id + AND a.target_pubkey = principal.pubkey AND a.action IN ('ban', 'resolve:ban') + AND a.created_at >= i.created_at + ) OR EXISTS ( + SELECT 1 FROM relay_admin_actions a WHERE a.report_community_id = i.community_id + AND a.enforcement_target_pubkey = principal.pubkey AND a.action = 'ban' + AND (a.step_marker = 'mutation_committed' OR a.state = 'succeeded') + AND a.updated_at >= i.created_at + ) + ) + ; + GET DIAGNOSTICS affected = ROW_COUNT; + total := total + affected; + PERFORM set_config('buzz.deletion_executor_community', COALESCE(prior_community, ''), true); + PERFORM set_config('buzz.deletion_fence_generation', COALESCE(prior_generation, ''), true); + END LOOP; + RETURN total; +END +$$; + +SELECT backfill_invite_revocations(); diff --git a/schema/functions/backfill_invite_revocations.sql b/schema/functions/backfill_invite_revocations.sql new file mode 100644 index 00000000000..abe8118b0be --- /dev/null +++ b/schema/functions/backfill_invite_revocations.sql @@ -0,0 +1,60 @@ +-- Include active bans and recorded prior bans, so lifting a ban before this +-- migration cannot restore an invite issued before that ban. Scope every owner +-- and audit lookup to the invitation's community. +-- Schema migrations hold the exclusive schema-destruction lock. Retain the +-- universal tenant fence too: acquire its shared lock, then read the current +-- generation before applying this narrowly scoped historical security repair. +-- A quiescing tenant may return to active, so skipping it could revive bearers. +CREATE FUNCTION backfill_invite_revocations() RETURNS BIGINT +LANGUAGE plpgsql AS $$ +DECLARE + target RECORD; + affected BIGINT; + total BIGINT := 0; + current_generation BIGINT; + prior_community TEXT := current_setting('buzz.deletion_executor_community', true); + prior_generation TEXT := current_setting('buzz.deletion_fence_generation', true); +BEGIN + FOR target IN SELECT id FROM communities + WHERE EXISTS (SELECT 1 FROM relay_invites WHERE community_id = communities.id) + ORDER BY id + LOOP + PERFORM pg_advisory_xact_lock_shared(community_deletion_lock_key(target.id)); + SELECT deletion_fence_generation INTO STRICT current_generation + FROM communities WHERE id = target.id; + PERFORM set_config('buzz.deletion_executor_community', target.id::text, true); + PERFORM set_config('buzz.deletion_fence_generation', current_generation::text, true); + UPDATE relay_invites i SET revoked_at = clock_timestamp() + WHERE i.community_id = target.id AND i.revoked_at IS NULL AND EXISTS ( + SELECT 1 FROM ( + SELECT CASE WHEN i.created_by ~ '^[0-9a-f]{64}$' + THEN decode(i.created_by, 'hex') END AS pubkey + UNION ALL + SELECT agent_owner_pubkey FROM users + WHERE community_id = i.community_id AND encode(pubkey, 'hex') = i.created_by + AND agent_owner_pubkey IS NOT NULL + ) principal + WHERE EXISTS ( + SELECT 1 FROM community_bans b WHERE b.community_id = i.community_id + AND b.pubkey = principal.pubkey AND b.banned + AND (b.ban_expires_at IS NULL OR b.ban_expires_at > clock_timestamp()) + ) OR EXISTS ( + SELECT 1 FROM moderation_actions a WHERE a.community_id = i.community_id + AND a.target_pubkey = principal.pubkey AND a.action IN ('ban', 'resolve:ban') + AND a.created_at >= i.created_at + ) OR EXISTS ( + SELECT 1 FROM relay_admin_actions a WHERE a.report_community_id = i.community_id + AND a.enforcement_target_pubkey = principal.pubkey AND a.action = 'ban' + AND (a.step_marker = 'mutation_committed' OR a.state = 'succeeded') + AND a.updated_at >= i.created_at + ) + ) + ; + GET DIAGNOSTICS affected = ROW_COUNT; + total := total + affected; + PERFORM set_config('buzz.deletion_executor_community', COALESCE(prior_community, ''), true); + PERFORM set_config('buzz.deletion_fence_generation', COALESCE(prior_generation, ''), true); + END LOOP; + RETURN total; +END +$$; diff --git a/schema/functions/invite_admission_lock.sql b/schema/functions/invite_admission_lock.sql new file mode 100644 index 00000000000..c5d7098d3f7 --- /dev/null +++ b/schema/functions/invite_admission_lock.sql @@ -0,0 +1,5 @@ +-- Serialize invitation admission with restriction and ownership changes per tenant. +CREATE FUNCTION invite_admission_lock(community UUID) RETURNS VOID +LANGUAGE sql AS $$ + SELECT pg_advisory_xact_lock(hashtextextended('invite-admission:' || community::text, 0)); +$$; diff --git a/schema/functions/lock_invite_restrictions.sql b/schema/functions/lock_invite_restrictions.sql new file mode 100644 index 00000000000..c8aec1f2c69 --- /dev/null +++ b/schema/functions/lock_invite_restrictions.sql @@ -0,0 +1,7 @@ +CREATE FUNCTION lock_invite_restrictions() RETURNS TRIGGER +LANGUAGE plpgsql AS $$ +BEGIN + PERFORM invite_admission_lock(NEW.community_id); + RETURN NEW; +END +$$; diff --git a/schema/functions/revoke_banned_issuer_invites.sql b/schema/functions/revoke_banned_issuer_invites.sql new file mode 100644 index 00000000000..beb40f1ca40 --- /dev/null +++ b/schema/functions/revoke_banned_issuer_invites.sql @@ -0,0 +1,15 @@ +-- Revocation is durable: neither unban nor expiry restores an issued bearer. +CREATE FUNCTION revoke_banned_issuer_invites() RETURNS TRIGGER +LANGUAGE plpgsql AS $$ +BEGIN + IF NEW.banned AND (NEW.ban_expires_at IS NULL OR NEW.ban_expires_at > clock_timestamp()) THEN + UPDATE relay_invites SET revoked_at = clock_timestamp() + WHERE community_id = NEW.community_id AND revoked_at IS NULL + AND (created_by = encode(NEW.pubkey, 'hex') OR created_by IN ( + SELECT encode(pubkey, 'hex') FROM users + WHERE community_id = NEW.community_id AND agent_owner_pubkey = NEW.pubkey + )); + END IF; + RETURN NEW; +END +$$; diff --git a/schema/functions/revoke_restricted_agent_invites.sql b/schema/functions/revoke_restricted_agent_invites.sql new file mode 100644 index 00000000000..3a6d2359b79 --- /dev/null +++ b/schema/functions/revoke_restricted_agent_invites.sql @@ -0,0 +1,16 @@ +-- Learning a verified owner must not revive invites issued by its restricted agent. +CREATE FUNCTION revoke_restricted_agent_invites() RETURNS TRIGGER +LANGUAGE plpgsql AS $$ +BEGIN + IF NEW.agent_owner_pubkey IS NOT NULL AND EXISTS ( + SELECT 1 FROM community_bans WHERE community_id = NEW.community_id + AND pubkey = NEW.agent_owner_pubkey AND banned + AND (ban_expires_at IS NULL OR ban_expires_at > clock_timestamp()) + ) THEN + UPDATE relay_invites SET revoked_at = clock_timestamp() + WHERE community_id = NEW.community_id AND created_by = encode(NEW.pubkey, 'hex') + AND revoked_at IS NULL; + END IF; + RETURN NEW; +END +$$; diff --git a/schema/schema.sql b/schema/schema.sql index d252cbdb7ea..d8ed4f90193 100644 --- a/schema/schema.sql +++ b/schema/schema.sql @@ -78,6 +78,12 @@ CREATE EXTENSION IF NOT EXISTS pgcrypto; \i functions/attach_community_write_fence.sql \i functions/profile_name_fields.sql +\i functions/invite_admission_lock.sql +\i functions/lock_invite_restrictions.sql +\i functions/revoke_banned_issuer_invites.sql +\i functions/revoke_restricted_agent_invites.sql +\i functions/backfill_invite_revocations.sql + -- tables \i tables/public/communities.sql \i tables/public/channels.sql diff --git a/schema/tables/public/relay_invites.sql b/schema/tables/public/relay_invites.sql index 4e0f2eab0a3..65a7a9d63de 100644 --- a/schema/tables/public/relay_invites.sql +++ b/schema/tables/public/relay_invites.sql @@ -11,6 +11,7 @@ CREATE TABLE relay_invites ( max_uses INTEGER CHECK (max_uses BETWEEN 1 AND 10000), use_count INTEGER NOT NULL DEFAULT 0 CHECK (use_count >= 0), expires_at TIMESTAMPTZ NOT NULL, + revoked_at TIMESTAMPTZ, created_by TEXT NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), PRIMARY KEY (community_id, id), diff --git a/schema/triggers/community_bans.sql b/schema/triggers/community_bans.sql index 063e3ab4565..27c66ee1a2c 100644 --- a/schema/triggers/community_bans.sql +++ b/schema/triggers/community_bans.sql @@ -1 +1,6 @@ SELECT attach_community_write_fence('community_bans'); + +CREATE TRIGGER invite_restriction_lock BEFORE INSERT OR UPDATE ON community_bans +FOR EACH ROW EXECUTE FUNCTION lock_invite_restrictions(); +CREATE TRIGGER invite_issuer_revocation AFTER INSERT OR UPDATE ON community_bans +FOR EACH ROW EXECUTE FUNCTION revoke_banned_issuer_invites(); diff --git a/schema/triggers/users.sql b/schema/triggers/users.sql index 5f65f3c763c..60bd2c5c761 100644 --- a/schema/triggers/users.sql +++ b/schema/triggers/users.sql @@ -1 +1,6 @@ SELECT attach_community_write_fence('users'); + +CREATE TRIGGER invite_owner_lock BEFORE INSERT OR UPDATE OF agent_owner_pubkey ON users +FOR EACH ROW EXECUTE FUNCTION lock_invite_restrictions(); +CREATE TRIGGER invite_owner_revocation AFTER INSERT OR UPDATE OF agent_owner_pubkey ON users +FOR EACH ROW EXECUTE FUNCTION revoke_restricted_agent_invites(); diff --git a/scripts/reconcile-schema-after-pgschema.sql b/scripts/reconcile-schema-after-pgschema.sql index 06d6258643d..c673c1a7792 100644 --- a/scripts/reconcile-schema-after-pgschema.sql +++ b/scripts/reconcile-schema-after-pgschema.sql @@ -232,3 +232,19 @@ BEGIN RAISE EXCEPTION 'replica_heartbeat must contain its singleton row after pgschema apply'; END IF; END $$; + +-- Invitation revocation is historical DML; pgschema does not execute it. +-- Use the same tenant-fenced, idempotent repair as migration0061. +SELECT backfill_invite_revocations(); +DO $$ +BEGIN + IF backfill_invite_revocations() <> 0 THEN + RAISE EXCEPTION 'invitation revocation backfill did not converge'; + END IF; + IF (SELECT count(*) FROM pg_trigger WHERE NOT tgisinternal + AND tgname IN ('invite_restriction_lock', 'invite_issuer_revocation', + 'invite_owner_lock', 'invite_owner_revocation')) <> 4 THEN + RAISE EXCEPTION 'invitation revocation triggers missing from live catalog'; + END IF; +END +$$; From d0441db91976304c4f8294e3aacfa8e1a891cf97 Mon Sep 17 00:00:00 2001 From: Brad Seiler Date: Fri, 9 Oct 2026 19:39:27 -0400 Subject: [PATCH 02/34] test(relay): select invite boundaries and seed fenced upgrade fixtures Co-authored-by: Codex Signed-off-by: Brad Seiler Signed-off-by: Local Builder <1c476cb1127ea270cb900bdc3891060140bdd1173f91498030ee9b4db01c2f20@buzz.block.builderlab.xyz> --- Justfile | 2 ++ .../src/store/relay_invite/upgrade_postgres_tests.rs | 6 +++++- scripts/run-tests.sh | 5 +++++ 3 files changed, 12 insertions(+), 1 deletion(-) diff --git a/Justfile b/Justfile index ca89c5f35b1..0b39d9552cb 100644 --- a/Justfile +++ b/Justfile @@ -602,6 +602,8 @@ 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::tests::/) + test(/^config::invite_cutoff_tests::/)' # 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/crates/buzz-db/src/store/relay_invite/upgrade_postgres_tests.rs b/crates/buzz-db/src/store/relay_invite/upgrade_postgres_tests.rs index 0bc964b7f01..bde32e697a8 100644 --- a/crates/buzz-db/src/store/relay_invite/upgrade_postgres_tests.rs +++ b/crates/buzz-db/src/store/relay_invite/upgrade_postgres_tests.rs @@ -27,14 +27,18 @@ async fn legacy_fixture() -> (PgPool, Vec) { .execute(&pool).await.unwrap(); } if index == 1 || index == 2 { + 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(if index == 1 { "quiescing" } else { "tombstone" }) - .execute(&pool) + .execute(&mut *tx) .await .unwrap(); + tx.commit().await.unwrap(); } communities.push(community); } diff --git a/scripts/run-tests.sh b/scripts/run-tests.sh index 13b5c73f7cf..9773f698c5f 100755 --- a/scripts/run-tests.sh +++ b/scripts/run-tests.sh @@ -375,6 +375,11 @@ run_unit_tests() { run_test_step "buzz-relay binary tests" \ cargo test -p buzz-relay --bin buzz-relay -- --nocapture + run_test_step "buzz-relay invitation token and cutoff contracts" \ + cargo test -p buzz-relay --lib invite_token::tests:: -- --nocapture + run_test_step "buzz-relay invitation cutoff configuration" \ + cargo test -p buzz-relay --lib config::invite_cutoff_tests:: -- --nocapture + run_test_step "buzz-relay boot lifecycle tests" \ cargo test -p buzz-relay --test boot_lifecycle -- --nocapture } From e6a811ab03995f37ffab1d220226413e252c0cdf Mon Sep 17 00:00:00 2001 From: Brad Seiler Date: Fri, 9 Oct 2026 19:41:28 -0400 Subject: [PATCH 03/34] test(relay): include the v1 verifier cutoff boundary in CI Co-authored-by: Codex Signed-off-by: Brad Seiler Signed-off-by: Local Builder <1c476cb1127ea270cb900bdc3891060140bdd1173f91498030ee9b4db01c2f20@buzz.block.builderlab.xyz> --- Justfile | 2 +- scripts/run-tests.sh | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Justfile b/Justfile index 0b39d9552cb..de4c4e66ff5 100644 --- a/Justfile +++ b/Justfile @@ -603,7 +603,7 @@ test-unit: + 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::tests::/) + test(/^config::invite_cutoff_tests::/)' + cargo nextest run -p buzz-relay --lib -E 'test(/^invite_token::/) + test(/^config::invite_cutoff_tests::/)' # 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/scripts/run-tests.sh b/scripts/run-tests.sh index 9773f698c5f..be9c0cf491c 100755 --- a/scripts/run-tests.sh +++ b/scripts/run-tests.sh @@ -376,7 +376,7 @@ run_unit_tests() { cargo test -p buzz-relay --bin buzz-relay -- --nocapture run_test_step "buzz-relay invitation token and cutoff contracts" \ - cargo test -p buzz-relay --lib invite_token::tests:: -- --nocapture + cargo test -p buzz-relay --lib invite_token:: -- --nocapture run_test_step "buzz-relay invitation cutoff configuration" \ cargo test -p buzz-relay --lib config::invite_cutoff_tests:: -- --nocapture From 91305ecb25ce014cb670ba2c098fcecb139ef2b5 Mon Sep 17 00:00:00 2001 From: Brad Seiler Date: Fri, 9 Oct 2026 19:26:04 -0400 Subject: [PATCH 04/34] fix(relay): retain mesh receives across housekeeping ticks Add a deterministic production-consumer regression, cancellation-safety documentation, and deadline diagnostics for mesh receive handling. Co-authored-by: Codex Signed-off-by: Brad Seiler (cherry picked from commit f1674a214188469c2420101df076ec5abc87ea5b) Signed-off-by: Brad Seiler Signed-off-by: Local Builder <1c476cb1127ea270cb900bdc3891060140bdd1173f91498030ee9b4db01c2f20@buzz.block.builderlab.xyz> --- crates/buzz-relay/src/api/mesh_demo.rs | 3 +- crates/buzz-relay/src/mesh_boot.rs | 109 +++++++++++++++++++--- crates/buzz-relay/src/tunnel/reliable.rs | 3 + crates/buzz-relay/tests/boot_lifecycle.rs | 52 +++++++---- 4 files changed, 133 insertions(+), 34 deletions(-) diff --git a/crates/buzz-relay/src/api/mesh_demo.rs b/crates/buzz-relay/src/api/mesh_demo.rs index 8649b97671a..77dc9aaed84 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/mesh_boot.rs b/crates/buzz-relay/src/mesh_boot.rs index 63a346258f5..d18d7ff8ca0 100644 --- a/crates/buzz-relay/src/mesh_boot.rs +++ b/crates/buzz-relay/src/mesh_boot.rs @@ -421,24 +421,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))) => { @@ -696,6 +711,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 5153e104df6..8238d410830 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 027fc4d1c26..753200b47be 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 = None; 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)); } @@ -814,10 +832,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() { @@ -839,10 +857,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!( @@ -851,14 +873,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}" From 4432ca23081734c85575669c758bb73cafcdb188 Mon Sep 17 00:00:00 2001 From: Brad Seiler Date: Fri, 9 Oct 2026 19:29:04 -0400 Subject: [PATCH 05/34] test(relay): select mesh receive regression in both CI paths Co-authored-by: Codex Signed-off-by: Brad Seiler (cherry picked from commit 3d02a44a92bb095ff303c946c9aaf585f52c0b29) Signed-off-by: Brad Seiler Signed-off-by: Local Builder <1c476cb1127ea270cb900bdc3891060140bdd1173f91498030ee9b4db01c2f20@buzz.block.builderlab.xyz> --- Justfile | 2 ++ scripts/run-tests.sh | 3 +++ 2 files changed, 5 insertions(+) diff --git a/Justfile b/Justfile index de4c4e66ff5..e3c8e265804 100644 --- a/Justfile +++ b/Justfile @@ -604,6 +604,8 @@ test-unit: + 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/scripts/run-tests.sh b/scripts/run-tests.sh index be9c0cf491c..8cac14540b9 100755 --- a/scripts/run-tests.sh +++ b/scripts/run-tests.sh @@ -380,6 +380,9 @@ run_unit_tests() { run_test_step "buzz-relay invitation cutoff configuration" \ cargo test -p buzz-relay --lib config::invite_cutoff_tests:: -- --nocapture + run_test_step "buzz-relay mesh receive lifetime regression" \ + cargo test -p buzz-relay --lib mesh_boot::tests::demo_echo_retains_pending_receive_across_housekeeping_ticks -- --exact --nocapture + run_test_step "buzz-relay boot lifecycle tests" \ cargo test -p buzz-relay --test boot_lifecycle -- --nocapture } From f6ba54686609056c2600754d665cb3060e7c4600 Mon Sep 17 00:00:00 2001 From: Brad Seiler Date: Fri, 9 Oct 2026 19:37:05 -0400 Subject: [PATCH 06/34] test(relay): initialize scraper errors from each observation Co-authored-by: Codex Signed-off-by: Brad Seiler (cherry picked from commit d43b957a5bfc4bd108e915be59db0a6af27d9798) Signed-off-by: Brad Seiler Signed-off-by: Local Builder <1c476cb1127ea270cb900bdc3891060140bdd1173f91498030ee9b4db01c2f20@buzz.block.builderlab.xyz> --- crates/buzz-relay/tests/boot_lifecycle.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/buzz-relay/tests/boot_lifecycle.rs b/crates/buzz-relay/tests/boot_lifecycle.rs index 753200b47be..ad6f20f17bd 100644 --- a/crates/buzz-relay/tests/boot_lifecycle.rs +++ b/crates/buzz-relay/tests/boot_lifecycle.rs @@ -177,7 +177,7 @@ fn wait_for_scraped_metric(process: &mut RelayProcess, port: u16, needle: &str) 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 = None; + let mut last_error: Option; let deadline = Instant::now() + METRICS_SCRAPE_DEADLINE; loop { assert!( From 19ecad9ad2a6930499082bb1b0da587f4db01f9b Mon Sep 17 00:00:00 2001 From: Brad Seiler Date: Fri, 9 Oct 2026 20:07:11 -0400 Subject: [PATCH 07/34] test(relay): extract exclusive invite fixture without redundant conversion Co-authored-by: Codex Signed-off-by: Brad Seiler Signed-off-by: Local Builder <1c476cb1127ea270cb900bdc3891060140bdd1173f91498030ee9b4db01c2f20@buzz.block.builderlab.xyz> --- .../src/api/invites/postgres_tests/restriction_tests.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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 index 0f1a69d9b2c..392fa206c6a 100644 --- a/crates/buzz-relay/src/api/invites/postgres_tests/restriction_tests.rs +++ b/crates/buzz-relay/src/api/invites/postgres_tests/restriction_tests.rs @@ -204,9 +204,9 @@ async fn cutoff_rejects_only_v1_and_restriction_lookup_failure_never_admits() { 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 mut state = Arc::try_unwrap(state) - .ok() - .expect("exclusive fixture state"); + 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( From 34795a3675f29d93bf3f0e98f9877061e2d90435 Mon Sep 17 00:00:00 2001 From: Brad Seiler Date: Fri, 9 Oct 2026 20:19:18 -0400 Subject: [PATCH 08/34] test(relay): expose invitation restrictions to PostgreSQL discovery Co-authored-by: Codex Signed-off-by: Brad Seiler Signed-off-by: Local Builder <1c476cb1127ea270cb900bdc3891060140bdd1173f91498030ee9b4db01c2f20@buzz.block.builderlab.xyz> --- .../postgres_tests/restriction_tests.rs | 464 +++++++++--------- 1 file changed, 233 insertions(+), 231 deletions(-) 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 index 392fa206c6a..be0dd131657 100644 --- a/crates/buzz-relay/src/api/invites/postgres_tests/restriction_tests.rs +++ b/crates/buzz-relay/src/api/invites/postgres_tests/restriction_tests.rs @@ -1,254 +1,256 @@ -use super::*; +mod postgres_tests { + use super::super::*; -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) - .await - .unwrap(); - (state, host, community, owner) -} + 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) + .await + .unwrap(); + (state, host, community, owner) + } -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() -} + 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, + #[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 - .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( + .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", - &claimant, - serde_json::json!({"code":bearer}).to_string(), + &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, &claimant.public_key().to_hex()) - .await - .unwrap()); - let unrestricted = Keys::generate(); - assert_eq!( - post_json( - state, + assert!(!state + .db + .is_relay_member(community, &agent.public_key().to_hex()) + .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", - &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()); - 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() + &other_agent, + serde_json::json!({"code":code}).to_string(), + &tag, ) - .await - .unwrap()); -} + .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(), + #[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 - .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"); -} + .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_lookup_failure_never_admits() { - 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( + #[tokio::test] + #[ignore = "requires Postgres"] + async fn cutoff_rejects_only_v1_and_restriction_lookup_failure_never_admits() { + 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":code}).to_string() + serde_json::json!({"code":legacy}).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; - assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR); - assert!(!state - .db - .is_relay_member(community, &claimant.public_key().to_hex()) - .await - .unwrap()); + .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; + assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR); + assert!(!state + .db + .is_relay_member(community, &claimant.public_key().to_hex()) + .await + .unwrap()); + } } From e6b00214b1dd382bf45d533aead5b5cf3cfa8321 Mon Sep 17 00:00:00 2001 From: Brad Seiler Date: Fri, 9 Oct 2026 20:25:54 -0400 Subject: [PATCH 09/34] test(db): derive push lease fixture time from persisted receipt Avoid comparing host and PostgreSQL clocks in the async enqueue guard regression. Assert strict lease ordering before releasing activation. Co-authored-by: Codex Signed-off-by: Brad Seiler (cherry picked from commit 893f81ffed0fa50048f786c7f698e9b8bf3a2ac6) Signed-off-by: Brad Seiler Signed-off-by: Local Builder <1c476cb1127ea270cb900bdc3891060140bdd1173f91498030ee9b4db01c2f20@buzz.block.builderlab.xyz> --- .../store/event_follow_up_postgres_tests.rs | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) 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 68b68e20e21..e6beb22b514 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!( From c674d64354ef804814c96d7be4b7a075aa1cf0ed Mon Sep 17 00:00:00 2001 From: Brad Seiler Date: Sun, 11 Oct 2026 20:12:49 -0400 Subject: [PATCH 10/34] fix(db): commit invitation ownership on admission Co-authored-by: Codex Signed-off-by: Brad Seiler Signed-off-by: Local Builder <1c476cb1127ea270cb900bdc3891060140bdd1173f91498030ee9b4db01c2f20@buzz.block.builderlab.xyz> --- TESTING.md | 3 + crates/buzz-db/src/error.rs | 4 + crates/buzz-db/src/store/relay_invite.rs | 135 ++++++++++--- .../src/store/relay_invite/claim_owner.rs | 145 ++++++++++++++ .../owner_admission_postgres_tests.rs | 112 +++++++++++ crates/buzz-db/src/store/relay_members.rs | 60 +++--- crates/buzz-relay/src/api/invites.rs | 112 ++++------- .../buzz-relay/src/api/invites/claim_auth.rs | 58 ++++++ .../postgres_tests/owner_admission_tests.rs | 177 ++++++++++++++++++ .../postgres_tests/restriction_tests.rs | 43 ++++- 10 files changed, 716 insertions(+), 133 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-relay/src/api/invites/claim_auth.rs create mode 100644 crates/buzz-relay/src/api/invites/postgres_tests/owner_admission_tests.rs diff --git a/TESTING.md b/TESTING.md index f4a4bde5e6b..44050532051 100644 --- a/TESTING.md +++ b/TESTING.md @@ -151,6 +151,9 @@ the same machine. 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. +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. 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 diff --git a/crates/buzz-db/src/error.rs b/crates/buzz-db/src/error.rs index e0f3f3e27b6..258458deed6 100644 --- a/crates/buzz-db/src/error.rs +++ b/crates/buzz-db/src/error.rs @@ -41,6 +41,10 @@ pub enum DbError { #[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/store/relay_invite.rs b/crates/buzz-db/src/store/relay_invite.rs index c8fb4339a11..2697b285b87 100644 --- a/crates/buzz-db/src/store/relay_invite.rs +++ b/crates/buzz-db/src/store/relay_invite.rs @@ -29,6 +29,9 @@ use uuid::Uuid; use crate::error::Result; use crate::{CommunityId, Db}; +mod claim_owner; +pub use claim_owner::{claim_relay_membership_with_owner, MembershipClaimResult}; + /// 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. @@ -61,6 +64,26 @@ pub enum ClaimOutcome { 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. #[derive(Debug)] pub struct MintedInvite { @@ -304,6 +327,29 @@ pub async fn claim_relay_invite( claimer_pubkey: &str, policy_version: Option<&str>, ) -> Result { + Ok(claim_relay_invite_with_owner( + pool, + community, + token_hash, + claimer_pubkey, + policy_version, + None, + ) + .await? + .outcome) +} + +/// Claim an invite with an owner verified by the caller, without pre-admission writes. +/// Restriction and first-write-wins owner checks hold the admission lock; ownership +/// commits atomically with membership, policy evidence, and invite consumption. +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>, +) -> Result { let mut tx = crate::begin_community_write_transaction( pool, community, @@ -311,9 +357,9 @@ pub async fn claim_relay_invite( ) .await?; - lock_admission(tx.conn(), community).await?; - if principal_restricted(tx.conn(), community, claimer_pubkey).await? { - return Ok(ClaimOutcome::Restricted); + lock_admission(&mut tx, community).await?; + if claim_owner::claimant_restricted(&mut tx, community, claimer_pubkey, verified_owner).await? { + return Ok(ClaimOutcome::Restricted.into()); } // 2. SELECT FOR UPDATE — lock the invite row for the duration of this txn. @@ -332,7 +378,7 @@ pub async fn claim_relay_invite( let Some(invite) = row else { tx.rollback().await?; log_claim_outcome(community, None, "invalid", None, None); - return Ok(ClaimOutcome::Invalid); + return Ok(ClaimOutcome::Invalid.into()); }; let invite_id: uuid::Uuid = invite.try_get("id")?; @@ -352,13 +398,13 @@ pub async fn claim_relay_invite( max_uses, Some(use_count), ); - return Ok(ClaimOutcome::Expired); + return Ok(ClaimOutcome::Expired.into()); } let revoked_at: Option> = invite.try_get("revoked_at")?; let issuer: String = invite.try_get("created_by")?; - if revoked_at.is_some() || principal_restricted(tx.conn(), community, &issuer).await? { - return Ok(ClaimOutcome::Revoked); + if revoked_at.is_some() || principal_restricted(&mut tx, community, &issuer).await? { + return Ok(ClaimOutcome::Revoked.into()); } let uses_remaining = || max_uses.map(|mu| mu - use_count); @@ -384,6 +430,9 @@ pub async fn claim_relay_invite( .execute(tx.conn()) .await?; } + let owner = + claim_owner::persist_claim_owner(&mut tx, community, claimer_pubkey, verified_owner) + .await?; tx.commit().await?; log_claim_outcome( community, @@ -392,9 +441,13 @@ pub async fn claim_relay_invite( max_uses, Some(use_count), ); - return Ok(ClaimOutcome::AlreadyMember { - use_count, - uses_remaining: uses_remaining(), + return Ok(InviteClaimResult { + outcome: ClaimOutcome::AlreadyMember { + use_count, + uses_remaining: uses_remaining(), + }, + owner_link_created: owner.link_created, + users_created: owner.users_created, }); } @@ -409,7 +462,7 @@ pub async fn claim_relay_invite( max_uses, Some(use_count), ); - return Ok(ClaimOutcome::Exhausted); + return Ok(ClaimOutcome::Exhausted.into()); } } @@ -443,6 +496,9 @@ pub async fn claim_relay_invite( } if !inserted { + let owner = + claim_owner::persist_claim_owner(&mut tx, community, claimer_pubkey, verified_owner) + .await?; tx.commit().await?; log_claim_outcome( community, @@ -451,9 +507,13 @@ pub async fn claim_relay_invite( max_uses, Some(use_count), ); - return Ok(ClaimOutcome::AlreadyMember { - use_count, - uses_remaining: uses_remaining(), + return Ok(InviteClaimResult { + outcome: ClaimOutcome::AlreadyMember { + use_count, + uses_remaining: uses_remaining(), + }, + owner_link_created: owner.link_created, + users_created: owner.users_created, }); } @@ -466,7 +526,10 @@ pub async fn claim_relay_invite( .execute(tx.conn()) .await?; - // 11. Commit. + // 11. Commit membership, evidence, consumption, and verified ownership together. + let owner = + claim_owner::persist_claim_owner(&mut tx, community, claimer_pubkey, verified_owner) + .await?; tx.commit().await?; let new_uses_remaining = max_uses.map(|mu| mu - new_use_count); @@ -479,9 +542,13 @@ pub async fn claim_relay_invite( Some(new_use_count), ); - Ok(ClaimOutcome::Joined { - use_count: new_use_count, - uses_remaining: new_uses_remaining, + Ok(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, }) } @@ -502,6 +569,27 @@ impl Db { mint_relay_invite(&self.pool, community, created_by, ttl_secs, max_uses).await } + /// Claim with verified owner provenance, returning committed owner-link creation. + #[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>, + ) -> Result { + claim_relay_invite_with_owner( + &self.pool, + community, + token_hash, + claimer_pubkey, + policy_version, + verified_owner, + ) + .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 { @@ -540,7 +628,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") @@ -575,7 +663,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) @@ -606,11 +694,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", ) @@ -1019,3 +1107,6 @@ mod revocation_postgres_tests; #[cfg(test)] #[path = "relay_invite/upgrade_postgres_tests.rs"] mod upgrade_postgres_tests; + +#[cfg(test)] +mod owner_admission_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 00000000000..5eeaf5b1d57 --- /dev/null +++ b/crates/buzz-db/src/store/relay_invite/claim_owner.rs @@ -0,0 +1,145 @@ +use crate::error::Result; +use crate::{CommunityId, DbError}; +use sqlx::{PgPool, Postgres, Transaction}; + +/// 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 Transaction<'_, Postgres>, + 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 Transaction<'_, Postgres>, + 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; + 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>, +) -> Result { + let connection = crate::observability::acquire_writer( + pool, + crate::observability::WriterOperation::Authorization, + ) + .await?; + let mut tx = sqlx::Transaction::begin(connection, None).await?; + crate::deletion::DeletionStore::new(pool.clone()) + .guard_transaction(&mut tx, community) + .await?; + super::lock_admission(&mut tx, community).await?; + if claimant_restricted(&mut tx, 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(&mut *tx) + .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(&mut *tx) + .await?; + } + + let owner = persist_claim_owner(&mut tx, community, pubkey, verified_owner).await?; + tx.commit().await?; + Ok(MembershipClaimResult { + inserted, + owner_link_created: owner.link_created, + users_created: owner.users_created, + }) +} 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 00000000000..788af47da3d --- /dev/null +++ b/crates/buzz-db/src/store/relay_invite/owner_admission_postgres_tests.rs @@ -0,0 +1,112 @@ +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) + ) + .await + .is_err()); + assert!(claim_relay_membership_with_owner( + &pool, + community, + &claimant, + "member", + Some("too-short"), + Some(&owner) + ) + .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)) + .await + .unwrap(); + assert!(matches!(first.outcome, ClaimOutcome::Joined { .. })); + assert!(first.owner_link_created); + assert_eq!(first.users_created, 2); + let retry = + claim_relay_invite_with_owner(&pool, community, &hash, &claimant, None, Some(&owner)) + .await + .unwrap(); + assert!(matches!(retry.outcome, ClaimOutcome::AlreadyMember { .. })); + assert!(!retry.owner_link_created); + assert_eq!(retry.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), + ) + .await + .unwrap(); + assert!(legacy.inserted && legacy.owner_link_created); + assert_eq!(legacy.users_created, 1); + let retry = claim_relay_membership_with_owner( + &pool, + community, + &legacy_agent, + "member", + None, + Some(&owner), + ) + .await + .unwrap(); + assert!(!retry.inserted && !retry.owner_link_created); + assert_eq!(retry.users_created, 0); +} diff --git a/crates/buzz-db/src/store/relay_members.rs b/crates/buzz-db/src/store/relay_members.rs index 66d3785d59c..ed48132f77e 100644 --- a/crates/buzz-db/src/store/relay_members.rs +++ b/crates/buzz-db/src/store/relay_members.rs @@ -182,43 +182,16 @@ pub async fn claim_relay_membership( role: &str, policy_version: Option<&str>, ) -> Result { - let mut tx = crate::begin_community_write_transaction( + Ok(super::relay_invite::claim_relay_membership_with_owner( pool, community, - observability::WriterOperation::Authorization, + pubkey, + role, + policy_version, + None, ) - .await?; - super::relay_invite::lock_admission(tx.conn(), community).await?; - if super::relay_invite::principal_restricted(tx.conn(), community, pubkey).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?; - } - - tx.commit().await?; - Ok(inserted) + .inserted) } /// Returns whether a member has persisted acceptance evidence for a policy version. @@ -1059,6 +1032,27 @@ impl Db { claim_relay_membership(&self.pool, community, pubkey, role, policy_version).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>, + ) -> Result { + super::relay_invite::claim_relay_membership_with_owner( + &self.pool, + community, + pubkey, + role, + policy_version, + verified_owner, + ) + .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-relay/src/api/invites.rs b/crates/buzz-relay/src/api/invites.rs index d06ca664a6c..82005849ae9 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,64 +228,6 @@ 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, - signed_created_at, - } = bridge::verify_nip98_exempt_invite_claim(headers, "POST", &url, Some(body))?; - bridge::check_nip98_replay(state, &tenant, event_id_bytes).await?; - - materialize_invite_owner(state, &tenant, &pubkey, headers, signed_created_at).await?; - Ok((tenant, pubkey)) -} - -async fn materialize_invite_owner( - state: &AppState, - tenant: &buzz_core::TenantContext, - pubkey: &nostr::PublicKey, - headers: &HeaderMap, - signed_created_at: Option, -) -> Result<(), (StatusCode, Json)> { - // The claim remains membership-exempt. Only cryptographically verified - // owner provenance is persisted, so the transactional restriction lookup - // also covers an agent onboarding for the first time. - 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, - ) { - if !super::relay_members::materialize_nip_oa_owner(state, tenant, pubkey, &owner).await { - return Err(api_error( - StatusCode::SERVICE_UNAVAILABLE, - "restriction lookup unavailable", - )); - } - } - Ok(()) -} - fn map_mint_error(error: buzz_db::DbError) -> (StatusCode, Json) { match error { buzz_db::DbError::InviteRestricted => api_error(StatusCode::FORBIDDEN, "invite_restricted"), @@ -369,10 +313,18 @@ async fn mint_invite_checked( return e.into_response(); } - if let Err(error) = - materialize_invite_owner(&state, &tenant, &pubkey, &headers, signed_created_at).await - { - return error.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, + ) { + if !super::relay_members::materialize_nip_oa_owner(&state, &tenant, &pubkey, &owner).await { + return api_error( + StatusCode::SERVICE_UNAVAILABLE, + "restriction lookup unavailable", + ) + .into_response(); + } } match mint_invite_inner(&state, body, tenant, pubkey).await { @@ -462,7 +414,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( @@ -475,6 +428,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 --- @@ -499,7 +453,7 @@ pub async fn claim_invite( let token_hash = hash_v2_code(&request.code); let outcome = state .db - .claim_relay_invite( + .claim_relay_invite_with_owner( tenant.community(), &token_hash, &claimer_hex, @@ -508,11 +462,16 @@ pub async fn claim_invite( .join_policy .as_ref() .map(|policy| policy.version.as_str()), + owner_hex.as_deref(), ) .await - .map_err(|e| internal_error(&format!("v2 invite claim: {e}")))?; - - return match outcome { + .map_err(map_claim_error)?; + metrics::counter!("buzz_users_created_total", "community" => tenant.host().to_owned()) + .increment(outcome.users_created); + if outcome.owner_link_created { + state.disconnect_unowned_agent_clusterwide(&tenant, pubkey.as_bytes()); + } + return match outcome.outcome { buzz_db::relay_invite::ClaimOutcome::Joined { .. } => { tracing::info!( community = %tenant.community(), @@ -584,9 +543,9 @@ pub async fn claim_invite( .map_err(|_| api_error(StatusCode::FORBIDDEN, "join_policy_required"))?; } - let was_inserted = state + let claim = state .db - .claim_relay_membership( + .claim_relay_membership_with_owner( tenant.community(), &claimer_hex, &payload.r, @@ -595,14 +554,16 @@ pub async fn claim_invite( .join_policy .as_ref() .map(|policy| policy.version.as_str()), + owner_hex.as_deref(), ) .await - .map_err(|e| match e { - buzz_db::DbError::InviteRestricted => { - api_error(StatusCode::FORBIDDEN, "invite_restricted") - } - e => internal_error(&format!("invite claim insert: {e}")), - })?; + .map_err(map_claim_error)?; + metrics::counter!("buzz_users_created_total", "community" => tenant.host().to_owned()) + .increment(claim.users_created); + if claim.owner_link_created { + state.disconnect_unowned_agent_clusterwide(&tenant, pubkey.as_bytes()); + } + let was_inserted = claim.inserted; if was_inserted { tracing::info!( @@ -654,6 +615,7 @@ 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; 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 00000000000..756b2ba848a --- /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 00000000000..5288accb9d7 --- /dev/null +++ b/crates/buzz-relay/src/api/invites/postgres_tests/owner_admission_tests.rs @@ -0,0 +1,177 @@ +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 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 index be0dd131657..880e0abbdd4 100644 --- a/crates/buzz-relay/src/api/invites/postgres_tests/restriction_tests.rs +++ b/crates/buzz-relay/src/api/invites/postgres_tests/restriction_tests.rs @@ -1,7 +1,8 @@ -mod postgres_tests { +pub(super) mod postgres_tests { use super::super::*; - async fn fixture() -> (Arc, String, buzz_core::CommunityId, Keys) { + 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 @@ -21,7 +22,7 @@ mod postgres_tests { (state, host, community, owner) } - async fn post_with_owner_tag( + pub(in crate::api::invites::postgres_tests) async fn post_with_owner_tag( state: Arc, host: &str, path: &str, @@ -138,6 +139,42 @@ mod postgres_tests { .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( From 1a4f366fd4efff6217f6e73063d654fddff3efdf Mon Sep 17 00:00:00 2001 From: Brad Seiler Date: Sun, 11 Oct 2026 20:12:55 -0400 Subject: [PATCH 11/34] test(relay): guard destructive invitation fixture Co-authored-by: Codex Signed-off-by: Brad Seiler Signed-off-by: Local Builder <1c476cb1127ea270cb900bdc3891060140bdd1173f91498030ee9b4db01c2f20@buzz.block.builderlab.xyz> --- .../postgres_tests/restriction_tests.rs | 50 +++++++++++++++++-- 1 file changed, 47 insertions(+), 3 deletions(-) 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 index 880e0abbdd4..7f4f61baf2b 100644 --- a/crates/buzz-relay/src/api/invites/postgres_tests/restriction_tests.rs +++ b/crates/buzz-relay/src/api/invites/postgres_tests/restriction_tests.rs @@ -238,6 +238,23 @@ pub(super) mod postgres_tests { #[tokio::test] #[ignore = "requires Postgres"] async fn cutoff_rejects_only_v1_and_restriction_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); @@ -283,11 +300,38 @@ pub(super) mod postgres_tests { serde_json::json!({"code":code}).to_string(), ) .await; - assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR); - assert!(!state + 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()); + .unwrap(); + assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR); + assert!(!membership.unwrap()); + } + + 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)); + } } } From d6b9fbc240ab8bda82dfc0962d6f56f45902beae Mon Sep 17 00:00:00 2001 From: Brad Seiler Date: Sun, 11 Oct 2026 20:13:01 -0400 Subject: [PATCH 12/34] fix(db): distinguish issuer timeout denials Co-authored-by: Codex Signed-off-by: Brad Seiler Signed-off-by: Local Builder <1c476cb1127ea270cb900bdc3891060140bdd1173f91498030ee9b4db01c2f20@buzz.block.builderlab.xyz> --- TESTING.md | 2 + crates/buzz-db/src/store/relay_invite.rs | 7 ++- .../owner_admission_postgres_tests.rs | 48 +++++++++++++++++++ crates/buzz-relay/src/api/invites.rs | 3 +- 4 files changed, 58 insertions(+), 2 deletions(-) diff --git a/TESTING.md b/TESTING.md index 44050532051..719bb774e3e 100644 --- a/TESTING.md +++ b/TESTING.md @@ -151,6 +151,8 @@ the same machine. 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. +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. diff --git a/crates/buzz-db/src/store/relay_invite.rs b/crates/buzz-db/src/store/relay_invite.rs index 2697b285b87..22167b85ac4 100644 --- a/crates/buzz-db/src/store/relay_invite.rs +++ b/crates/buzz-db/src/store/relay_invite.rs @@ -60,6 +60,8 @@ pub enum ClaimOutcome { 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, } @@ -403,9 +405,12 @@ pub async fn claim_relay_invite_with_owner( let revoked_at: Option> = invite.try_get("revoked_at")?; let issuer: String = invite.try_get("created_by")?; - if revoked_at.is_some() || principal_restricted(&mut tx, community, &issuer).await? { + if revoked_at.is_some() { return Ok(ClaimOutcome::Revoked.into()); } + if principal_restricted(&mut tx, community, &issuer).await? { + return Ok(ClaimOutcome::IssuerRestricted.into()); + } let uses_remaining = || max_uses.map(|mu| mu - use_count); 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 index 788af47da3d..9c9fe6de5af 100644 --- 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 @@ -110,3 +110,51 @@ async fn successful_claim_reports_only_committed_user_creation() { assert!(!retry.inserted && !retry.owner_link_created); assert_eq!(retry.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( + &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( + &pool, + community, + &hash_v2_code(&invite.code), + &claimant, + None + ) + .await + .unwrap(), + ClaimOutcome::Joined { use_count: 1, .. } + )); +} diff --git a/crates/buzz-relay/src/api/invites.rs b/crates/buzz-relay/src/api/invites.rs index 82005849ae9..3384f6023ec 100644 --- a/crates/buzz-relay/src/api/invites.rs +++ b/crates/buzz-relay/src/api/invites.rs @@ -508,7 +508,8 @@ pub async fn claim_invite( buzz_db::relay_invite::ClaimOutcome::Exhausted => { Err(api_error(StatusCode::FORBIDDEN, "invite_exhausted")) } - buzz_db::relay_invite::ClaimOutcome::Revoked => { + 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 => { From c83c9bb1c373f682e0dd57bb78e9bb1d680a90e7 Mon Sep 17 00:00:00 2001 From: Brad Seiler Date: Sun, 11 Oct 2026 20:13:06 -0400 Subject: [PATCH 13/34] fix(db): log transactional claim denials Co-authored-by: Codex Signed-off-by: Brad Seiler Signed-off-by: Local Builder <1c476cb1127ea270cb900bdc3891060140bdd1173f91498030ee9b4db01c2f20@buzz.block.builderlab.xyz> --- crates/buzz-db/src/store/relay_invite.rs | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/crates/buzz-db/src/store/relay_invite.rs b/crates/buzz-db/src/store/relay_invite.rs index 22167b85ac4..f6df89c81e4 100644 --- a/crates/buzz-db/src/store/relay_invite.rs +++ b/crates/buzz-db/src/store/relay_invite.rs @@ -361,6 +361,8 @@ pub async fn claim_relay_invite_with_owner( lock_admission(&mut tx, community).await?; if claim_owner::claimant_restricted(&mut tx, community, claimer_pubkey, verified_owner).await? { + tx.rollback().await?; + log_claim_outcome(community, None, "restricted", None, None); return Ok(ClaimOutcome::Restricted.into()); } @@ -405,11 +407,17 @@ pub async fn claim_relay_invite_with_owner( let revoked_at: Option> = invite.try_get("revoked_at")?; let issuer: String = invite.try_get("created_by")?; - if revoked_at.is_some() { - return Ok(ClaimOutcome::Revoked.into()); - } - if principal_restricted(&mut tx, community, &issuer).await? { - return Ok(ClaimOutcome::IssuerRestricted.into()); + let denial = if revoked_at.is_some() { + Some((ClaimOutcome::Revoked, "revoked")) + } else if principal_restricted(&mut tx, community, &issuer).await? { + Some((ClaimOutcome::IssuerRestricted, "issuer_restricted")) + } else { + None + }; + if let Some((outcome, label)) = denial { + tx.rollback().await?; + log_claim_outcome(community, Some(invite_id), label, max_uses, Some(use_count)); + return Ok(outcome.into()); } let uses_remaining = || max_uses.map(|mu| mu - use_count); From 2ab99e9b7e8fa4f716ac1ce826a98e3e23baeaab Mon Sep 17 00:00:00 2001 From: Local Builder <1c476cb1127ea270cb900bdc3891060140bdd1173f91498030ee9b4db01c2f20@buzz.block.builderlab.xyz> Date: Tue, 13 Oct 2026 13:46:18 -0400 Subject: [PATCH 14/34] fix(db): own invite revocation in writer transactions Acquire admission before domain row locks in restriction and owner writers. Revoke invitations in the same commit without SQL triggers, while keeping existing owner links and ordinary user inserts lock-free. Signed-off-by: Local Builder <1c476cb1127ea270cb900bdc3891060140bdd1173f91498030ee9b4db01c2f20@buzz.block.builderlab.xyz> --- crates/buzz-db/src/store/moderation.rs | 6 +- .../buzz-db/src/store/relay_admin_actions.rs | 8 +- crates/buzz-db/src/store/relay_invite.rs | 5 + .../src/store/relay_invite/claim_owner.rs | 7 +- .../src/store/relay_invite/revocation.rs | 52 +++ .../relay_invite/revocation_postgres_tests.rs | 112 ++++-- .../relay_invite/upgrade_postgres_tests.rs | 7 +- .../writer_protocol_postgres_tests.rs | 357 ++++++++++++++++++ crates/buzz-db/src/store/user.rs | 29 ++ crates/buzz-relay/src/api/bridge.rs | 22 +- crates/buzz-relay/src/handlers/ingest.rs | 25 +- migrations/0061_invite_revocation.sql | 56 +-- .../functions/backfill_invite_revocations.sql | 5 + schema/functions/lock_invite_restrictions.sql | 7 - .../revoke_banned_issuer_invites.sql | 15 - .../revoke_restricted_agent_invites.sql | 16 - schema/schema.sql | 3 - schema/triggers/community_bans.sql | 5 - schema/triggers/users.sql | 5 - scripts/reconcile-schema-after-pgschema.sql | 4 +- 20 files changed, 596 insertions(+), 150 deletions(-) create mode 100644 crates/buzz-db/src/store/relay_invite/revocation.rs create mode 100644 crates/buzz-db/src/store/relay_invite/writer_protocol_postgres_tests.rs delete mode 100644 schema/functions/lock_invite_restrictions.sql delete mode 100644 schema/functions/revoke_banned_issuer_invites.sql delete mode 100644 schema/functions/revoke_restricted_agent_invites.sql diff --git a/crates/buzz-db/src/store/moderation.rs b/crates/buzz-db/src/store/moderation.rs index 00bcb3af879..0452a51a8cf 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 305ab18de29..01c2b972360 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,8 @@ 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 +572,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 +620,8 @@ 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 f6df89c81e4..27c5cf83c7c 100644 --- a/crates/buzz-db/src/store/relay_invite.rs +++ b/crates/buzz-db/src/store/relay_invite.rs @@ -30,7 +30,9 @@ use crate::error::Result; 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 @@ -1123,3 +1125,6 @@ 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 index 5eeaf5b1d57..e7f96a48f91 100644 --- a/crates/buzz-db/src/store/relay_invite/claim_owner.rs +++ b/crates/buzz-db/src/store/relay_invite/claim_owner.rs @@ -81,7 +81,12 @@ pub(super) async fn persist_claim_owner( .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; + .bind(community.as_uuid()).bind(&claimant_bytes).bind(&owner_bytes).execute(&mut **tx).await?.rows_affected() > 0; + // Defensive parity with other owner writers; admission already rejects banned owners. + if link_created { + super::revoke_restricted_agent_invites(tx, community, &claimant_bytes, &owner_bytes) + .await?; + } Ok(OwnerPersistence { link_created, users_created, 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 00000000000..adf0846db05 --- /dev/null +++ b/crates/buzz-db/src/store/relay_invite/revocation.rs @@ -0,0 +1,52 @@ +//! Application-owned revocation, under the admission lock and in the writer's commit. +use crate::{error::Result, CommunityId}; +use sqlx::{Postgres, Transaction}; + +/// 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 Transaction<'_, Postgres>, + 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 Transaction<'_, Postgres>, + 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 index a472cab39c4..03bfb5311f0 100644 --- a/crates/buzz-db/src/store/relay_invite/revocation_postgres_tests.rs +++ b/crates/buzz-db/src/store/relay_invite/revocation_postgres_tests.rs @@ -2,7 +2,7 @@ use super::*; use crate::moderation::{ban_member, unban_member}; use uuid::Uuid; -async fn fixture() -> (PgPool, CommunityId, String, String) { +pub(super) async fn fixture() -> (PgPool, CommunityId, String, String) { let pool = PgPool::connect(&crate::test_support::database_url()) .await .unwrap(); @@ -186,15 +186,10 @@ async fn issuer_agent_revocation_survives_owner_unban() { ); } -async fn queued_claim( - pool: &PgPool, - community: CommunityId, - code: &str, - claimant: String, -) -> tokio::task::JoinHandle> { +pub(super) async fn named_pool() -> (PgPool, String) { let name = format!("invite-race-{}", Uuid::new_v4()); let connection_name = name.clone(); - let claim_pool = sqlx::postgres::PgPoolOptions::new() + let pool = sqlx::postgres::PgPoolOptions::new() .max_connections(1) .after_connect(move |conn, _| { let name = connection_name.clone(); @@ -209,19 +204,49 @@ async fn queued_claim( .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(&claim_pool, community, &hash, &claimant, None).await }); - 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='advisory' AND NOT l.granted)") - .bind(&name).fetch_one(pool).await.unwrap(); - if waiting {break;} - assert!(!claim.is_finished(),"claim bypassed the production admission lock"); - tokio::task::yield_now().await; - } - }).await.expect("claim must observably wait on the admission lock"); + wait_for_lock(pool, &name, "advisory", &claim).await; claim } @@ -233,12 +258,40 @@ async fn claimant_ban_and_timeout_commit_before_queued_claim_without_consuming_a let invite = mint_relay_invite(&pool, community, &issuer, 3600, Some(1)) .await .unwrap(); - let mut restriction = pool.begin().await.unwrap(); - sqlx::query("INSERT INTO community_bans(community_id,pubkey,banned,muted_until,actor_pubkey) VALUES($1,$2,$3,CASE WHEN $3 THEN NULL ELSE clock_timestamp()+interval '1 hour' END,$4)") - .bind(community.as_uuid()).bind(vec![2u8;32]).bind(!timeout).bind(vec![3u8;32]) - .execute(&mut *restriction).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; - restriction.commit().await.unwrap(); + 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", @@ -274,16 +327,21 @@ async fn owner_materialization_commits_before_queued_agent_claim() { ban_member(&pool, community, &[4; 32], &[3; 32], None, None) .await .unwrap(); - let mut owner = pool.begin().await.unwrap(); - sqlx::query("UPDATE users SET agent_owner_pubkey=$3 WHERE community_id=$1 AND pubkey=$2") + 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]) - .bind(vec![4u8; 32]) - .execute(&mut *owner) + .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; - owner.commit().await.unwrap(); + row_lock.commit().await.unwrap(); + assert!(owner.await.unwrap().unwrap()); assert_eq!(claim.await.unwrap().unwrap(), ClaimOutcome::Restricted); } 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 index bde32e697a8..74ad7988651 100644 --- a/crates/buzz-db/src/store/relay_invite/upgrade_postgres_tests.rs +++ b/crates/buzz-db/src/store/relay_invite/upgrade_postgres_tests.rs @@ -76,7 +76,7 @@ async fn assert_repaired_and_fenced(pool: &PgPool, communities: &[Uuid]) { ); let triggers: i64=sqlx::query_scalar("SELECT count(*) FROM pg_trigger WHERE NOT tgisinternal AND tgname IN ('invite_restriction_lock','invite_issuer_revocation','invite_owner_lock','invite_owner_revocation','community_write_fence_relay_invites')") .fetch_one(pool).await.unwrap(); - assert_eq!(triggers, 5); + assert_eq!(triggers, 1, "only the community deletion fence remains"); } #[tokio::test] @@ -99,15 +99,10 @@ async fn migration_schema_desired_upgrade_reconciles_the_same_populated_history( .unwrap(); for sql in [ include_str!("../../../../../schema/functions/invite_admission_lock.sql"), - include_str!("../../../../../schema/functions/lock_invite_restrictions.sql"), - include_str!("../../../../../schema/functions/revoke_banned_issuer_invites.sql"), - include_str!("../../../../../schema/functions/revoke_restricted_agent_invites.sql"), include_str!("../../../../../schema/functions/backfill_invite_revocations.sql"), ] { sqlx::raw_sql(sql).execute(&pool).await.unwrap(); } - sqlx::raw_sql("CREATE TRIGGER invite_restriction_lock BEFORE INSERT OR UPDATE ON community_bans FOR EACH ROW EXECUTE FUNCTION lock_invite_restrictions(); CREATE TRIGGER invite_issuer_revocation AFTER INSERT OR UPDATE ON community_bans FOR EACH ROW EXECUTE FUNCTION revoke_banned_issuer_invites(); CREATE TRIGGER invite_owner_lock BEFORE INSERT OR UPDATE OF agent_owner_pubkey ON users FOR EACH ROW EXECUTE FUNCTION lock_invite_restrictions(); CREATE TRIGGER invite_owner_revocation AFTER INSERT OR UPDATE OF agent_owner_pubkey ON users FOR EACH ROW EXECUTE FUNCTION revoke_restricted_agent_invites();") - .execute(&pool).await.unwrap(); let still_unrepaired: i64 = sqlx::query_scalar("SELECT count(*) FROM relay_invites WHERE revoked_at IS NULL") .fetch_one(&pool) 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 00000000000..608ac81bb92 --- /dev/null +++ b/crates/buzz-db/src/store/relay_invite/writer_protocol_postgres_tests.rs @@ -0,0 +1,357 @@ +use super::revocation_postgres_tests::{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( + &pool, + community, + &hash_v2_code(&invite.code), + &claimant, + None + ) + .await + .unwrap(), + ClaimOutcome::Revoked + ); + } else { + assert_eq!( + claim_relay_invite( + &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( + &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(); +} diff --git a/crates/buzz-db/src/store/user.rs b/crates/buzz-db/src/store/user.rs index d767b56c756..f0197596650 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 9bc2940a81e..ab7d7f5df30 100644 --- a/crates/buzz-relay/src/api/bridge.rs +++ b/crates/buzz-relay/src/api/bridge.rs @@ -8699,6 +8699,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); \ @@ -8718,6 +8719,15 @@ pub(crate) mod postgres_tests { ) .await .expect("schema pool"); + for sql in [ + include_str!("../../../../schema/functions/community_deletion_lock_key.sql"), + include_str!("../../../../schema/functions/invite_admission_lock.sql"), + ] { + sqlx::raw_sql(sql) + .execute(&pool) + .await + .expect("writer lock functions"); + } Arc::get_mut(&mut state).expect("unique state").db = buzz_db::Db::from_pool(pool); let tenant = fresh_tenant("late-owner.test"); @@ -8803,12 +8813,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 @@ -8841,6 +8845,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/handlers/ingest.rs b/crates/buzz-relay/src/handlers/ingest.rs index de4a19217d2..b6483518ca8 100644 --- a/crates/buzz-relay/src/handlers/ingest.rs +++ b/crates/buzz-relay/src/handlers/ingest.rs @@ -7325,9 +7325,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 @@ -7335,6 +7334,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 \ @@ -7350,8 +7351,22 @@ mod postgres_tests { let pool = sqlx::PgPool::connect_with(options) .await .expect("schema pool"); + for sql in [ + include_str!("../../../../schema/functions/community_deletion_lock_key.sql"), + include_str!("../../../../schema/functions/invite_admission_lock.sql"), + ] { + sqlx::raw_sql(sql) + .execute(&pool) + .await + .expect("writer lock functions"); + } let state = build_canvas_ingest_state(&db_url, &pool).await; let community = buzz_core::tenant::CommunityId::from_uuid(Uuid::new_v4()); + sqlx::query("INSERT INTO communities(id, host) VALUES($1,'revoke-owner.test')") + .bind(community.as_uuid()) + .execute(&pool) + .await + .expect("writer community"); let tenant = TenantContext::resolved(community, "revoke-owner.test".to_string()); sqlx::query(sqlx::AssertSqlSafe(format!( "INSERT INTO {schema}.communities (id, host) VALUES ($1, 'revoke-owner.test')" @@ -7416,6 +7431,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/migrations/0061_invite_revocation.sql b/migrations/0061_invite_revocation.sql index 6555200622b..41eb0702f87 100644 --- a/migrations/0061_invite_revocation.sql +++ b/migrations/0061_invite_revocation.sql @@ -6,57 +6,11 @@ LANGUAGE sql AS $$ SELECT pg_advisory_xact_lock(hashtextextended('invite-admission:' || community::text, 0)); $$; -CREATE FUNCTION lock_invite_restrictions() RETURNS TRIGGER -LANGUAGE plpgsql AS $$ -BEGIN - PERFORM invite_admission_lock(NEW.community_id); - RETURN NEW; -END -$$; - --- Revocation is durable: neither unban nor expiry restores an issued bearer. -CREATE FUNCTION revoke_banned_issuer_invites() RETURNS TRIGGER -LANGUAGE plpgsql AS $$ -BEGIN - IF NEW.banned AND (NEW.ban_expires_at IS NULL OR NEW.ban_expires_at > clock_timestamp()) THEN - UPDATE relay_invites SET revoked_at = clock_timestamp() - WHERE community_id = NEW.community_id AND revoked_at IS NULL - AND (created_by = encode(NEW.pubkey, 'hex') OR created_by IN ( - SELECT encode(pubkey, 'hex') FROM users - WHERE community_id = NEW.community_id AND agent_owner_pubkey = NEW.pubkey - )); - END IF; - RETURN NEW; -END -$$; - --- Learning a verified owner must not revive invites issued by its restricted agent. -CREATE FUNCTION revoke_restricted_agent_invites() RETURNS TRIGGER -LANGUAGE plpgsql AS $$ -BEGIN - IF NEW.agent_owner_pubkey IS NOT NULL AND EXISTS ( - SELECT 1 FROM community_bans WHERE community_id = NEW.community_id - AND pubkey = NEW.agent_owner_pubkey AND banned - AND (ban_expires_at IS NULL OR ban_expires_at > clock_timestamp()) - ) THEN - UPDATE relay_invites SET revoked_at = clock_timestamp() - WHERE community_id = NEW.community_id AND created_by = encode(NEW.pubkey, 'hex') - AND revoked_at IS NULL; - END IF; - RETURN NEW; -END -$$; - -CREATE TRIGGER invite_restriction_lock BEFORE INSERT OR UPDATE ON community_bans -FOR EACH ROW EXECUTE FUNCTION lock_invite_restrictions(); -CREATE TRIGGER invite_issuer_revocation AFTER INSERT OR UPDATE ON community_bans -FOR EACH ROW EXECUTE FUNCTION revoke_banned_issuer_invites(); - -CREATE TRIGGER invite_owner_lock BEFORE INSERT OR UPDATE OF agent_owner_pubkey ON users -FOR EACH ROW EXECUTE FUNCTION lock_invite_restrictions(); -CREATE TRIGGER invite_owner_revocation AFTER INSERT OR UPDATE OF agent_owner_pubkey ON users -FOR EACH ROW EXECUTE FUNCTION revoke_restricted_agent_invites(); - +-- Live revocation is application-owned: supported restriction and owner writers +-- take invite_admission_lock before row locks and revoke in the same commit. +-- Rollout gate: drain all old writer pods before lifting bans or reopening +-- invitation admission, then rerun backfill_invite_revocations() under the +-- schema-destruction maintenance lock to repair bans committed during rollout. -- Include active bans and recorded prior bans, so lifting a ban before this -- migration cannot restore an invite issued before that ban. Scope every owner -- and audit lookup to the invitation's community. diff --git a/schema/functions/backfill_invite_revocations.sql b/schema/functions/backfill_invite_revocations.sql index abe8118b0be..e434de59905 100644 --- a/schema/functions/backfill_invite_revocations.sql +++ b/schema/functions/backfill_invite_revocations.sql @@ -1,3 +1,8 @@ +-- Live revocation is application-owned: supported restriction and owner writers +-- take invite_admission_lock before row locks and revoke in the same commit. +-- Rollout gate: drain all old writer pods before lifting bans or reopening +-- invitation admission, then rerun backfill_invite_revocations() under the +-- schema-destruction maintenance lock to repair bans committed during rollout. -- Include active bans and recorded prior bans, so lifting a ban before this -- migration cannot restore an invite issued before that ban. Scope every owner -- and audit lookup to the invitation's community. diff --git a/schema/functions/lock_invite_restrictions.sql b/schema/functions/lock_invite_restrictions.sql deleted file mode 100644 index c8aec1f2c69..00000000000 --- a/schema/functions/lock_invite_restrictions.sql +++ /dev/null @@ -1,7 +0,0 @@ -CREATE FUNCTION lock_invite_restrictions() RETURNS TRIGGER -LANGUAGE plpgsql AS $$ -BEGIN - PERFORM invite_admission_lock(NEW.community_id); - RETURN NEW; -END -$$; diff --git a/schema/functions/revoke_banned_issuer_invites.sql b/schema/functions/revoke_banned_issuer_invites.sql deleted file mode 100644 index beb40f1ca40..00000000000 --- a/schema/functions/revoke_banned_issuer_invites.sql +++ /dev/null @@ -1,15 +0,0 @@ --- Revocation is durable: neither unban nor expiry restores an issued bearer. -CREATE FUNCTION revoke_banned_issuer_invites() RETURNS TRIGGER -LANGUAGE plpgsql AS $$ -BEGIN - IF NEW.banned AND (NEW.ban_expires_at IS NULL OR NEW.ban_expires_at > clock_timestamp()) THEN - UPDATE relay_invites SET revoked_at = clock_timestamp() - WHERE community_id = NEW.community_id AND revoked_at IS NULL - AND (created_by = encode(NEW.pubkey, 'hex') OR created_by IN ( - SELECT encode(pubkey, 'hex') FROM users - WHERE community_id = NEW.community_id AND agent_owner_pubkey = NEW.pubkey - )); - END IF; - RETURN NEW; -END -$$; diff --git a/schema/functions/revoke_restricted_agent_invites.sql b/schema/functions/revoke_restricted_agent_invites.sql deleted file mode 100644 index 3a6d2359b79..00000000000 --- a/schema/functions/revoke_restricted_agent_invites.sql +++ /dev/null @@ -1,16 +0,0 @@ --- Learning a verified owner must not revive invites issued by its restricted agent. -CREATE FUNCTION revoke_restricted_agent_invites() RETURNS TRIGGER -LANGUAGE plpgsql AS $$ -BEGIN - IF NEW.agent_owner_pubkey IS NOT NULL AND EXISTS ( - SELECT 1 FROM community_bans WHERE community_id = NEW.community_id - AND pubkey = NEW.agent_owner_pubkey AND banned - AND (ban_expires_at IS NULL OR ban_expires_at > clock_timestamp()) - ) THEN - UPDATE relay_invites SET revoked_at = clock_timestamp() - WHERE community_id = NEW.community_id AND created_by = encode(NEW.pubkey, 'hex') - AND revoked_at IS NULL; - END IF; - RETURN NEW; -END -$$; diff --git a/schema/schema.sql b/schema/schema.sql index d8ed4f90193..93f56d23c43 100644 --- a/schema/schema.sql +++ b/schema/schema.sql @@ -79,9 +79,6 @@ CREATE EXTENSION IF NOT EXISTS pgcrypto; \i functions/profile_name_fields.sql \i functions/invite_admission_lock.sql -\i functions/lock_invite_restrictions.sql -\i functions/revoke_banned_issuer_invites.sql -\i functions/revoke_restricted_agent_invites.sql \i functions/backfill_invite_revocations.sql -- tables diff --git a/schema/triggers/community_bans.sql b/schema/triggers/community_bans.sql index 27c66ee1a2c..063e3ab4565 100644 --- a/schema/triggers/community_bans.sql +++ b/schema/triggers/community_bans.sql @@ -1,6 +1 @@ SELECT attach_community_write_fence('community_bans'); - -CREATE TRIGGER invite_restriction_lock BEFORE INSERT OR UPDATE ON community_bans -FOR EACH ROW EXECUTE FUNCTION lock_invite_restrictions(); -CREATE TRIGGER invite_issuer_revocation AFTER INSERT OR UPDATE ON community_bans -FOR EACH ROW EXECUTE FUNCTION revoke_banned_issuer_invites(); diff --git a/schema/triggers/users.sql b/schema/triggers/users.sql index 60bd2c5c761..5f65f3c763c 100644 --- a/schema/triggers/users.sql +++ b/schema/triggers/users.sql @@ -1,6 +1 @@ SELECT attach_community_write_fence('users'); - -CREATE TRIGGER invite_owner_lock BEFORE INSERT OR UPDATE OF agent_owner_pubkey ON users -FOR EACH ROW EXECUTE FUNCTION lock_invite_restrictions(); -CREATE TRIGGER invite_owner_revocation AFTER INSERT OR UPDATE OF agent_owner_pubkey ON users -FOR EACH ROW EXECUTE FUNCTION revoke_restricted_agent_invites(); diff --git a/scripts/reconcile-schema-after-pgschema.sql b/scripts/reconcile-schema-after-pgschema.sql index c673c1a7792..f3f46f37da5 100644 --- a/scripts/reconcile-schema-after-pgschema.sql +++ b/scripts/reconcile-schema-after-pgschema.sql @@ -243,8 +243,8 @@ BEGIN END IF; IF (SELECT count(*) FROM pg_trigger WHERE NOT tgisinternal AND tgname IN ('invite_restriction_lock', 'invite_issuer_revocation', - 'invite_owner_lock', 'invite_owner_revocation')) <> 4 THEN - RAISE EXCEPTION 'invitation revocation triggers missing from live catalog'; + 'invite_owner_lock', 'invite_owner_revocation')) <> 0 THEN + RAISE EXCEPTION 'invitation revocation must use supported application writers, not triggers'; END IF; END $$; From a3b7b181a86cae6d8e96fe7a3c5548ccd341f199 Mon Sep 17 00:00:00 2001 From: Local Builder <1c476cb1127ea270cb900bdc3891060140bdd1173f91498030ee9b4db01c2f20@buzz.block.builderlab.xyz> Date: Thu, 15 Oct 2026 18:09:56 -0400 Subject: [PATCH 15/34] fix(db): reconcile invitation admission with main Adapt invitation helpers to the community-admitted transaction API and move the unreleased revocation migration after the current schema changes. Exercise upgrades from the latest pre-revocation schema. Signed-off-by: Local Builder <1c476cb1127ea270cb900bdc3891060140bdd1173f91498030ee9b4db01c2f20@buzz.block.builderlab.xyz> --- .../buzz-db/src/store/relay_admin_actions.rs | 6 ++-- crates/buzz-db/src/store/relay_invite.rs | 14 +++++---- .../src/store/relay_invite/claim_owner.rs | 29 +++++++++---------- .../src/store/relay_invite/revocation.rs | 9 +++--- .../relay_invite/upgrade_postgres_tests.rs | 2 +- ...ocation.sql => 0065_invite_revocation.sql} | 0 scripts/reconcile-schema-after-pgschema.sql | 2 +- 7 files changed, 31 insertions(+), 31 deletions(-) rename migrations/{0061_invite_revocation.sql => 0065_invite_revocation.sql} (100%) diff --git a/crates/buzz-db/src/store/relay_admin_actions.rs b/crates/buzz-db/src/store/relay_admin_actions.rs index 01c2b972360..d118704465d 100644 --- a/crates/buzz-db/src/store/relay_admin_actions.rs +++ b/crates/buzz-db/src/store/relay_admin_actions.rs @@ -524,7 +524,8 @@ 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?; + crate::relay_invite::revoke_banned_issuer_invites(tx.conn(), community_id, target_pubkey) + .await?; let marker = sqlx::query( r#" @@ -620,7 +621,8 @@ 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?; + crate::relay_invite::revoke_banned_issuer_invites(tx.conn(), community_id, target_pubkey) + .await?; let marker = sqlx::query( r#" diff --git a/crates/buzz-db/src/store/relay_invite.rs b/crates/buzz-db/src/store/relay_invite.rs index 27c5cf83c7c..736205bfa9f 100644 --- a/crates/buzz-db/src/store/relay_invite.rs +++ b/crates/buzz-db/src/store/relay_invite.rs @@ -361,8 +361,10 @@ pub async fn claim_relay_invite_with_owner( ) .await?; - lock_admission(&mut tx, community).await?; - if claim_owner::claimant_restricted(&mut tx, community, claimer_pubkey, verified_owner).await? { + 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(ClaimOutcome::Restricted.into()); @@ -411,7 +413,7 @@ pub async fn claim_relay_invite_with_owner( let issuer: String = invite.try_get("created_by")?; let denial = if revoked_at.is_some() { Some((ClaimOutcome::Revoked, "revoked")) - } else if principal_restricted(&mut tx, community, &issuer).await? { + } else if principal_restricted(tx.conn(), community, &issuer).await? { Some((ClaimOutcome::IssuerRestricted, "issuer_restricted")) } else { None @@ -446,7 +448,7 @@ pub async fn claim_relay_invite_with_owner( .await?; } let owner = - claim_owner::persist_claim_owner(&mut tx, community, claimer_pubkey, verified_owner) + claim_owner::persist_claim_owner(tx.conn(), community, claimer_pubkey, verified_owner) .await?; tx.commit().await?; log_claim_outcome( @@ -512,7 +514,7 @@ pub async fn claim_relay_invite_with_owner( if !inserted { let owner = - claim_owner::persist_claim_owner(&mut tx, community, claimer_pubkey, verified_owner) + claim_owner::persist_claim_owner(tx.conn(), community, claimer_pubkey, verified_owner) .await?; tx.commit().await?; log_claim_outcome( @@ -543,7 +545,7 @@ pub async fn claim_relay_invite_with_owner( // 11. Commit membership, evidence, consumption, and verified ownership together. let owner = - claim_owner::persist_claim_owner(&mut tx, community, claimer_pubkey, verified_owner) + claim_owner::persist_claim_owner(tx.conn(), community, claimer_pubkey, verified_owner) .await?; tx.commit().await?; diff --git a/crates/buzz-db/src/store/relay_invite/claim_owner.rs b/crates/buzz-db/src/store/relay_invite/claim_owner.rs index e7f96a48f91..c1d7683e31e 100644 --- a/crates/buzz-db/src/store/relay_invite/claim_owner.rs +++ b/crates/buzz-db/src/store/relay_invite/claim_owner.rs @@ -1,6 +1,6 @@ use crate::error::Result; use crate::{CommunityId, DbError}; -use sqlx::{PgPool, Postgres, Transaction}; +use sqlx::PgPool; /// Result of a committed legacy invitation claim. pub struct MembershipClaimResult { @@ -14,7 +14,7 @@ pub struct MembershipClaimResult { /// Check existing and presented owner identities without creating a user row. pub(super) async fn claimant_restricted( - tx: &mut Transaction<'_, Postgres>, + tx: &mut sqlx::PgConnection, community: CommunityId, claimant: &str, verified_owner: Option<&str>, @@ -36,7 +36,7 @@ pub(super) async fn claimant_restricted( hex::decode(claimant) .map_err(|_| DbError::InvalidData("invalid invitation principal".into()))?, ) - .fetch_optional(&mut **tx) + .fetch_optional(&mut *tx) .await?; let owner_bytes = hex::decode(owner).map_err(|_| DbError::InvalidData("invalid invitation owner".into()))?; @@ -57,7 +57,7 @@ pub(super) struct OwnerPersistence { /// Called only on an admitted claim, while the admission lock is still held. pub(super) async fn persist_claim_owner( - tx: &mut Transaction<'_, Postgres>, + tx: &mut sqlx::PgConnection, community: CommunityId, claimant: &str, verified_owner: Option<&str>, @@ -76,12 +76,12 @@ pub(super) async fn persist_claim_owner( ) .bind(community.as_uuid()) .bind(key) - .execute(&mut **tx) + .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; + .bind(community.as_uuid()).bind(&claimant_bytes).bind(&owner_bytes).execute(&mut *tx).await?.rows_affected() > 0; // Defensive parity with other owner writers; admission already rejects banned owners. if link_created { super::revoke_restricted_agent_invites(tx, community, &claimant_bytes, &owner_bytes) @@ -102,17 +102,14 @@ pub async fn claim_relay_membership_with_owner( policy_version: Option<&str>, verified_owner: Option<&str>, ) -> Result { - let connection = crate::observability::acquire_writer( + let mut tx = crate::begin_community_write_transaction( pool, + community, crate::observability::WriterOperation::Authorization, ) .await?; - let mut tx = sqlx::Transaction::begin(connection, None).await?; - crate::deletion::DeletionStore::new(pool.clone()) - .guard_transaction(&mut tx, community) - .await?; - super::lock_admission(&mut tx, community).await?; - if claimant_restricted(&mut tx, community, pubkey, verified_owner).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( @@ -123,7 +120,7 @@ pub async fn claim_relay_membership_with_owner( .bind(community.as_uuid()) .bind(pubkey) .bind(role) - .execute(&mut *tx) + .execute(tx.conn()) .await? .rows_affected() > 0; @@ -136,11 +133,11 @@ pub async fn claim_relay_membership_with_owner( .bind(community.as_uuid()) .bind(pubkey) .bind(version) - .execute(&mut *tx) + .execute(tx.conn()) .await?; } - let owner = persist_claim_owner(&mut tx, community, pubkey, verified_owner).await?; + let owner = persist_claim_owner(tx.conn(), community, pubkey, verified_owner).await?; tx.commit().await?; Ok(MembershipClaimResult { inserted, diff --git a/crates/buzz-db/src/store/relay_invite/revocation.rs b/crates/buzz-db/src/store/relay_invite/revocation.rs index adf0846db05..c9e17cb836a 100644 --- a/crates/buzz-db/src/store/relay_invite/revocation.rs +++ b/crates/buzz-db/src/store/relay_invite/revocation.rs @@ -1,11 +1,10 @@ //! Application-owned revocation, under the admission lock and in the writer's commit. use crate::{error::Result, CommunityId}; -use sqlx::{Postgres, Transaction}; /// 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 Transaction<'_, Postgres>, + tx: &mut sqlx::PgConnection, community: CommunityId, pubkey: &[u8], ) -> Result<()> { @@ -22,7 +21,7 @@ pub(crate) async fn revoke_banned_issuer_invites( ) .bind(community.as_uuid()) .bind(pubkey) - .execute(&mut **tx) + .execute(&mut *tx) .await?; Ok(()) } @@ -30,7 +29,7 @@ pub(crate) async fn revoke_banned_issuer_invites( /// 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 Transaction<'_, Postgres>, + tx: &mut sqlx::PgConnection, community: CommunityId, agent_pubkey: &[u8], owner_pubkey: &[u8], @@ -46,7 +45,7 @@ pub(crate) async fn revoke_restricted_agent_invites( .bind(community.as_uuid()) .bind(agent_pubkey) .bind(owner_pubkey) - .execute(&mut **tx) + .execute(&mut *tx) .await?; Ok(()) } 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 index 74ad7988651..23ee2f64252 100644 --- a/crates/buzz-db/src/store/relay_invite/upgrade_postgres_tests.rs +++ b/crates/buzz-db/src/store/relay_invite/upgrade_postgres_tests.rs @@ -5,7 +5,7 @@ async fn legacy_fixture() -> (PgPool, Vec) { let pool = PgPool::connect(&crate::test_support::database_url()) .await .unwrap(); - crate::migration::run_migrations_through(&pool, 60) + crate::migration::run_migrations_through(&pool, 64) .await .unwrap(); let mut communities = Vec::new(); diff --git a/migrations/0061_invite_revocation.sql b/migrations/0065_invite_revocation.sql similarity index 100% rename from migrations/0061_invite_revocation.sql rename to migrations/0065_invite_revocation.sql diff --git a/scripts/reconcile-schema-after-pgschema.sql b/scripts/reconcile-schema-after-pgschema.sql index f3f46f37da5..575fa1d2216 100644 --- a/scripts/reconcile-schema-after-pgschema.sql +++ b/scripts/reconcile-schema-after-pgschema.sql @@ -234,7 +234,7 @@ BEGIN END $$; -- Invitation revocation is historical DML; pgschema does not execute it. --- Use the same tenant-fenced, idempotent repair as migration0061. +-- Use the same tenant-fenced, idempotent repair as migration 0065. SELECT backfill_invite_revocations(); DO $$ BEGIN From 313cbe2ff9d77affdd67109d6080a26517975bc2 Mon Sep 17 00:00:00 2001 From: Local Builder <1c476cb1127ea270cb900bdc3891060140bdd1173f91498030ee9b4db01c2f20@buzz.block.builderlab.xyz> Date: Thu, 15 Oct 2026 18:22:08 -0400 Subject: [PATCH 16/34] test(relay): reconcile custom-schema writer fixtures Signed-off-by: Local Builder <1c476cb1127ea270cb900bdc3891060140bdd1173f91498030ee9b4db01c2f20@buzz.block.builderlab.xyz> --- crates/buzz-relay/src/api/bridge.rs | 15 ++++++--------- crates/buzz-relay/src/handlers/ingest.rs | 20 ++++++-------------- 2 files changed, 12 insertions(+), 23 deletions(-) diff --git a/crates/buzz-relay/src/api/bridge.rs b/crates/buzz-relay/src/api/bridge.rs index ab7d7f5df30..4c49d361174 100644 --- a/crates/buzz-relay/src/api/bridge.rs +++ b/crates/buzz-relay/src/api/bridge.rs @@ -8719,15 +8719,12 @@ pub(crate) mod postgres_tests { ) .await .expect("schema pool"); - for sql in [ - include_str!("../../../../schema/functions/community_deletion_lock_key.sql"), - include_str!("../../../../schema/functions/invite_admission_lock.sql"), - ] { - sqlx::raw_sql(sql) - .execute(&pool) - .await - .expect("writer lock functions"); - } + sqlx::raw_sql(include_str!( + "../../../../schema/functions/invite_admission_lock.sql" + )) + .execute(&pool) + .await + .expect("invite admission lock function"); Arc::get_mut(&mut state).expect("unique state").db = buzz_db::Db::from_pool(pool); let tenant = fresh_tenant("late-owner.test"); diff --git a/crates/buzz-relay/src/handlers/ingest.rs b/crates/buzz-relay/src/handlers/ingest.rs index b6483518ca8..968b6532274 100644 --- a/crates/buzz-relay/src/handlers/ingest.rs +++ b/crates/buzz-relay/src/handlers/ingest.rs @@ -7351,22 +7351,14 @@ mod postgres_tests { let pool = sqlx::PgPool::connect_with(options) .await .expect("schema pool"); - for sql in [ - include_str!("../../../../schema/functions/community_deletion_lock_key.sql"), - include_str!("../../../../schema/functions/invite_admission_lock.sql"), - ] { - sqlx::raw_sql(sql) - .execute(&pool) - .await - .expect("writer lock functions"); - } + sqlx::raw_sql(include_str!( + "../../../../schema/functions/invite_admission_lock.sql" + )) + .execute(&pool) + .await + .expect("invite admission lock function"); let state = build_canvas_ingest_state(&db_url, &pool).await; let community = buzz_core::tenant::CommunityId::from_uuid(Uuid::new_v4()); - sqlx::query("INSERT INTO communities(id, host) VALUES($1,'revoke-owner.test')") - .bind(community.as_uuid()) - .execute(&pool) - .await - .expect("writer community"); let tenant = TenantContext::resolved(community, "revoke-owner.test".to_string()); sqlx::query(sqlx::AssertSqlSafe(format!( "INSERT INTO {schema}.communities (id, host) VALUES ($1, 'revoke-owner.test')" From 22b5282a64eeef05f1720e211d72962adb4adf93 Mon Sep 17 00:00:00 2001 From: seiler Date: Sat, 17 Oct 2026 19:26:55 +0000 Subject: [PATCH 17/34] Repair strict legacy invite revocation Signed-off-by: seiler Co-authored-by: Codex --- TESTING.md | 17 +- crates/buzz-db/src/runtime/migration.rs | 22 +- crates/buzz-db/src/store/relay_invite.rs | 31 +- .../relay_invite/revocation_postgres_tests.rs | 225 +++++++ .../relay_invite/upgrade_postgres_tests.rs | 557 +++++++++++++++--- .../handlers/group_state_postgres_tests.rs | 9 +- crates/buzz-relay/src/state.rs | 15 +- ...ocation.sql => 0067_invite_revocation.sql} | 60 +- .../functions/backfill_invite_revocations.sql | 59 +- schema/tables/public/relay_invites.sql | 1 + scripts/reconcile-schema-after-pgschema.sql | 2 +- 11 files changed, 856 insertions(+), 142 deletions(-) rename migrations/{0065_invite_revocation.sql => 0067_invite_revocation.sql} (60%) diff --git a/TESTING.md b/TESTING.md index 719bb774e3e..c5cc9668d47 100644 --- a/TESTING.md +++ b/TESTING.md @@ -151,6 +151,12 @@ the same machine. 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. Historical repair revokes a +legacy row when any retained `community_bans` row exists for its canonical +issuer or currently recorded owner, regardless of whether the restriction is +active or whether the invite was created before it. New mints explicitly mark +their rows as protocol-native; active bans still revoke those rows. 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 @@ -176,6 +182,13 @@ all serving pods and retain it on compatible rollbacks; rolling back to a versio without this setting's enforcement restores legacy acceptance until natural expiry. This change does not choose or deploy a cutoff. +Before applying the invitation migration, operators must reconcile any +previously applied invitation migration version. 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 @@ -220,7 +233,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="" @@ -361,7 +374,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-db/src/runtime/migration.rs b/crates/buzz-db/src/runtime/migration.rs index 7f16dd339d6..5eb5aaaa90d 100644 --- a/crates/buzz-db/src/runtime/migration.rs +++ b/crates/buzz-db/src/runtime/migration.rs @@ -722,14 +722,25 @@ 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!(migrations - .iter() - .any(|migration| migration.sql.as_str().contains("ADD COLUMN revoked_at"))); + assert_eq!(migrations[66].version, 67); + assert!(migrations[66] + .sql + .as_str() + .contains("ADD COLUMN legacy_admission BOOLEAN NOT NULL DEFAULT true")); + for function in [ + include_str!("../../../../schema/functions/invite_admission_lock.sql"), + include_str!("../../../../schema/functions/backfill_invite_revocations.sql"), + ] { + let normalized = normalize_sql(function); + assert!(normalize_sql(migrations[66].sql.as_str()).contains(&normalized)); + assert!(normalize_sql(&desired_schema).contains(&normalized)); + } assert_eq!(migrations[58].version, 59); assert!(migrations[58].sql.as_str().contains("buzz.push_enabled")); assert_eq!(migrations[59].version, 60); @@ -749,11 +760,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/relay_invite.rs b/crates/buzz-db/src/store/relay_invite.rs index 736205bfa9f..e77838d3a09 100644 --- a/crates/buzz-db/src/store/relay_invite.rs +++ b/crates/buzz-db/src/store/relay_invite.rs @@ -123,6 +123,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. /// @@ -136,6 +143,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(); @@ -159,8 +171,9 @@ pub async fn mint_relay_invite( 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()) @@ -202,13 +215,13 @@ pub(crate) async fn principal_restricted( community: CommunityId, pubkey: &str, ) -> Result { - let bytes = hex::decode(pubkey) - .map_err(|_| crate::DbError::InvalidData("invalid invitation principal".into()))?; - if bytes.len() != 32 { + 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 (\ @@ -372,7 +385,7 @@ pub async fn claim_relay_invite_with_owner( // 2. SELECT FOR UPDATE — lock the invite row for the duration of this txn. let row = sqlx::query( - "SELECT id, max_uses, use_count, expires_at, created_by, revoked_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", @@ -411,8 +424,12 @@ pub async fn claim_relay_invite_with_owner( let revoked_at: Option> = invite.try_get("revoked_at")?; let issuer: String = invite.try_get("created_by")?; - let denial = if revoked_at.is_some() { + let legacy_admission: bool = invite.try_get("legacy_admission")?; + let canonical_issuer = is_canonical_invite_pubkey(&issuer); + let denial = if revoked_at.is_some() || (legacy_admission && !canonical_issuer) { Some((ClaimOutcome::Revoked, "revoked")) + } else if !canonical_issuer { + Some((ClaimOutcome::Invalid, "invalid")) } else if principal_restricted(tx.conn(), community, &issuer).await? { Some((ClaimOutcome::IssuerRestricted, "issuer_restricted")) } else { 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 index 03bfb5311f0..462afce138d 100644 --- a/crates/buzz-db/src/store/relay_invite/revocation_postgres_tests.rs +++ b/crates/buzz-db/src/store/relay_invite/revocation_postgres_tests.rs @@ -186,6 +186,231 @@ async fn issuer_agent_revocation_survives_owner_unban() { ); } +#[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(); + } + + assert_eq!( + sqlx::query_scalar::<_, i64>("SELECT backfill_invite_revocations()") + .fetch_one(&pool) + .await + .unwrap(), + 0, + "lifted restrictions and later audit writes do not revoke a new row" + ); + assert!(matches!( + claim_relay_invite( + &pool, + community, + &hash_v2_code(&invite.code), + &claimant, + None + ) + .await + .unwrap(), + ClaimOutcome::Joined { .. } + )); +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn historical_backfill_still_revokes_new_protocol_rows_for_active_bans() { + 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!( + sqlx::query_scalar::<_, i64>("SELECT backfill_invite_revocations()") + .fetch_one(&pool) + .await + .unwrap(), + 1, + "active restriction repair applies to new-protocol rows" + ); + assert_eq!( + claim_relay_invite( + &pool, + community, + &hash_v2_code(&invite.code), + &claimant, + None + ) + .await + .unwrap(), + ClaimOutcome::Revoked + ); +} + +#[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(&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(&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(); 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 index 23ee2f64252..e2a08a47b9c 100644 --- a/crates/buzz-db/src/store/relay_invite/upgrade_postgres_tests.rs +++ b/crates/buzz-db/src/store/relay_invite/upgrade_postgres_tests.rs @@ -1,60 +1,378 @@ use super::*; +use chrono::{DateTime, Duration, Utc}; use uuid::Uuid; -async fn legacy_fixture() -> (PgPool, Vec) { +struct LegacyInviteCase { + community: Uuid, + revoked: bool, +} + +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(); - crate::migration::run_migrations_through(&pool, 64) + crate::migration::run_migrations_through(&pool, 65) .await .unwrap(); - let mut communities = Vec::new(); - for index in 0..4 { + let last_migration: i64 = + sqlx::query_scalar("SELECT max(version) FROM _sqlx_migrations WHERE success") + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!( + last_migration, 65, + "fixtures start at the pinned base 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)") + sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)") .bind(community) .bind(format!("upgrade-{index}-{community}.example")) .execute(&pool) .await .unwrap(); - sqlx::query("INSERT INTO relay_invites(community_id,token_hash,created_by,expires_at,created_at) VALUES($1,$2,$3,now()+interval '1 day',now()-interval '1 hour')") - .bind(community).bind(vec![index as u8;32]).bind(hex::encode([1;32])) - .execute(&pool).await.unwrap(); - if index != 3 { - // Historical accepted ban after issuance, already lifted by upgrade. - sqlx::query("INSERT INTO moderation_actions(community_id,actor_pubkey,action,target_pubkey) VALUES($1,$2,'ban',$3)") - .bind(community).bind(vec![2u8;32]).bind(vec![1u8;32]) - .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 in the pre-0067 schema. The current + // writer also takes invite_admission_lock(), which this + // migrated-through-65 database does not have yet. + 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; + } + _ => {} } - if index == 1 || index == 2 { - 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", + + 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, ) - .bind(community) - .bind(if index == 1 { "quiescing" } else { "tombstone" }) - .execute(&mut *tx) - .await - .unwrap(); - tx.commit().await.unwrap(); + } 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; } - communities.push(community); + + cases.push(LegacyInviteCase { + community, + revoked: index < 8, + }); } - (pool, communities) + (pool, cases) +} + +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()))) + ); } -async fn assert_repaired_and_fenced(pool: &PgPool, communities: &[Uuid]) { - for (index, community) in communities.iter().enumerate() { - let revoked: bool = sqlx::query_scalar( - "SELECT revoked_at IS NOT NULL FROM relay_invites WHERE community_id=$1", +async fn assert_repaired_and_fenced(pool: &PgPool, cases: &[LegacyInviteCase]) { + assert_admission_column(pool).await; + for case in cases { + let (revoked, legacy): (bool, bool) = sqlx::query_as( + "SELECT revoked_at IS NOT NULL, legacy_admission \ + FROM relay_invites WHERE community_id = $1", ) - .bind(community) + .bind(case.community) .fetch_one(pool) .await .unwrap(); - assert_eq!(revoked, index != 3, "tenant {index}"); + assert_eq!(revoked, case.revoked, "tenant {}", case.community); + assert!(legacy, "old-code inserts retain the legacy default"); } assert_eq!( sqlx::query_scalar::<_, i64>("SELECT backfill_invite_revocations()") @@ -64,56 +382,159 @@ async fn assert_repaired_and_fenced(pool: &PgPool, communities: &[Uuid]) { 0, "repair is idempotent" ); - // The repair restores context; ordinary writes stay fenced afterward. - let error = sqlx::query("UPDATE relay_invites SET use_count=use_count+1 WHERE community_id=$1") - .bind(communities[1]) - .execute(pool) + let unfenced_error = + sqlx::query("UPDATE relay_invites SET use_count = use_count + 1 WHERE community_id = $1") + .bind(cases[1].community) + .execute(pool) + .await + .unwrap_err(); + assert!( + unfenced_error + .to_string() + .contains("community write fenced"), + "{unfenced_error}" + ); + let invitation_triggers: i64 = sqlx::query_scalar( + "SELECT count(*) FROM pg_trigger WHERE NOT tgisinternal \ + AND tgname IN ('invite_restriction_lock', 'invite_issuer_revocation', \ + 'invite_owner_lock', 'invite_owner_revocation', \ + 'community_write_fence_relay_invites')", + ) + .fetch_one(pool) + .await + .unwrap(); + assert_eq!( + invitation_triggers, 1, + "only the community deletion fence remains" + ); +} + +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 the desired schema and invite backfill remains solely on + // the real reconcile path below. + 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_err(); + .unwrap(); + let pgschema = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../bin/pgschema"); + let schema_file = + std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../schema/schema.sql"); + let output = std::process::Command::new(pgschema) + .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!( - error.to_string().contains("community write fenced"), - "{error}" + output.status.success(), + "pgschema apply failed:\n{}\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr), ); - let triggers: i64=sqlx::query_scalar("SELECT count(*) FROM pg_trigger WHERE NOT tgisinternal AND tgname IN ('invite_restriction_lock','invite_issuer_revocation','invite_owner_lock','invite_owner_revocation','community_write_fence_relay_invites')") - .fetch_one(pool).await.unwrap(); - assert_eq!(triggers, 1, "only the community deletion fence remains"); } #[tokio::test] #[ignore = "requires Postgres"] -async fn migration_schema_backfill_repairs_history_in_active_and_deletion_fenced_tenants() { - let (pool, communities) = legacy_fixture().await; +async fn migration_schema_backfill_repairs_retained_restrictions_and_ignores_audit_only_history() { + let (pool, cases) = legacy_fixture().await; crate::migration::run_migrations(&pool).await.unwrap(); - assert_repaired_and_fenced(&pool, &communities).await; + assert_repaired_and_fenced(&pool, &cases).await; } #[tokio::test] #[ignore = "requires Postgres"] -async fn migration_schema_desired_upgrade_reconciles_the_same_populated_history() { - let (pool, communities) = legacy_fixture().await; - // Apply the actual desired-state function/trigger sources to the populated - // old schema, then the actual pgschema reconciliation entry point. - sqlx::raw_sql("ALTER TABLE relay_invites ADD COLUMN revoked_at TIMESTAMPTZ") - .execute(&pool) +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(); - for sql in [ - include_str!("../../../../../schema/functions/invite_admission_lock.sql"), - include_str!("../../../../../schema/functions/backfill_invite_revocations.sql"), - ] { - sqlx::raw_sql(sql).execute(&pool).await.unwrap(); - } - let still_unrepaired: i64 = - sqlx::query_scalar("SELECT count(*) FROM relay_invites WHERE revoked_at IS NULL") + assert_eq!(before, cases.len() as i64); + + // Exercise the deployment path against the populated database already + // migrated through 0065, rather than reproducing its DDL by hand. + apply_pgschema_to_existing_database(&pool).await; + assert_admission_column(&pool).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!(still_unrepaired, 4, "DDL alone cannot repair history"); - sqlx::raw_sql(include_str!( - "../../../../../scripts/reconcile-schema-after-pgschema.sql" - )) - .execute(&pool) - .await - .unwrap(); - assert_repaired_and_fenced(&pool, &communities).await; + assert_eq!( + before_reconcile, 0, + "pgschema does not execute historical DML" + ); + + let reconcile = include_str!("../../../../../scripts/reconcile-schema-after-pgschema.sql"); + sqlx::raw_sql(reconcile).execute(&pool).await.unwrap(); + assert_repaired_and_fenced(&pool, &cases).await; + // The actual reconciliation entry point is safe to repeat and converges + // without another row change. + sqlx::raw_sql(reconcile).execute(&pool).await.unwrap(); + assert_eq!( + sqlx::query_scalar::<_, i64>("SELECT backfill_invite_revocations()") + .fetch_one(&pool) + .await + .unwrap(), + 0 + ); + assert_repaired_and_fenced(&pool, &cases).await; } diff --git a/crates/buzz-relay/src/handlers/group_state_postgres_tests.rs b/crates/buzz-relay/src/handlers/group_state_postgres_tests.rs index ea252c10b1a..b519703d2e4 100644 --- a/crates/buzz-relay/src/handlers/group_state_postgres_tests.rs +++ b/crates/buzz-relay/src/handlers/group_state_postgres_tests.rs @@ -13,7 +13,14 @@ use crate::state::AppState; use buzz_auth::Scope; pub(crate) async fn state() -> Arc { - crate::state::tests::test_state_with_database_url(&crate::test_support::database_url()).await + // This fixture creates several real channels per test. Keep pool waiting + // bounded while allowing the isolated PostgreSQL lane's local service a + // little scheduling headroom. + crate::state::tests::test_state_with_database_url_and_acquire_timeout( + &crate::test_support::database_url(), + std::time::Duration::from_secs(3), + ) + .await } pub(crate) async fn community(state: &AppState, label: &str) -> TenantContext { diff --git a/crates/buzz-relay/src/state.rs b/crates/buzz-relay/src/state.rs index 1e88eb8652e..29cce6de5fe 100644 --- a/crates/buzz-relay/src/state.rs +++ b/crates/buzz-relay/src/state.rs @@ -2883,6 +2883,19 @@ pub(crate) mod tests { /// tests deterministically exercise fail-closed database seams without /// depending on whether a developer has the normal test database running. pub(crate) async fn test_state_with_database_url(database_url: &str) -> Arc { + test_state_with_database_url_and_acquire_timeout( + database_url, + std::time::Duration::from_millis(100), + ) + .await + } + + /// Build the explicit-database test state with a caller-selected bounded + /// pool-acquire timeout for database suites that perform sustained writes. + pub(crate) async fn test_state_with_database_url_and_acquire_timeout( + database_url: &str, + acquire_timeout: std::time::Duration, + ) -> Arc { // Fix 5: use Config::for_test() which holds NIP_FI_ENV_LOCK internally. [FI-TRACE-ENV-RACE] let mut config = crate::config::Config::for_test(); config.require_relay_membership = false; @@ -2890,7 +2903,7 @@ pub(crate) mod tests { config.database_url = database_url.to_owned(); config.read_database_url = None; let pool = sqlx::postgres::PgPoolOptions::new() - .acquire_timeout(std::time::Duration::from_millis(100)) + .acquire_timeout(acquire_timeout) .connect_lazy(&config.database_url) .expect("lazy pg pool"); build_test_state(config, pool).await diff --git a/migrations/0065_invite_revocation.sql b/migrations/0067_invite_revocation.sql similarity index 60% rename from migrations/0065_invite_revocation.sql rename to migrations/0067_invite_revocation.sql index 41eb0702f87..8acbe32df36 100644 --- a/migrations/0065_invite_revocation.sql +++ b/migrations/0067_invite_revocation.sql @@ -1,4 +1,5 @@ ALTER TABLE relay_invites ADD COLUMN revoked_at TIMESTAMPTZ; +ALTER TABLE relay_invites ADD COLUMN legacy_admission BOOLEAN NOT NULL DEFAULT true; -- Serialize invitation admission with restriction and ownership changes per tenant. CREATE FUNCTION invite_admission_lock(community UUID) RETURNS VOID @@ -11,9 +12,11 @@ $$; -- Rollout gate: drain all old writer pods before lifting bans or reopening -- invitation admission, then rerun backfill_invite_revocations() under the -- schema-destruction maintenance lock to repair bans committed during rollout. --- Include active bans and recorded prior bans, so lifting a ban before this --- migration cannot restore an invite issued before that ban. Scope every owner --- and audit lookup to the invitation's community. +-- Keep active bans authoritative for every invite. Legacy rows are additionally +-- revoked for any retained restriction row on their canonical issuer or current +-- owner, even when the restriction was lifted or the invite was minted later. +-- Do not infer enforcement from moderation or action audit rows. Scope every +-- issuer, owner, and restriction lookup to the invitation's community. -- Schema migrations hold the exclusive schema-destruction lock. Retain the -- universal tenant fence too: acquire its shared lock, then read the current -- generation before applying this narrowly scoped historical security repair. @@ -38,31 +41,34 @@ BEGIN PERFORM set_config('buzz.deletion_executor_community', target.id::text, true); PERFORM set_config('buzz.deletion_fence_generation', current_generation::text, true); UPDATE relay_invites i SET revoked_at = clock_timestamp() - WHERE i.community_id = target.id AND i.revoked_at IS NULL AND EXISTS ( - SELECT 1 FROM ( - SELECT CASE WHEN i.created_by ~ '^[0-9a-f]{64}$' - THEN decode(i.created_by, 'hex') END AS pubkey - UNION ALL - SELECT agent_owner_pubkey FROM users - WHERE community_id = i.community_id AND encode(pubkey, 'hex') = i.created_by - AND agent_owner_pubkey IS NOT NULL - ) principal - WHERE EXISTS ( - SELECT 1 FROM community_bans b WHERE b.community_id = i.community_id - AND b.pubkey = principal.pubkey AND b.banned - AND (b.ban_expires_at IS NULL OR b.ban_expires_at > clock_timestamp()) - ) OR EXISTS ( - SELECT 1 FROM moderation_actions a WHERE a.community_id = i.community_id - AND a.target_pubkey = principal.pubkey AND a.action IN ('ban', 'resolve:ban') - AND a.created_at >= i.created_at - ) OR EXISTS ( - SELECT 1 FROM relay_admin_actions a WHERE a.report_community_id = i.community_id - AND a.enforcement_target_pubkey = principal.pubkey AND a.action = 'ban' - AND (a.step_marker = 'mutation_committed' OR a.state = 'succeeded') - AND a.updated_at >= i.created_at + WHERE i.community_id = target.id AND i.revoked_at IS NULL + AND ( + EXISTS ( + SELECT 1 FROM community_bans b + WHERE b.community_id = i.community_id + AND ( + b.pubkey = CASE WHEN octet_length(i.created_by) = 64 + AND i.created_by !~ '[^0-9a-f]' + THEN decode(i.created_by, 'hex') END + OR b.pubkey = ( + SELECT u.agent_owner_pubkey + FROM users u + WHERE u.community_id = i.community_id + AND octet_length(i.created_by) = 64 + AND i.created_by !~ '[^0-9a-f]' + AND encode(u.pubkey, 'hex') = i.created_by + ) + ) + AND ( + i.legacy_admission + OR (b.banned AND (b.ban_expires_at IS NULL + OR b.ban_expires_at > clock_timestamp())) + ) ) - ) - ; + OR (i.legacy_admission AND ( + octet_length(i.created_by) <> 64 OR i.created_by ~ '[^0-9a-f]' + )) + ); GET DIAGNOSTICS affected = ROW_COUNT; total := total + affected; PERFORM set_config('buzz.deletion_executor_community', COALESCE(prior_community, ''), true); diff --git a/schema/functions/backfill_invite_revocations.sql b/schema/functions/backfill_invite_revocations.sql index e434de59905..057512ed429 100644 --- a/schema/functions/backfill_invite_revocations.sql +++ b/schema/functions/backfill_invite_revocations.sql @@ -3,9 +3,11 @@ -- Rollout gate: drain all old writer pods before lifting bans or reopening -- invitation admission, then rerun backfill_invite_revocations() under the -- schema-destruction maintenance lock to repair bans committed during rollout. --- Include active bans and recorded prior bans, so lifting a ban before this --- migration cannot restore an invite issued before that ban. Scope every owner --- and audit lookup to the invitation's community. +-- Keep active bans authoritative for every invite. Legacy rows are additionally +-- revoked for any retained restriction row on their canonical issuer or current +-- owner, even when the restriction was lifted or the invite was minted later. +-- Do not infer enforcement from moderation or action audit rows. Scope every +-- issuer, owner, and restriction lookup to the invitation's community. -- Schema migrations hold the exclusive schema-destruction lock. Retain the -- universal tenant fence too: acquire its shared lock, then read the current -- generation before applying this narrowly scoped historical security repair. @@ -30,31 +32,34 @@ BEGIN PERFORM set_config('buzz.deletion_executor_community', target.id::text, true); PERFORM set_config('buzz.deletion_fence_generation', current_generation::text, true); UPDATE relay_invites i SET revoked_at = clock_timestamp() - WHERE i.community_id = target.id AND i.revoked_at IS NULL AND EXISTS ( - SELECT 1 FROM ( - SELECT CASE WHEN i.created_by ~ '^[0-9a-f]{64}$' - THEN decode(i.created_by, 'hex') END AS pubkey - UNION ALL - SELECT agent_owner_pubkey FROM users - WHERE community_id = i.community_id AND encode(pubkey, 'hex') = i.created_by - AND agent_owner_pubkey IS NOT NULL - ) principal - WHERE EXISTS ( - SELECT 1 FROM community_bans b WHERE b.community_id = i.community_id - AND b.pubkey = principal.pubkey AND b.banned - AND (b.ban_expires_at IS NULL OR b.ban_expires_at > clock_timestamp()) - ) OR EXISTS ( - SELECT 1 FROM moderation_actions a WHERE a.community_id = i.community_id - AND a.target_pubkey = principal.pubkey AND a.action IN ('ban', 'resolve:ban') - AND a.created_at >= i.created_at - ) OR EXISTS ( - SELECT 1 FROM relay_admin_actions a WHERE a.report_community_id = i.community_id - AND a.enforcement_target_pubkey = principal.pubkey AND a.action = 'ban' - AND (a.step_marker = 'mutation_committed' OR a.state = 'succeeded') - AND a.updated_at >= i.created_at + WHERE i.community_id = target.id AND i.revoked_at IS NULL + AND ( + EXISTS ( + SELECT 1 FROM community_bans b + WHERE b.community_id = i.community_id + AND ( + b.pubkey = CASE WHEN octet_length(i.created_by) = 64 + AND i.created_by !~ '[^0-9a-f]' + THEN decode(i.created_by, 'hex') END + OR b.pubkey = ( + SELECT u.agent_owner_pubkey + FROM users u + WHERE u.community_id = i.community_id + AND octet_length(i.created_by) = 64 + AND i.created_by !~ '[^0-9a-f]' + AND encode(u.pubkey, 'hex') = i.created_by + ) + ) + AND ( + i.legacy_admission + OR (b.banned AND (b.ban_expires_at IS NULL + OR b.ban_expires_at > clock_timestamp())) + ) ) - ) - ; + OR (i.legacy_admission AND ( + octet_length(i.created_by) <> 64 OR i.created_by ~ '[^0-9a-f]' + )) + ); GET DIAGNOSTICS affected = ROW_COUNT; total := total + affected; PERFORM set_config('buzz.deletion_executor_community', COALESCE(prior_community, ''), true); diff --git a/schema/tables/public/relay_invites.sql b/schema/tables/public/relay_invites.sql index 65a7a9d63de..d2352f4f419 100644 --- a/schema/tables/public/relay_invites.sql +++ b/schema/tables/public/relay_invites.sql @@ -12,6 +12,7 @@ CREATE TABLE relay_invites ( use_count INTEGER NOT NULL DEFAULT 0 CHECK (use_count >= 0), expires_at TIMESTAMPTZ NOT NULL, revoked_at TIMESTAMPTZ, + legacy_admission BOOLEAN NOT NULL DEFAULT true, created_by TEXT NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), PRIMARY KEY (community_id, id), diff --git a/scripts/reconcile-schema-after-pgschema.sql b/scripts/reconcile-schema-after-pgschema.sql index 575fa1d2216..190f61aee3b 100644 --- a/scripts/reconcile-schema-after-pgschema.sql +++ b/scripts/reconcile-schema-after-pgschema.sql @@ -234,7 +234,7 @@ BEGIN END $$; -- Invitation revocation is historical DML; pgschema does not execute it. --- Use the same tenant-fenced, idempotent repair as migration 0065. +-- Use the same tenant-fenced, idempotent repair as migration 0067. SELECT backfill_invite_revocations(); DO $$ BEGIN From 66fabf9e64037c75f4111502a04e87d841b13eea Mon Sep 17 00:00:00 2001 From: seiler Date: Sat, 17 Oct 2026 20:07:08 +0000 Subject: [PATCH 18/34] test(db): upgrade invite repair from schema 66 Co-authored-by: Codex Signed-off-by: seiler --- .../store/relay_invite/upgrade_postgres_tests.rs | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) 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 index e2a08a47b9c..74b2e00b678 100644 --- a/crates/buzz-db/src/store/relay_invite/upgrade_postgres_tests.rs +++ b/crates/buzz-db/src/store/relay_invite/upgrade_postgres_tests.rs @@ -171,7 +171,8 @@ async fn legacy_fixture() -> (PgPool, Vec) { let pool = PgPool::connect(&crate::test_support::database_url()) .await .unwrap(); - crate::migration::run_migrations_through(&pool, 65) + // Pinned main ends at migration 0066, so 0067 is the first candidate migration. + crate::migration::run_migrations_through(&pool, 66) .await .unwrap(); let last_migration: i64 = @@ -180,8 +181,8 @@ async fn legacy_fixture() -> (PgPool, Vec) { .await .unwrap(); assert_eq!( - last_migration, 65, - "fixtures start at the pinned base schema" + last_migration, 66, + "fixtures start at the complete pinned-main schema" ); let issuer = [1u8; 32]; @@ -206,9 +207,8 @@ async fn legacy_fixture() -> (PgPool, Vec) { .execute(&pool) .await .unwrap(); - // Seed the owner link in the pre-0067 schema. The current - // writer also takes invite_admission_lock(), which this - // migrated-through-65 database does not have yet. + // Seed the owner link before candidate migration 0067 installs + // invite_admission_lock(), which the current writer takes. sqlx::query( "UPDATE users SET agent_owner_pubkey = $1 \ WHERE community_id = $2 AND pubkey = $3", @@ -509,8 +509,8 @@ async fn migration_schema_desired_pgschema_reconcile_matches_populated_upgrade() .unwrap(); assert_eq!(before, cases.len() as i64); - // Exercise the deployment path against the populated database already - // migrated through 0065, rather than reproducing its DDL by hand. + // Exercise the deployment path against populated data at the complete + // pinned-main schema through 0066, rather than reproducing its DDL by hand. apply_pgschema_to_existing_database(&pool).await; assert_admission_column(&pool).await; let before_reconcile: i64 = From 083118f9ea87aa635af9002aa6b906c28beee6ed Mon Sep 17 00:00:00 2001 From: seiler Date: Tue, 20 Oct 2026 23:48:01 +0000 Subject: [PATCH 19/34] Fix invite restriction test fixture Signed-off-by: seiler Co-authored-by: Codex --- .../src/api/invites/postgres_tests/restriction_tests.rs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) 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 index 7f4f61baf2b..a445c51f4e1 100644 --- a/crates/buzz-relay/src/api/invites/postgres_tests/restriction_tests.rs +++ b/crates/buzz-relay/src/api/invites/postgres_tests/restriction_tests.rs @@ -16,8 +16,15 @@ pub(super) mod postgres_tests { let owner = Keys::generate(); state .db - .add_relay_member(community, &owner.public_key().to_hex(), "owner", None) + .add_relay_member( + community, + &owner.public_key().to_hex(), + "owner", + None, + &state.relay_keypair, + ) .await + .map(|write| write.outcome) .unwrap(); (state, host, community, owner) } From ee0ecfa92951654ccdc2be314f528cc555203dfd Mon Sep 17 00:00:00 2001 From: seiler Date: Wed, 21 Oct 2026 00:13:48 +0000 Subject: [PATCH 20/34] Classify invite upgrade fixture as migration schema Signed-off-by: seiler Co-authored-by: Codex --- .../buzz-db/src/store/relay_invite/upgrade_postgres_tests.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 index 0d27abe6e8d..88bd8460d2e 100644 --- a/crates/buzz-db/src/store/relay_invite/upgrade_postgres_tests.rs +++ b/crates/buzz-db/src/store/relay_invite/upgrade_postgres_tests.rs @@ -576,7 +576,8 @@ async fn migration_schema_backfill_repairs_retained_restrictions_and_ignores_aud #[tokio::test] #[ignore = "requires Postgres"] -async fn legacy_claim_lazily_revokes_history_after_a_fenced_community_reactivates() { +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(); run_invite_repair_script(&pool).await; From 9017435270bbcb7b9738e55d5c8d91768344cf21 Mon Sep 17 00:00:00 2001 From: seiler Date: Wed, 21 Oct 2026 17:25:56 +0000 Subject: [PATCH 21/34] Separate invite history repair from schema rollout Signed-off-by: seiler Co-authored-by: Codex --- TESTING.md | 37 ++-- crates/buzz-db/TESTING.md | 5 +- crates/buzz-db/src/runtime/migration.rs | 15 +- crates/buzz-db/src/store/relay_invite.rs | 5 +- .../relay_invite/revocation_postgres_tests.rs | 25 +-- .../relay_invite/upgrade_postgres_tests.rs | 181 ++++++------------ .../backfill_invite_revocations.sql | 141 -------------- 7 files changed, 96 insertions(+), 313 deletions(-) delete mode 100644 scripts/maintenance/backfill_invite_revocations.sql diff --git a/TESTING.md b/TESTING.md index 00794e091b7..13fed7310be 100644 --- a/TESTING.md +++ b/TESTING.md @@ -151,12 +151,14 @@ the same machine. 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. Historical repair revokes a -legacy row when any retained `community_bans` row exists for its canonical -issuer or currently recorded owner, regardless of whether the restriction is -active or whether the invite was created before it. New mints explicitly mark -their rows as protocol-native; active bans still revoke those rows. Audit and -action records alone do not establish enforcement. +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 @@ -185,21 +187,14 @@ This change does not choose or deploy a cutoff. Migration 0067 is an expand step: it adds `revoked_at` and `legacy_admission`, and does not rewrite existing invite rows. Before applying it, reconcile any previously applied invitation migration version with the -schema and migration history. For the rollout, pause invitation minting and -claiming and ban lifts; apply migration 0067; deploy the new relay and drain all -old writers; then run the fenced historical repair while admission remains -paused: - -```bash -psql -X -v ON_ERROR_STOP=1 -f scripts/maintenance/backfill_invite_revocations.sql -``` - -Resume minting, claiming, and ban lifts only after the script reports zero -unrepaired invites and only new relay code is serving. The repair is bounded -and idempotent; rerun it after interruption before reopening admission. A -community outside the active state is skipped and remains fenced. A rollback -to code that ignores `revoked_at` also requires invitation admission to remain -paused. Legacy owner attribution uses the agent's currently recorded +schema and migration history. Pause invitation minting and claiming and ban +lifts; apply migration 0067; deploy the new relay and drain all old writers; +then keep admission paused until the separately reviewed, fenced historical +repair completes. Resume only after it reports zero unrepaired invites and +only new relay code is serving. The repair processes active communities and +leaves a skipped community's legacy markers in place until it can be repaired. +A rollback to code that ignores `revoked_at` also requires invitation admission +to remain paused. 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 diff --git a/crates/buzz-db/TESTING.md b/crates/buzz-db/TESTING.md index 057a1f3be47..2bcaad515b0 100644 --- a/crates/buzz-db/TESTING.md +++ b/crates/buzz-db/TESTING.md @@ -64,5 +64,6 @@ 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 migration-upgrade tests also execute the operator repair script with -`psql`; make sure the PostgreSQL client is installed and resolves on `PATH`. +Invite upgrade tests start from the complete schema through migration 0066 and +exercise both migration and desired-state paths against populated data. The +historical repair is validated separately from the application/schema change. diff --git a/crates/buzz-db/src/runtime/migration.rs b/crates/buzz-db/src/runtime/migration.rs index fd28914fda2..cdf41baea5b 100644 --- a/crates/buzz-db/src/runtime/migration.rs +++ b/crates/buzz-db/src/runtime/migration.rs @@ -741,20 +741,7 @@ mod postgres_tests { .to_ascii_uppercase() .contains("CREATE FUNCTION")); assert!(!desired_schema.contains("invite_admission_lock")); - assert!(!desired_schema.contains("backfill_invite_revocations")); - - let invite_repair = - include_str!("../../../../scripts/maintenance/backfill_invite_revocations.sql"); - assert!(invite_repair.contains("pg_advisory_xact_lock_shared")); - assert!(invite_repair.contains("'invite-admission:'")); - assert!(invite_repair.contains("deletion_state = 'active' AND deleted_at IS NULL")); - assert!(invite_repair.contains("LIMIT 500")); - assert!(invite_repair.contains("FOR UPDATE OF invite")); - assert!(invite_repair.contains("COMMIT;")); - assert!(invite_repair.contains("SELECT count(*) AS unrepaired_invites")); - assert!(!invite_repair - .to_ascii_uppercase() - .contains("CREATE FUNCTION")); + assert_eq!(migrations[58].version, 59); assert!(migrations[58].sql.as_str().contains("buzz.push_enabled")); assert_eq!(migrations[59].version, 60); diff --git a/crates/buzz-db/src/store/relay_invite.rs b/crates/buzz-db/src/store/relay_invite.rs index 6a321efbda0..7b37dcb702e 100644 --- a/crates/buzz-db/src/store/relay_invite.rs +++ b/crates/buzz-db/src/store/relay_invite.rs @@ -241,8 +241,9 @@ pub(crate) async fn principal_restricted( } /// Whether a restriction row permanently revokes an invite from this principal. -/// Legacy invites treat every retained row as history; new invites only treat -/// a currently active ban as permanent revocation. +/// 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, 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 index aec5cb9362a..54da3cf1749 100644 --- a/crates/buzz-db/src/store/relay_invite/revocation_postgres_tests.rs +++ b/crates/buzz-db/src/store/relay_invite/revocation_postgres_tests.rs @@ -317,7 +317,6 @@ async fn stalled_production_mint_ignores_lifted_history_and_later_audit_touches( .unwrap(); } - super::upgrade_postgres_tests::run_invite_repair_script(&pool).await; let revoked: Option> = sqlx::query_scalar("SELECT revoked_at FROM relay_invites WHERE community_id=$1 AND id=$2") .bind(community.as_uuid()) @@ -345,7 +344,7 @@ async fn stalled_production_mint_ignores_lifted_history_and_later_audit_touches( #[tokio::test] #[ignore = "requires Postgres"] -async fn historical_backfill_still_revokes_new_protocol_rows_for_active_bans() { +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 @@ -370,19 +369,6 @@ async fn historical_backfill_still_revokes_new_protocol_rows_for_active_bans() { .await .unwrap(); - super::upgrade_postgres_tests::run_invite_repair_script(&pool).await; - 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 restriction repair applies to new-protocol rows" - ); assert_eq!( claim_relay_invite_outcome( &pool, @@ -395,6 +381,15 @@ async fn historical_backfill_still_revokes_new_protocol_rows_for_active_bans() { .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] 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 index 88bd8460d2e..2a482c4484a 100644 --- a/crates/buzz-db/src/store/relay_invite/upgrade_postgres_tests.rs +++ b/crates/buzz-db/src/store/relay_invite/upgrade_postgres_tests.rs @@ -4,7 +4,6 @@ use uuid::Uuid; struct LegacyInviteCase { community: Uuid, - revoked: bool, } struct RelayAdminActionFixture<'a> { @@ -335,68 +334,19 @@ async fn legacy_fixture() -> (PgPool, Vec) { set_deletion_state(&pool, community, "tombstone").await; } - cases.push(LegacyInviteCase { - community, - // The maintenance repair must not write through a deletion fence. - revoked: index < 8 && !matches!(index, 1 | 2), - }); + cases.push(LegacyInviteCase { community }); } (pool, cases) } -pub(super) async fn run_invite_repair_script(pool: &PgPool) -> String { - 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(); - let script = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) - .join("../../scripts/maintenance/backfill_invite_revocations.sql"); - let output = std::process::Command::new("psql") - .args(["-X", "-v", "ON_ERROR_STOP=1"]) - .arg("--host") - .arg(host) - .arg("--port") - .arg(port) - .arg("--username") - .arg(user) - .arg("--dbname") - .arg(database) - .arg("--file") - .arg(script) - .env("PGPASSWORD", password) - .output() - .unwrap(); - assert!( - output.status.success(), - "invite repair script failed:\n{}\n{}", - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr), - ); - let stdout = String::from_utf8_lossy(&output.stdout).into_owned(); - assert!( - stdout.lines().any(|line| line.trim() == "0"), - "repair validation did not return zero unrepaired invites:\n{stdout}" - ); - stdout -} - -async fn invite_revocation_state(pool: &PgPool) -> Vec<(Uuid, Option>)> { - sqlx::query_as("SELECT community_id, revoked_at FROM relay_invites ORDER BY community_id") - .fetch_all(pool) - .await - .unwrap() +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) { @@ -428,55 +378,14 @@ async fn assert_admission_column(pool: &PgPool) { ); } -async fn assert_repaired_and_fenced(pool: &PgPool, cases: &[LegacyInviteCase]) { - assert_admission_column(pool).await; - for case in cases { - let (revoked, legacy): (bool, bool) = sqlx::query_as( - "SELECT revoked_at IS NOT NULL, legacy_admission \ - FROM relay_invites WHERE community_id = $1", - ) - .bind(case.community) - .fetch_one(pool) - .await - .unwrap(); - assert_eq!(revoked, case.revoked, "tenant {}", case.community); - assert!(legacy, "old-code inserts retain the legacy default"); - } - let unfenced_error = - sqlx::query("UPDATE relay_invites SET use_count = use_count + 1 WHERE community_id = $1") - .bind(cases[1].community) - .execute(pool) - .await - .unwrap_err(); - assert!( - unfenced_error - .to_string() - .contains("community write fenced"), - "{unfenced_error}" - ); - let invitation_triggers: i64 = sqlx::query_scalar( - "SELECT count(*) FROM pg_trigger WHERE NOT tgisinternal \ - AND tgname IN ('invite_restriction_lock', 'invite_issuer_revocation', \ - 'invite_owner_lock', 'invite_owner_revocation', \ - 'community_write_fence_relay_invites')", - ) - .fetch_one(pool) - .await - .unwrap(); - assert_eq!( - invitation_triggers, 1, - "only the community deletion fence remains" - ); -} - 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; the historical repair remains a separate - // operator script required after deployment. + // 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; \ @@ -553,7 +462,7 @@ async fn apply_pgschema_to_existing_database(pool: &PgPool) { #[tokio::test] #[ignore = "requires Postgres"] -async fn migration_schema_backfill_repairs_retained_restrictions_and_ignores_audit_only_history() { +async fn migration_schema_0067_adds_invite_columns_without_rewriting_history() { let (pool, cases) = legacy_fixture().await; crate::migration::run_migrations(&pool).await.unwrap(); assert_admission_column(&pool).await; @@ -567,11 +476,35 @@ async fn migration_schema_backfill_repairs_retained_restrictions_and_ignores_aud 0, "schema migration must not rewrite historical rows" ); - run_invite_repair_script(&pool).await; - let repaired = invite_revocation_state(&pool).await; - assert_repaired_and_fenced(&pool, &cases).await; - run_invite_repair_script(&pool).await; - assert_eq!(invite_revocation_state(&pool).await, repaired); + 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] @@ -580,7 +513,6 @@ async fn migration_schema_legacy_claim_lazily_revokes_history_after_a_fenced_com { let (pool, cases) = legacy_fixture().await; crate::migration::run_migrations(&pool).await.unwrap(); - run_invite_repair_script(&pool).await; let community = cases[1].community; let token_hash = [2u8; 32]; @@ -633,6 +565,16 @@ async fn migration_schema_legacy_claim_lazily_revokes_history_after_a_fenced_com 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] @@ -663,15 +605,18 @@ async fn migration_schema_desired_pgschema_reconcile_matches_populated_upgrade() 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; the explicit maintenance - // script below owns historical data repair in both schema modes. + // 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); - - run_invite_repair_script(&pool).await; - let repaired = invite_revocation_state(&pool).await; - assert_repaired_and_fenced(&pool, &cases).await; - run_invite_repair_script(&pool).await; - assert_eq!(invite_revocation_state(&pool).await, repaired); - assert_repaired_and_fenced(&pool, &cases).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/scripts/maintenance/backfill_invite_revocations.sql b/scripts/maintenance/backfill_invite_revocations.sql deleted file mode 100644 index e8d00523bb1..00000000000 --- a/scripts/maintenance/backfill_invite_revocations.sql +++ /dev/null @@ -1,141 +0,0 @@ --- Run after schema migration 0067 and after the new relay is fully rolled out --- and all old writers are drained. Keep invite mint/claim and ban lifts paused --- until this repair and its final validation succeed. --- --- Each community batch commits independently, takes the same lock order as the --- application writers, and skips a community unless it is active and has no --- deletion timestamp. The script is idempotent; rerun it after a failure. -\set ON_ERROR_STOP on -SET lock_timeout = '5s'; -SET statement_timeout = '2min'; - -DO $$ -DECLARE - last_community UUID; - target_community UUID; - community_active BOOLEAN; - affected BIGINT; -BEGIN - LOOP - SELECT id INTO target_community - FROM communities - WHERE last_community IS NULL OR id > last_community - ORDER BY id - LIMIT 1; - EXIT WHEN NOT FOUND; - last_community := target_community; - - LOOP - PERFORM pg_advisory_xact_lock_shared( - community_deletion_lock_key(target_community) - ); - PERFORM pg_advisory_xact_lock( - hashtextextended('invite-admission:' || target_community::text, 0) - ); - SELECT deletion_state = 'active' AND deleted_at IS NULL - INTO community_active - FROM communities - WHERE id = target_community; - - IF community_active IS DISTINCT FROM TRUE THEN - COMMIT; - EXIT; - END IF; - - WITH candidates AS ( - SELECT invite.id - FROM relay_invites AS invite - CROSS JOIN LATERAL ( - SELECT CASE - WHEN octet_length(invite.created_by) = 64 - AND invite.created_by !~ '[^0-9a-f]' - THEN decode(invite.created_by, 'hex') - END AS pubkey - ) AS issuer - WHERE invite.community_id = target_community - AND invite.revoked_at IS NULL - AND ( - EXISTS ( - SELECT 1 - FROM community_bans AS restriction - WHERE restriction.community_id = invite.community_id - AND ( - restriction.pubkey = issuer.pubkey - OR restriction.pubkey = ( - SELECT user_row.agent_owner_pubkey - FROM users AS user_row - WHERE user_row.community_id = invite.community_id - AND user_row.pubkey = issuer.pubkey - ) - ) - AND ( - invite.legacy_admission - OR ( - restriction.banned - AND (restriction.ban_expires_at IS NULL - OR restriction.ban_expires_at > clock_timestamp()) - ) - ) - ) - OR (invite.legacy_admission AND issuer.pubkey IS NULL) - ) - ORDER BY invite.id - LIMIT 500 - FOR UPDATE OF invite - ) - UPDATE relay_invites AS invite - SET revoked_at = clock_timestamp() - FROM candidates - WHERE invite.community_id = target_community - AND invite.id = candidates.id - AND invite.revoked_at IS NULL; - GET DIAGNOSTICS affected = ROW_COUNT; - - COMMIT; - EXIT WHEN affected = 0; - END LOOP; - END LOOP; -END -$$; - --- Expected result: 0. This includes active bans for every invite and every --- retained restriction row (including lifted and timeout-only rows) for legacy --- issuers or their currently recorded owners in active communities. -SELECT count(*) AS unrepaired_invites -FROM relay_invites AS invite -JOIN communities AS community ON community.id = invite.community_id -CROSS JOIN LATERAL ( - SELECT CASE - WHEN octet_length(invite.created_by) = 64 - AND invite.created_by !~ '[^0-9a-f]' - THEN decode(invite.created_by, 'hex') - END AS pubkey -) AS issuer -WHERE community.deletion_state = 'active' - AND community.deleted_at IS NULL - AND invite.revoked_at IS NULL - AND ( - EXISTS ( - SELECT 1 - FROM community_bans AS restriction - WHERE restriction.community_id = invite.community_id - AND ( - restriction.pubkey = issuer.pubkey - OR restriction.pubkey = ( - SELECT user_row.agent_owner_pubkey - FROM users AS user_row - WHERE user_row.community_id = invite.community_id - AND user_row.pubkey = issuer.pubkey - ) - ) - AND ( - invite.legacy_admission - OR ( - restriction.banned - AND (restriction.ban_expires_at IS NULL - OR restriction.ban_expires_at > clock_timestamp()) - ) - ) - ) - OR (invite.legacy_admission AND issuer.pubkey IS NULL) - ); From 96f9eefc1ee4b453e2aa4fe0bfd3f926c695d805 Mon Sep 17 00:00:00 2001 From: Brad Seiler Date: Wed, 21 Oct 2026 12:51:18 -0700 Subject: [PATCH 22/34] fix(invites): distinguish owner mismatch and explain claim recovery Keep owner mismatch terminal while retaining fail-closed storage errors. Explain restriction and owner recovery in each invite client without suggesting fresh-link bypasses. Co-authored-by: Codex Signed-off-by: Brad Seiler --- TESTING.md | 45 +++++-- crates/buzz-relay/src/api/bridge.rs | 10 +- crates/buzz-relay/src/api/invites.rs | 25 ++-- .../postgres_tests/owner_admission_tests.rs | 66 ++++++++++ .../postgres_tests/restriction_tests.rs | 45 ++++++- crates/buzz-relay/src/api/mod.rs | 17 ++- crates/buzz-relay/src/audio/handler.rs | 3 +- crates/buzz-relay/src/handlers/auth.rs | 4 +- .../onboarding/useClaimInvite.jsdom-test.mjs | 119 ++++++++++++++++++ .../src/features/onboarding/useClaimInvite.ts | 12 +- desktop/src/shared/api/inviteHelpers.ts | 18 +++ .../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 + web/src/features/invite/ui/InvitePage.tsx | 6 + web/tests/e2e/smoke.spec.ts | 60 +++++++++ 21 files changed, 545 insertions(+), 47 deletions(-) create mode 100644 desktop/src/features/onboarding/useClaimInvite.jsdom-test.mjs diff --git a/TESTING.md b/TESTING.md index 13fed7310be..83d396b83e4 100644 --- a/TESTING.md +++ b/TESTING.md @@ -164,6 +164,13 @@ 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 an account or issuer restriction in this community: +contact a community administrator, or retry after a temporary restriction ends. +`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 @@ -184,17 +191,33 @@ all serving pods and retain it on compatible rollbacks; rolling back to a versio without this setting's enforcement restores legacy acceptance until natural expiry. This change does not choose or deploy a cutoff. -Migration 0067 is an expand step: it adds `revoked_at` and -`legacy_admission`, and does not rewrite existing invite rows. Before applying -it, reconcile any previously applied invitation migration version with the -schema and migration history. Pause invitation minting and claiming and ban -lifts; apply migration 0067; deploy the new relay and drain all old writers; -then keep admission paused until the separately reviewed, fenced historical -repair completes. Resume only after it reports zero unrepaired invites and -only new relay code is serving. The repair processes active communities and -leaves a skipped community's legacy markers in place until it can be repaired. -A rollback to code that ignores `revoked_at` also requires invitation admission -to remain paused. Legacy owner attribution uses the agent's currently recorded +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 0067 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 0067 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. +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 diff --git a/crates/buzz-relay/src/api/bridge.rs b/crates/buzz-relay/src/api/bridge.rs index f84b6d0915c..a21ba8bf8c8 100644 --- a/crates/buzz-relay/src/api/bridge.rs +++ b/crates/buzz-relay/src/api/bridge.rs @@ -1241,7 +1241,8 @@ async fn submit_event_authed( } }; if let Some(owner) = nip_oa_owner { - super::relay_members::materialize_nip_oa_owner(state, tenant, &pubkey, &owner).await; + let _ = + super::relay_members::materialize_nip_oa_owner(state, tenant, &pubkey, &owner).await; } let auth = IngestAuth::Http { @@ -8675,7 +8676,7 @@ pub(crate) mod postgres_tests { .unwrap() }; - assert!(materialize().await, "first call records the link"); + assert!(materialize().await.unwrap(), "first call records the link"); assert_eq!(agent_rows().await, 1); // Remove the row behind the cache's back: a call that still wrote @@ -8686,7 +8687,10 @@ pub(crate) mod postgres_tests { .execute(state.db.pool()) .await .unwrap(); - assert!(materialize().await, "a known link is still confirmed"); + assert!( + materialize().await.unwrap(), + "a known link is still confirmed" + ); assert_eq!(agent_rows().await, 0, "a known link writes nothing"); } diff --git a/crates/buzz-relay/src/api/invites.rs b/crates/buzz-relay/src/api/invites.rs index b8698abc2b0..abbc03ac6a7 100644 --- a/crates/buzz-relay/src/api/invites.rs +++ b/crates/buzz-relay/src/api/invites.rs @@ -320,12 +320,19 @@ async fn mint_invite_checked( super::relay_members::extract_auth_tag_header(&headers), signed_created_at, ) { - if !super::relay_members::materialize_nip_oa_owner(&state, &tenant, &pubkey, &owner).await { - return api_error( - StatusCode::SERVICE_UNAVAILABLE, - "restriction lookup unavailable", - ) - .into_response(); + match super::relay_members::materialize_nip_oa_owner(&state, &tenant, &pubkey, &owner).await + { + Ok(true) => {} + Ok(false) => { + return api_error(StatusCode::FORBIDDEN, "invite_owner_mismatch").into_response(); + } + Err(_) => { + return api_error( + StatusCode::SERVICE_UNAVAILABLE, + "restriction lookup unavailable", + ) + .into_response(); + } } } @@ -1953,11 +1960,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/postgres_tests/owner_admission_tests.rs b/crates/buzz-relay/src/api/invites/postgres_tests/owner_admission_tests.rs index 5288accb9d7..ee5a490d4f8 100644 --- 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 @@ -10,6 +10,72 @@ mod postgres_tests { .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() { 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 index a445c51f4e1..33089d64a8b 100644 --- a/crates/buzz-relay/src/api/invites/postgres_tests/restriction_tests.rs +++ b/crates/buzz-relay/src/api/invites/postgres_tests/restriction_tests.rs @@ -244,7 +244,7 @@ pub(super) mod postgres_tests { #[tokio::test] #[ignore = "requires Postgres"] - async fn cutoff_rejects_only_v1_and_restriction_lookup_failure_never_admits() { + async fn cutoff_rejects_only_v1_and_restriction_or_owner_lookup_failure_never_admits() { let Some(identity) = [ "NEXTEST_RUN_ID", "NEXTEST_BINARY_ID", @@ -318,6 +318,49 @@ pub(super) mod postgres_tests { .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"], + "restriction lookup unavailable" + ); + 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 { diff --git a/crates/buzz-relay/src/api/mod.rs b/crates/buzz-relay/src/api/mod.rs index 89ff79f7e4c..c1bf8d9c297 100644 --- a/crates/buzz-relay/src/api/mod.rs +++ b/crates/buzz-relay/src/api/mod.rs @@ -405,23 +405,26 @@ 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 `Ok(false)` for an owner mismatch and `Err` for storage failure. /// /// A mapping already confirmed in `observer_owner_cache` returns early: /// `agent_owner_pubkey` is set once and never cleared, so the writes /// below would all be no-ops. HTTP agents call this on every request. + /// Returns `Ok(false)` for a confirmed owner mismatch; storage failures + /// propagate so callers can distinguish denial from unavailability. pub async fn materialize_nip_oa_owner( state: &AppState, tenant: &TenantContext, agent: &nostr::PublicKey, owner: &nostr::PublicKey, - ) -> bool { + ) -> Result { let owner_key = ( tenant.community(), agent.to_bytes().to_vec(), owner.to_bytes().to_vec(), ); if state.observer_owner_cache.get(&owner_key) == Some(true) { - return true; + return Ok(true); } for (role, pubkey) in [("agent", agent), ("owner", owner)] { match state @@ -439,7 +442,7 @@ pub mod relay_members { Ok(false) => {} Err(e) => { tracing::warn!(%role, error = %e, "ensure_user failed during NIP-OA backfill"); - return false; + return Err(e); } } } @@ -464,10 +467,12 @@ pub mod relay_members { .db .is_agent_owner(tenant.community(), agent.as_bytes(), owner.as_bytes()) .await - .unwrap_or(false), + .inspect_err(|e| { + tracing::warn!(error = %e, "failed to verify recorded NIP-OA owner"); + })?, Err(e) => { tracing::warn!(error = %e, "failed to backfill agent_owner_pubkey"); - false + return Err(e); } }; @@ -477,7 +482,7 @@ pub mod relay_members { .insert((tenant.community(), agent.to_bytes().to_vec()), true); state.observer_owner_cache.insert(owner_key, true); } - materialized + Ok(materialized) } #[cfg(test)] diff --git a/crates/buzz-relay/src/audio/handler.rs b/crates/buzz-relay/src/audio/handler.rs index 3a4d2fcde1c..fa5dbc8cb25 100644 --- a/crates/buzz-relay/src/audio/handler.rs +++ b/crates/buzz-relay/src/audio/handler.rs @@ -939,6 +939,7 @@ async fn run_active_audio_connection( }; crate::api::relay_members::materialize_nip_oa_owner(&state, &tenant, &pubkey, &owner) .await + .unwrap_or(false) }; if !linked { shadow_attempt.refused(); @@ -4010,7 +4011,7 @@ mod tests { // Fix 5: use Config::for_test() which holds NIP_FI_ENV_LOCK internally. [FI-TRACE-ENV-RACE] let mut config = crate::config::Config::for_test(); config.require_relay_membership = require_relay_membership; - config.database_url = "postgres://buzz:buzz_dev@127.0.0.1:1/buzz".to_string(); + config.database_url = "postgres://buzz:buzz_dev@127.0.0.1:1/buzz".to_string(); // sadscan:disable np.postgres.1 -- local test-only credentials on a closed port config.redis_url = "redis://127.0.0.1:1".to_string(); let mut pool_options = sqlx::postgres::PgPoolOptions::new(); if let Some(timeout) = acquire_timeout { diff --git a/crates/buzz-relay/src/handlers/auth.rs b/crates/buzz-relay/src/handlers/auth.rs index b035d094794..e950bd057de 100644 --- a/crates/buzz-relay/src/handlers/auth.rs +++ b/crates/buzz-relay/src/handlers/auth.rs @@ -625,6 +625,7 @@ pub async fn handle_auth(event: nostr::Event, conn: Arc, state: &owner, ) .await + .unwrap_or(false) { warn!( conn_id = %conn_id, @@ -2158,7 +2159,8 @@ mod tests { &agent.public_key(), &owner.public_key(), ) - .await, + .await + .expect("owner storage available"), "the owner link commits" ); release.notify_one(); diff --git a/desktop/src/features/onboarding/useClaimInvite.jsdom-test.mjs b/desktop/src/features/onboarding/useClaimInvite.jsdom-test.mjs new file mode 100644 index 00000000000..90a64c504d4 --- /dev/null +++ b/desktop/src/features/onboarding/useClaimInvite.jsdom-test.mjs @@ -0,0 +1,119 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import React, { act } from "react"; +import { createRoot } from "react-dom/client"; + +import { + CommunityOnboardingProvider, + loadCommunityOnboardingTransaction, + startCommunityOnboarding, + useCommunityOnboarding, +} from "./communityOnboarding.tsx"; +import { useClaimInvite } from "./useClaimInvite.ts"; + +for (const [sentinel, guidance] of [ + [ + "invite_restricted", + "This account or the invite issuer is restricted in this community. Contact a community administrator. If the restriction is temporary, try again after it ends.", + ], + [ + "invite_owner_mismatch", + "This account does not match its recorded owner. Use the correct account or owner identity to join this community.", + ], +]) { + test(`${sentinel} parks the claim with recovery guidance until an explicit retry`, async () => { + localStorage.clear(); + startCommunityOnboarding({ + source: "deep-link-join", + relayUrl: "wss://relay.example", + inviteCode: "same-code", + }); + const previousFetch = globalThis.fetch; + const previousTauri = window.__TAURI_INTERNALS__; + window.__TAURI_INTERNALS__ = { + async invoke(command) { + assert.equal(command, "sign_event"); + return JSON.stringify({ id: "signed-event", pubkey: "claimant" }); + }, + }; + let attempts = 0; + let claimObserved; + const claim = new Promise((resolve) => { + claimObserved = resolve; + }); + let retryObserved; + const retryClaim = new Promise((resolve) => { + retryObserved = resolve; + }); + globalThis.fetch = async (url, options) => { + assert.equal(url, "https://relay.example/api/invites/claim"); + assert.equal(JSON.parse(options.body).code, "same-code"); + attempts++; + if (attempts === 1) claimObserved(); + if (attempts === 2) retryObserved(); + return new Response( + JSON.stringify( + attempts === 1 ? { error: sentinel } : { status: "joined" }, + ), + { status: attempts === 1 ? 403 : 200 }, + ); + }; + let onboarding; + function Claim() { + useClaimInvite(); + onboarding = useCommunityOnboarding(); + return React.createElement( + "p", + { role: "alert", "data-stage": onboarding.transaction?.stage }, + onboarding.transaction?.error, + ); + } + const container = document.createElement("div"); + const root = createRoot(container); + try { + await act(async () => { + root.render( + React.createElement( + CommunityOnboardingProvider, + null, + React.createElement(Claim), + ), + ); + }); + await act(async () => { + await claim; + }); + assert.equal(container.textContent, guidance); + assert.equal(loadCommunityOnboardingTransaction().stage, "claiming"); + assert.equal(loadCommunityOnboardingTransaction().error, guidance); + // Re-render with the persisted error: it must not start a retry loop. + await act(async () => { + root.render( + React.createElement( + CommunityOnboardingProvider, + null, + React.createElement(Claim), + ), + ); + }); + assert.equal(attempts, 1); + await act(async () => { + onboarding.update({ error: undefined }); + }); + // Signing awaits WebCrypto/native IPC before fetch. Observe that second + // request explicitly, then let React commit its successful transition. + await act(async () => { + await retryClaim; + }); + assert.equal(attempts, 2); + assert.equal(container.querySelector("p").dataset.stage, "connecting"); + assert.equal(loadCommunityOnboardingTransaction().stage, "connecting"); + assert.equal(loadCommunityOnboardingTransaction().error, undefined); + } finally { + await act(async () => root.unmount()); + globalThis.fetch = previousFetch; + window.__TAURI_INTERNALS__ = previousTauri; + localStorage.clear(); + } + }); +} diff --git a/desktop/src/features/onboarding/useClaimInvite.ts b/desktop/src/features/onboarding/useClaimInvite.ts index 3134953617c..f1eed59a8ef 100644 --- a/desktop/src/features/onboarding/useClaimInvite.ts +++ b/desktop/src/features/onboarding/useClaimInvite.ts @@ -1,11 +1,7 @@ import * as React from "react"; import { useCommunityOnboarding } from "@/features/onboarding/communityOnboarding"; -import { - inviteErrorMessage, - isInviteExhaustedError, - isInviteExpiredError, -} from "@/shared/api/inviteHelpers"; +import { inviteClaimErrorMessage } from "@/shared/api/inviteHelpers"; import { claimInvite } from "@/shared/api/invites"; /** @@ -38,11 +34,7 @@ export function useClaimInvite() { .catch((error: unknown) => update( { - error: isInviteExpiredError(error) - ? "This invite code has expired — ask for a new one." - : isInviteExhaustedError(error) - ? "This invite has reached its use limit. Ask for a new invite." - : inviteErrorMessage(error), + error: inviteClaimErrorMessage(error), }, transaction.id, ), diff --git a/desktop/src/shared/api/inviteHelpers.ts b/desktop/src/shared/api/inviteHelpers.ts index 2b5efe4e945..63e7e529369 100644 --- a/desktop/src/shared/api/inviteHelpers.ts +++ b/desktop/src/shared/api/inviteHelpers.ts @@ -87,6 +87,24 @@ export function inviteErrorMessage(error: unknown): string { return error instanceof Error ? error.message : `${error}`; } +/** Explain claim denials without suggesting a new link can bypass restrictions. */ +export function inviteClaimErrorMessage(error: unknown): string { + const message = inviteErrorMessage(error); + if (message === "invite_restricted") { + return "This account or the invite issuer is restricted in this community. Contact a community administrator. If the restriction is temporary, try again after it ends."; + } + if (message === "invite_owner_mismatch") { + return "This account does not match its recorded owner. Use the correct account or owner identity to join this community."; + } + if (isInviteExpiredError(error)) { + return "This invite code has expired — ask for a new one."; + } + if (isInviteExhaustedError(error)) { + return "This invite has reached its use limit. Ask for a new invite."; + } + return message; +} + export function isInviteExpiredError(error: unknown): boolean { return inviteErrorMessage(error) === INVITE_EXPIRED_ERROR; } diff --git a/mobile/lib/features/invites/invite_join_provider.dart b/mobile/lib/features/invites/invite_join_provider.dart index b7a6a7731d4..885677ef38a 100644 --- a/mobile/lib/features/invites/invite_join_provider.dart +++ b/mobile/lib/features/invites/invite_join_provider.dart @@ -437,6 +437,12 @@ bool _requiresFreshInvite(Object error) { String _friendlyInviteError(Object error) { final message = error.toString(); + if (message.contains('invite_restricted')) { + return 'This account or the invite issuer is restricted in this community. Contact a community administrator. If the restriction is temporary, try again after it ends.'; + } + if (message.contains('invite_owner_mismatch')) { + return 'This account does not match its recorded owner. Use the correct account or owner identity to join this community.'; + } if (message.contains('invite_expired')) return 'This invite has expired.'; if (message.contains('invite_exhausted')) { return 'This invite has reached its use limit. Ask for a new invite.'; diff --git a/mobile/lib/shared/hosted/hosted_admission.dart b/mobile/lib/shared/hosted/hosted_admission.dart index fa7e75a5910..706e4684a4e 100644 --- a/mobile/lib/shared/hosted/hosted_admission.dart +++ b/mobile/lib/shared/hosted/hosted_admission.dart @@ -107,7 +107,11 @@ class HostedAdmission { await readJoinResponse(_http, request); checkCurrent(); } on HostedInviteFailure catch (failure) { - if (failure == HostedInviteFailure.policyRequired) rethrow; + if (failure == HostedInviteFailure.policyRequired || + failure == HostedInviteFailure.restricted || + failure == HostedInviteFailure.ownerMismatch) { + rethrow; + } checkCurrent(); // A lost claim response may outlive its code. AUTH alone also admits // non-members on open relays; reconcile explicit membership first. diff --git a/mobile/lib/shared/hosted/hosted_flow.dart b/mobile/lib/shared/hosted/hosted_flow.dart index e57affb470d..414b12a3537 100644 --- a/mobile/lib/shared/hosted/hosted_flow.dart +++ b/mobile/lib/shared/hosted/hosted_flow.dart @@ -52,6 +52,8 @@ enum HostedFlowProblem { inviteExpired, inviteExhausted, inviteInvalid, + inviteRestricted, + inviteOwnerMismatch, communityCreationRejected, } @@ -86,7 +88,8 @@ class HostedFlow extends ChangeNotifier { bool get canRetry => problem == null || problem == HostedFlowProblem.retry || - problem == HostedFlowProblem.invitePolicyRequired; + problem == HostedFlowProblem.invitePolicyRequired || + problem == HostedFlowProblem.inviteRestricted; final Future Function(Uri) _openWeb; HostedFlowStage stage = HostedFlowStage.idle; HostedFlowProblem? problem; @@ -734,6 +737,8 @@ class HostedFlow extends ChangeNotifier { HostedInviteFailure.expired => HostedFlowProblem.inviteExpired, HostedInviteFailure.exhausted => HostedFlowProblem.inviteExhausted, HostedInviteFailure.invalid => HostedFlowProblem.inviteInvalid, + HostedInviteFailure.restricted => HostedFlowProblem.inviteRestricted, + HostedInviteFailure.ownerMismatch => HostedFlowProblem.inviteOwnerMismatch, AccountFailure.denied => HostedFlowProblem.denied, AccountFailure.accountSetupRequired => HostedFlowProblem.accountSetupRequired, diff --git a/mobile/lib/shared/hosted/hosted_flow_problem_message.dart b/mobile/lib/shared/hosted/hosted_flow_problem_message.dart index 957804c940b..5200786daf6 100644 --- a/mobile/lib/shared/hosted/hosted_flow_problem_message.dart +++ b/mobile/lib/shared/hosted/hosted_flow_problem_message.dart @@ -17,6 +17,10 @@ String hostedFlowProblemMessage( 'This invite has no uses left. Ask for a new invite link.', HostedFlowProblem.inviteInvalid => 'This invite is not valid. Ask for a new invite link.', + HostedFlowProblem.inviteRestricted => + 'This account or the invite issuer is restricted in this community. Contact a community administrator. If the restriction is temporary, try again after it ends.', + HostedFlowProblem.inviteOwnerMismatch => + 'This account does not match its recorded owner. Use the correct account or owner identity to join this community.', HostedFlowProblem.denied => 'This account does not have access.', HostedFlowProblem.differentAccount => 'Your browser signed in to a different account. Sign in to your original account on the web, then reopen setup. Your saved account and keys have been kept.', diff --git a/mobile/lib/shared/hosted/hosted_join_policy.dart b/mobile/lib/shared/hosted/hosted_join_policy.dart index d91ac8dfde2..e28ca7b3b03 100644 --- a/mobile/lib/shared/hosted/hosted_join_policy.dart +++ b/mobile/lib/shared/hosted/hosted_join_policy.dart @@ -17,7 +17,14 @@ class HostedJoinPolicy { final String? privacy; } -enum HostedInviteFailure { policyRequired, expired, exhausted, invalid } +enum HostedInviteFailure { + policyRequired, + expired, + exhausted, + invalid, + restricted, + ownerMismatch, +} /// Reads only a bounded JSON document, never follows redirects or logs bodies. Future> readJoinResponse( @@ -45,6 +52,8 @@ Future> readJoinResponse( 'invite_expired' => HostedInviteFailure.expired, 'invite_exhausted' => HostedInviteFailure.exhausted, 'invite_invalid' => HostedInviteFailure.invalid, + 'invite_restricted' => HostedInviteFailure.restricted, + 'invite_owner_mismatch' => HostedInviteFailure.ownerMismatch, _ => AccountFailure.denied, }; } diff --git a/mobile/test/features/invites/invite_join_provider_test.dart b/mobile/test/features/invites/invite_join_provider_test.dart index cd350b673ed..7883e439d95 100644 --- a/mobile/test/features/invites/invite_join_provider_test.dart +++ b/mobile/test/features/invites/invite_join_provider_test.dart @@ -614,6 +614,68 @@ void main() { expect(attempts, 1); }); + for (final (sentinel, guidance) in [ + ( + 'invite_restricted', + 'This account or the invite issuer is restricted in this community. Contact a community administrator. If the restriction is temporary, try again after it ends.', + ), + ( + 'invite_owner_mismatch', + 'This account does not match its recorded owner. Use the correct account or owner identity to join this community.', + ), + ]) { + test('$sentinel explains recovery without replacing the invite', () async { + final keys = nostr.Keys.generate(); + var attempts = 0; + final storage = CommunityStorage(secure: FakeSecureStorage()); + final auth = _RecordingAuthNotifier(); + final container = ProviderContainer( + overrides: [ + communityStorageProvider.overrideWithValue(storage), + authProvider.overrideWith(() => auth), + inviteKeyGeneratorProvider.overrideWithValue(() => keys), + inviteJoinRecoveryProvider.overrideWithValue( + (_) => _successfulRecovery(), + ), + inviteJoinHttpClientProvider.overrideWithValue( + http_testing.MockClient((request) async { + attempts++; + expect(jsonDecode(request.body)['code'], 'same-code'); + return attempts == 1 + ? http.Response(jsonEncode({'error': sentinel}), 403) + : http.Response(jsonEncode({'status': 'joined'}), 200); + }), + ), + ], + ); + addTearDown(container.dispose); + final notifier = container.read(inviteJoinProvider.notifier); + await notifier.prepare( + const InviteDeepLink( + relayUrl: 'wss://relay.example.com', + code: 'same-code', + ), + ); + await notifier.confirmJoin(); + + final denied = container.read(inviteJoinProvider); + expect(denied.status, InviteJoinStatus.error); + expect(denied.errorMessage, guidance); + expect(denied.requiresFreshInvite, isFalse); + expect(attempts, 1); + expect(auth.authenticatedCommunities, isEmpty); + expect(await storage.loadAll(), isEmpty); + + // Model an explicit retry after the restriction or identity is corrected. + await notifier.confirmJoin(); + expect( + container.read(inviteJoinProvider).status, + InviteJoinStatus.success, + ); + expect(attempts, 2); + }); + } + test('failed claim can be retried and preserves policy receipt', () async { final keys = nostr.Keys.generate(); var attempts = 0; diff --git a/mobile/test/features/onboarding/hosted_journeys_test.dart b/mobile/test/features/onboarding/hosted_journeys_test.dart index 450219f7f69..548a855eb13 100644 --- a/mobile/test/features/onboarding/hosted_journeys_test.dart +++ b/mobile/test/features/onboarding/hosted_journeys_test.dart @@ -744,6 +744,68 @@ void main() { }, ); + for (final (sentinel, guidance, retryable) in [ + ( + 'invite_restricted', + 'This account or the invite issuer is restricted in this community. Contact a community administrator. If the restriction is temporary, try again after it ends.', + true, + ), + ( + 'invite_owner_mismatch', + 'This account does not match its recorded owner. Use the correct account or owner identity to join this community.', + false, + ), + ]) { + journey('$sentinel is rendered without discarding the hosted invite', ( + tester, + f, + ) async { + await f.initialize(); + f.offerProfile = false; + f.claimError = sentinel; + // Even recorded membership must not turn a current explicit denial + // into successful admission through lost-response reconciliation. + f.rows.add(f.row('one')); + await f.mount(tester); + await f.link(tester, 'one'); + await tester.tap(find.byType(CheckboxListTile)); + await f.settle(tester); + await f.tap(tester, 'Accept and join'); + expect(find.text(guidance), findsOneWidget); + expect(await f.storage.loadAll(), isEmpty); + expect(f.probes, isEmpty); + expect(f.sent('/claim'), hasLength(1)); + expect(f.sent('/communities/joined'), isEmpty); + expect( + find.text('Try again').hitTestable(), + retryable ? findsOneWidget : findsNothing, + ); + await f.settle(tester); + expect(f.sent('/claim'), hasLength(1)); + expect((await f.flow.browser.retainedIntent())!['code'], 'invite-one'); + f.claimError = null; + if (retryable) { + await f.tap(tester, 'Try again'); + } else { + // Owner mismatch requires the correct identity, not an automatic + // retry. Explicitly abandon setup, then reopen the same link after + // the server-side owner conflict has been corrected. + await f.tap(tester, 'Leave setup'); + expect((await f.store.read())!.pubkey, f.keys.public); + await f.link(tester, 'one'); + } + await tester.tap(find.byType(CheckboxListTile)); + await f.settle(tester); + await f.tap(tester, 'Accept and join'); + expect(f.sent('/claim'), hasLength(2)); + for (final request in f.sent('/claim')) { + expect(jsonDecode(request.body)['code'], 'invite-one'); + } + expect((await f.storage.loadAll()).single.pubkey, f.keys.public); + expect(find.text('Community home').hitTestable(), findsOneWidget); + }, requiresAuthDomain: true); + } + journey( 'second invitation replaces pending consent and only joins the newer community', (tester, f) async { diff --git a/mobile/test/helpers/hosted_journey_fixture.dart b/mobile/test/helpers/hosted_journey_fixture.dart index 60ba65fe749..e90110a7366 100644 --- a/mobile/test/helpers/hosted_journey_fixture.dart +++ b/mobile/test/helpers/hosted_journey_fixture.dart @@ -74,6 +74,7 @@ class HostedJourneyFixture { bool loseCreateResponse = false; bool loseClaimResponse = false; bool inviteExpired = false; + String? claimError; final owned = >[]; final expiry = DateTime.now() .toUtc() @@ -258,6 +259,9 @@ class HostedJourneyFixture { } result = {'receipt': 'synthetic-policy-receipt'}; } else if (path == '/api/invites/claim') { + if (claimError != null) { + return http.Response(jsonEncode({'error': claimError}), 403); + } if (loseClaimResponse) { loseClaimResponse = false; rows.add(row(request.url.host.split('.').first)); diff --git a/web/src/features/invite/ui/InvitePage.tsx b/web/src/features/invite/ui/InvitePage.tsx index 9d4034dafc4..e412c63d9e8 100644 --- a/web/src/features/invite/ui/InvitePage.tsx +++ b/web/src/features/invite/ui/InvitePage.tsx @@ -26,6 +26,12 @@ type PolicyDocument = { title: string; markdown: string }; /** Convert relay invite sentinels into user-facing recovery guidance. */ function inviteClaimErrorMessage(message: string): string { + if (message.includes("invite_restricted")) { + return "This account or the invite issuer is restricted in this community. Contact a community administrator. If the restriction is temporary, try again after it ends."; + } + if (message.includes("invite_owner_mismatch")) { + return "This account does not match its recorded owner. Use the correct account or owner identity to join this community."; + } if (message.includes("invite_exhausted")) { return "This invite has reached its use limit. Ask for a new invite."; } diff --git a/web/tests/e2e/smoke.spec.ts b/web/tests/e2e/smoke.spec.ts index 11bec34409e..1ea5818b737 100644 --- a/web/tests/e2e/smoke.spec.ts +++ b/web/tests/e2e/smoke.spec.ts @@ -209,6 +209,66 @@ test("invite can enroll a NIP-07 identity for browser access", async ({ expect(claimObserved).toBe(true); }); +for (const [sentinel, guidance] of [ + [ + "invite_restricted", + "This account or the invite issuer is restricted in this community. Contact a community administrator. If the restriction is temporary, try again after it ends.", + ], + [ + "invite_owner_mismatch", + "This account does not match its recorded owner. Use the correct account or owner identity to join this community.", + ], +]) { + test(`invite shows recovery for ${sentinel} without automatically retrying`, async ({ + page, + }) => { + await page.addInitScript(() => { + Object.assign(window, { + nostr: { + async getPublicKey() { + return "ab".repeat(32); + }, + async signEvent(event: Record) { + return { + ...event, + id: "cd".repeat(32), + pubkey: "ab".repeat(32), + sig: "ef".repeat(64), + }; + }, + }, + }); + }); + await page.route("**/api/join-policy", (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ policy: null }), + }), + ); + let attempts = 0; + await page.route("**/api/invites/claim", async (route) => { + attempts++; + expect(route.request().postDataJSON()).toEqual({ code: "browser-code" }); + await route.fulfill({ + status: 403, + contentType: "application/json", + body: JSON.stringify({ error: sentinel }), + }); + }); + await page.goto("/invite/browser-code"); + const join = page.getByRole("button", { name: "Join in browser" }); + await join.click(); + await expect(page.getByRole("alert")).toHaveText(guidance); + await expect(join).toBeEnabled(); + expect(attempts).toBe(1); + await expect(page).toHaveURL(/\/invite\/browser-code$/); + await join.click(); + await expect(page.getByRole("alert")).toHaveText(guidance); + expect(attempts).toBe(2); + }); +} + test("invite asks Safari users to choose their Mac download", async ({ browser, }) => { From e1afe121299aaf0ffbe6fb7fcde522b695aac2bf Mon Sep 17 00:00:00 2001 From: Brad Seiler Date: Wed, 21 Oct 2026 14:02:46 -0700 Subject: [PATCH 23/34] Synchronize local task signal tests with pending input Wait for stdin consumption instead of elapsed startup time before delivering SIGTERM. Keep the stream open and the input below its limit so the real runner remains blocked awaiting EOF. Preserve the serial startup, turn and stdin cases while adding case labels and raw exit status to cancellation diagnostics. Signed-off-by: seiler Co-authored-by: Codex Signed-off-by: Brad Seiler --- crates/buzz-acp/tests/run_task.rs | 73 ++++++++++++++++++++++++++----- 1 file changed, 61 insertions(+), 12 deletions(-) diff --git a/crates/buzz-acp/tests/run_task.rs b/crates/buzz-acp/tests/run_task.rs index fec3d786917..6e36927510a 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) ); @@ -411,6 +413,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::{ @@ -427,9 +455,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 @@ -437,24 +476,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() { From 88aaa4d210e18a44dd2cf3fb8b8794f7ffba6227 Mon Sep 17 00:00:00 2001 From: seiler Date: Wed, 21 Oct 2026 21:39:54 +0000 Subject: [PATCH 24/34] Synchronize descendant timeout fixture before measuring cleanup Wait for a complete live descendant PID within the existing outer watchdog before starting the short timeout. Spawn completion alone does not guarantee that the shell has recorded its child. Share the unchanged output collector with the fixture and retain independent group cleanup on setup failure or an expired watchdog. Keep timeout, cancellation and descendant-death assertions intact. Signed-off-by: seiler Co-authored-by: Codex Signed-off-by: seiler Signed-off-by: Brad Seiler --- .../discovery/bounded_command.rs | 137 +++++++++++++++--- 1 file changed, 115 insertions(+), 22 deletions(-) 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 16eb01231b0..593ff5dc609 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,116 @@ mod tests { #[cfg(unix)] #[test] fn reaps_descendant_on_timeout() { + // 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 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 started = Instant::now(); + let bound = Duration::from_secs(5); 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"); + 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 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 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.armed = false; + 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.armed = false; } // Deterministic seam regression (Thufir's finding): a drain fed a reader From 51b8e9c30f81be064281c1879ebdd926f479bbbe Mon Sep 17 00:00:00 2001 From: seiler Date: Wed, 21 Oct 2026 22:45:14 +0000 Subject: [PATCH 25/34] Disarm timeout fixture guards through their mutable receiver Replace both direct field assignments with a FixtureTree method so guard state mutation is explicit and avoids the captured-field unused-assignment warning under warnings-as-errors Clippy. Preserve disarming before result delivery and after the descendant-death assertion. Signed-off-by: seiler Co-authored-by: Codex Signed-off-by: Brad Seiler --- .../src/managed_agents/discovery/bounded_command.rs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) 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 593ff5dc609..70297885bb4 100644 --- a/desktop/src-tauri/src/managed_agents/discovery/bounded_command.rs +++ b/desktop/src-tauri/src/managed_agents/discovery/bounded_command.rs @@ -594,6 +594,11 @@ mod tests { child: Option, armed: bool, } + impl FixtureTree { + fn disarm(&mut self) { + self.armed = false; + } + } impl Drop for FixtureTree { fn drop(&mut self) { if self.armed { @@ -677,7 +682,7 @@ mod tests { worker.child.as_mut().expect("owned fixture"), Duration::from_millis(300), ); - worker.armed = false; + worker.disarm(); let _ = tx.send((descendant_pid, result)); }); let (descendant_pid, result) = rx @@ -694,7 +699,7 @@ mod tests { !pid_alive(descendant_pid), "backgrounded descendant {descendant_pid} must be group-killed on timeout, but it survived" ); - fixture.armed = false; + fixture.disarm(); } // Deterministic seam regression (Thufir's finding): a drain fed a reader From 85f08b9692135126050ea0af6518bd7bc03c6f57 Mon Sep 17 00:00:00 2001 From: seiler Date: Thu, 22 Oct 2026 05:30:04 +0000 Subject: [PATCH 26/34] fix(db): order invitation expansion after channel join roles Keep main migration 0067 intact and move the unpublished invitation expansion to 0068. Exercise populated upgrades from complete main through 0067, retaining guest channel admission policy on both schema paths. Signed-off-by: seiler Co-authored-by: Codex --- TESTING.md | 4 +- crates/buzz-db/TESTING.md | 8 ++- crates/buzz-db/src/runtime/migration.rs | 9 ++- .../relay_invite/upgrade_postgres_tests.rs | 58 +++++++++++++++++-- ...ocation.sql => 0068_invite_revocation.sql} | 0 5 files changed, 66 insertions(+), 13 deletions(-) rename migrations/{0067_invite_revocation.sql => 0068_invite_revocation.sql} (100%) diff --git a/TESTING.md b/TESTING.md index 83d396b83e4..dbb492fe0f5 100644 --- a/TESTING.md +++ b/TESTING.md @@ -194,7 +194,7 @@ 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 0067 is schema-only: it adds +restriction rejection until their repair. Migration 0068 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. @@ -205,7 +205,7 @@ 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 0067 and +verify no old writer remains. Keep them stopped while applying schema 0068 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 diff --git a/crates/buzz-db/TESTING.md b/crates/buzz-db/TESTING.md index 2bcaad515b0..7d997a07919 100644 --- a/crates/buzz-db/TESTING.md +++ b/crates/buzz-db/TESTING.md @@ -64,6 +64,8 @@ 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 0066 and -exercise both migration and desired-state paths against populated data. The -historical repair is validated separately from the application/schema change. +Invite upgrade tests start from the complete schema through migration 0067, +including channel join roles. They exercise the invitation schema expansion +(migration 0068) 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/runtime/migration.rs b/crates/buzz-db/src/runtime/migration.rs index 48e6d40eb0e..96640f89772 100644 --- a/crates/buzz-db/src/runtime/migration.rs +++ b/crates/buzz-db/src/runtime/migration.rs @@ -769,7 +769,10 @@ mod postgres_tests { let mut migrations: Vec<_> = MIGRATOR.iter().collect(); migrations.sort_by_key(|migration| migration.version); - assert_eq!(migrations.len(), 67); + assert_eq!(migrations.len(), 68); + for (index, migration) in migrations.iter().enumerate() { + assert_eq!(migration.version, (index + 1) as i64); + } assert_eq!(migrations[64].version, 65); let retire_ttl = strip_sql_comments(migrations[64].sql.as_str()); for statement in [ @@ -788,8 +791,8 @@ mod postgres_tests { .sql .as_str() .contains("CREATE TABLE community_nip_fi_issuers")); - assert_eq!(migrations[66].version, 67); - let invite_migration = strip_sql_comments(migrations[66].sql.as_str()); + assert_eq!(migrations[67].version, 68); + let invite_migration = strip_sql_comments(migrations[67].sql.as_str()); assert!(invite_migration.contains("SET LOCAL lock_timeout = '5s'")); assert!(invite_migration.contains("SET LOCAL statement_timeout = '30s'")); assert!( 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 index 2a482c4484a..2b48f1471b2 100644 --- a/crates/buzz-db/src/store/relay_invite/upgrade_postgres_tests.rs +++ b/crates/buzz-db/src/store/relay_invite/upgrade_postgres_tests.rs @@ -170,8 +170,8 @@ async fn legacy_fixture() -> (PgPool, Vec) { let pool = PgPool::connect(&crate::test_support::database_url()) .await .unwrap(); - // Pinned main ends at migration 0066, so 0067 is the first candidate migration. - crate::migration::run_migrations_through(&pool, 66) + // Pinned main includes channel join roles in 0067; invitations expand in 0068. + crate::migration::run_migrations_through(&pool, 67) .await .unwrap(); let last_migration: i64 = @@ -180,7 +180,7 @@ async fn legacy_fixture() -> (PgPool, Vec) { .await .unwrap(); assert_eq!( - last_migration, 66, + last_migration, 67, "fixtures start at the complete pinned-main schema" ); @@ -336,9 +336,47 @@ async fn legacy_fixture() -> (PgPool, Vec) { 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 \ @@ -462,10 +500,18 @@ async fn apply_pgschema_to_existing_database(pool: &PgPool) { #[tokio::test] #[ignore = "requires Postgres"] -async fn migration_schema_0067_adds_invite_columns_without_rewriting_history() { +async fn migration_schema_0068_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(), + 68 + ); 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" @@ -588,9 +634,10 @@ async fn migration_schema_desired_pgschema_reconcile_matches_populated_upgrade() assert_eq!(before, cases.len() as i64); // Exercise the deployment path against populated data at the complete - // pinned-main schema through 0066, rather than reproducing its DDL by hand. + // pinned-main schema through 0067, 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) @@ -609,6 +656,7 @@ async fn migration_schema_desired_pgschema_reconcile_matches_populated_upgrade() // 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" diff --git a/migrations/0067_invite_revocation.sql b/migrations/0068_invite_revocation.sql similarity index 100% rename from migrations/0067_invite_revocation.sql rename to migrations/0068_invite_revocation.sql From b577ac2650bf4ca78cb0b0dda57123cb730eb7e6 Mon Sep 17 00:00:00 2001 From: seiler Date: Fri, 23 Oct 2026 18:26:52 +0000 Subject: [PATCH 27/34] fix(desktop): keep community actions stable across hover transitions Cancel pending hover changes when community actions explicitly open or close. Cover stale open and close timers on the rendered component and use controlled time for pointer crossing coverage. Keep composer markdown assertions focused on the latest signed message so later read-state events cannot replace the observed content. Co-authored-by: Codex Signed-off-by: seiler --- .../ui/CommunitySwitcher.jsdom-test.mjs | 64 +++++++++++++++++++ .../communities/ui/CommunitySwitcher.tsx | 19 ++---- desktop/tests/e2e/community-rail.spec.ts | 52 ++++----------- .../e2e/composer-selection-formatting.spec.ts | 53 ++++----------- 4 files changed, 96 insertions(+), 92 deletions(-) create mode 100644 desktop/src/features/communities/ui/CommunitySwitcher.jsdom-test.mjs 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 00000000000..b627493c3bc --- /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 14e358b2885..bf136e549d2 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({