diff --git a/TESTING.md b/TESTING.md index 4405053205..719bb774e3 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 2697b285b8..22167b85ac 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 788af47da3..9c9fe6de5a 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 82005849ae..3384f6023e 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 => {