fix(db): distinguish issuer timeout denials

Co-authored-by: Codex <codex@openai.com>
Signed-off-by: Brad Seiler <seiler@squareup.com>
Signed-off-by: Local Builder <1c476cb1127ea270cb900bdc3891060140bdd1173f91498030ee9b4db01c2f20@buzz.block.builderlab.xyz>
This commit is contained in:
Brad Seiler
2026-10-11 20:13:01 -04:00
committed by seiler
parent 1a4f366fd4
commit d6b9fbc240
4 changed files with 58 additions and 2 deletions

View File

@@ -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.

View File

@@ -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<DateTime<Utc>> = 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);

View File

@@ -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<DateTime<Utc>> =
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, .. }
));
}

View File

@@ -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 => {