diff --git a/TESTING.md b/TESTING.md index 2b290e20aa..7f176faca4 100644 --- a/TESTING.md +++ b/TESTING.md @@ -219,6 +219,9 @@ keep it closed until it is active and repaired, then recheck zero before admitti traffic. After repair, timeouts/mutes only block temporarily; active bans still revoke permanently. A rollback to code that ignores `revoked_at` requires invitation admission paused, using the same traffic/writer controls if needed. +It also requires `BUZZ_AUTO_MIGRATE` off: with auto-migration on (the Helm +default), the old binary fails at startup with SQLx `VersionMissing(74)`. See +[docs/database.md](docs/database.md) Rule 5. Legacy owner attribution uses the agent's currently recorded `agent_owner_pubkey`; deleting an owner user row out of band clears that link through its foreign key, so a later repair cannot recover the former diff --git a/crates/buzz-db/src/store/relay_invite.rs b/crates/buzz-db/src/store/relay_invite.rs index 7b37dcb702..614606a18f 100644 --- a/crates/buzz-db/src/store/relay_invite.rs +++ b/crates/buzz-db/src/store/relay_invite.rs @@ -199,6 +199,9 @@ pub async fn mint_relay_invite( /// Lock invitation admission against community restriction and owner writes. /// Call before reading restrictions or locking an invite row, and hold to commit. +/// A joined claim holds it through its NIP-43 membership snapshot, so mint, ban, +/// timeout and first owner link wait for that snapshot, which grows with the +/// member count. Claims reject unknown codes before taking it. pub(crate) async fn lock_admission( tx: &mut sqlx::PgConnection, community: CommunityId, @@ -420,6 +423,21 @@ pub async fn claim_relay_invite_with_owner( verified_owner: Option<&str>, relay_keypair: &nostr::Keys, ) -> Result> { + // Unknown codes are free to forge, so reject them before taking the + // community-wide admission lock; the locked read below stays authoritative. + let known: bool = sqlx::query_scalar( + "SELECT EXISTS (SELECT 1 FROM relay_invites \ + WHERE community_id = $1 AND token_hash = $2)", + ) + .bind(community.as_uuid()) + .bind(token_hash) + .fetch_one(pool) + .await?; + if !known { + log_claim_outcome(community, None, "invalid", None, None); + return Ok(MembershipWrite::unchanged(ClaimOutcome::Invalid.into())); + } + let mut tx = crate::begin_community_write_transaction( pool, community, 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 3e52567b30..7e275b1263 100644 --- a/crates/buzz-db/src/store/relay_invite/claim_owner.rs +++ b/crates/buzz-db/src/store/relay_invite/claim_owner.rs @@ -82,7 +82,9 @@ pub(super) async fn persist_claim_owner( } 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; - // Defensive parity with other owner writers; admission already rejects banned owners. + // Defense in depth: `claimant_restricted` already refused a banned owner + // under the same admission lock, so this finds nothing today. It keeps + // parity with every other owner writer if that check ever moves. if link_created { super::revoke_restricted_agent_invites(tx, community, &claimant_bytes, &owner_bytes) .await?; 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 index 871423ce34..7157267ec9 100644 --- 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 @@ -357,3 +357,22 @@ async fn linked_authorization_owner_and_missing_agent_do_not_wait_on_admission() )); claim.rollback().await.unwrap(); } + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn unknown_claim_code_is_rejected_without_waiting_on_admission() { + let (pool, community, _, claimant) = fixture().await; + let mut held = pool.begin().await.unwrap(); + lock_admission(&mut held, community).await.unwrap(); + // A forged code must not queue behind admission; if the claim takes the + // lock before looking the code up, this bounded call cannot finish. + let outcome = tokio::time::timeout( + std::time::Duration::from_secs(5), + claim_relay_invite_outcome(&pool, community, &[0xAB; 32], &claimant, None), + ) + .await + .expect("unknown code waited on the admission lock") + .unwrap(); + assert_eq!(outcome, ClaimOutcome::Invalid); + held.rollback().await.unwrap(); +} diff --git a/crates/buzz-relay/src/api/invites.rs b/crates/buzz-relay/src/api/invites.rs index 5ee770a3d6..e184a35c41 100644 --- a/crates/buzz-relay/src/api/invites.rs +++ b/crates/buzz-relay/src/api/invites.rs @@ -330,7 +330,7 @@ async fn mint_invite_checked( super::relay_members::OwnerLink::Unavailable => { return api_error( StatusCode::SERVICE_UNAVAILABLE, - "restriction lookup unavailable", + crate::handlers::auth::OWNER_LINK_ERROR, ) .into_response(); } @@ -517,6 +517,9 @@ pub async fn claim_invite( buzz_db::relay_invite::ClaimOutcome::Exhausted => { Err(api_error(StatusCode::FORBIDDEN, "invite_exhausted")) } + // Deliberately indistinguishable, even for an issuer timeout whose + // link works again later: a claim never reveals the issuer's + // moderation state. buzz_db::relay_invite::ClaimOutcome::Revoked | buzz_db::relay_invite::ClaimOutcome::IssuerRestricted => { Err(api_error(StatusCode::FORBIDDEN, "invite_invalid")) 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 33089d64a8..029dbbadfe 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 @@ -351,7 +351,7 @@ pub(super) mod postgres_tests { assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE); assert_eq!( read_json(response).await["error"], - "restriction lookup unavailable" + crate::handlers::auth::OWNER_LINK_ERROR ); let after: i64 = sqlx::query_scalar("SELECT count(*) FROM relay_invites") .fetch_one(state.db.pool())