Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions crates/buzz-db/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,10 @@ pub enum DbError {
#[error("access denied: {0}")]
AccessDenied(String),

/// Required initial policy cannot be created without issuer bindings.
#[error("required community creation needs NIP-FI issuer bindings")]
NipFiBindingRequired,

/// Invitation admission or minting was denied by authoritative restrictions.
#[error("invitation principal is restricted")]
InviteRestricted,
Expand Down Expand Up @@ -176,6 +180,7 @@ impl DbError {
E::InvalidData(_) => "invalid_data",
E::NotFound(_) | E::ChannelNotFound(_) | E::MemberNotFound(_) => "not_found",
E::AccessDenied(_) => "access_denied",
E::NipFiBindingRequired => "nip_fi_binding_required",
_ => "other",
};
kind.to_owned()
Expand Down
73 changes: 65 additions & 8 deletions crates/buzz-db/src/store/community.rs
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,10 @@ pub struct CreatedCommunityRecord {
pub id: CommunityId,
/// Normalized host stored for the community.
pub host: String,
/// Whether this transaction inserted the community.
pub created: bool,
/// Stored policy read from the inserted or existing row.
pub requires_nip_fi: bool,
}

/// Result of atomically creating a community with its initial owner.
Expand Down Expand Up @@ -611,7 +615,9 @@ impl Db {

/// Like [`Db::create_community_with_owner`], binding `nip_fi_issuers` to
/// the new community in the same transaction. Identical retries return the
/// original record without touching its bindings.
/// original record without touching its bindings. Operator tenant creation
/// must use [`Db::create_community_with_owner_and_nip_fi_policy`] to apply
/// the configured creation requirement.
#[datastore_span(
name = "create_community_with_owner_and_nip_fi_issuers",
system = "postgresql"
Expand All @@ -622,6 +628,31 @@ impl Db {
owner_pubkey: &str,
nip_fi_issuers: &[String],
relay_keypair: &nostr::Keys,
) -> Result<relay_members::MembershipWrite<CreateCommunityWithOwnerResult>> {
self.create_community_with_owner_and_nip_fi_policy(
normalized_host,
owner_pubkey,
nip_fi_issuers,
false,
relay_keypair,
)
.await
}

/// Atomically create a community, owner, issuer bindings and initial NIP-FI
/// policy (generation 1 when required, otherwise 0). Identical retries keep
/// the original policy and bindings. Required creation needs issuers.
#[datastore_span(
name = "create_community_with_owner_and_nip_fi_policy",
system = "postgresql"
)]
pub async fn create_community_with_owner_and_nip_fi_policy(
&self,
normalized_host: &str,
owner_pubkey: &str,
nip_fi_issuers: &[String],
requires_nip_fi: bool,
relay_keypair: &nostr::Keys,
) -> Result<relay_members::MembershipWrite<CreateCommunityWithOwnerResult>> {
use relay_members::MembershipWrite;
let owner_pubkey = owner_pubkey.to_ascii_lowercase();
Expand All @@ -644,17 +675,21 @@ impl Db {

let row = sqlx::query(
r#"
INSERT INTO communities (host)
VALUES ($1)
INSERT INTO communities (host, requires_nip_fi, policy_generation)
VALUES ($1, $2, CASE WHEN $2 THEN 1 ELSE 0 END)
ON CONFLICT (lower(host)) DO NOTHING
RETURNING id, host
RETURNING id, host, requires_nip_fi
"#,
)
.bind(normalized_host)
.bind(requires_nip_fi)
.fetch_optional(&mut *tx)
.await?;

if let Some(row) = row {
if requires_nip_fi && nip_fi_issuers.is_empty() {
return Err(DbError::NipFiBindingRequired);
}
let id: Uuid = row.try_get("id")?;
let host: String = row.try_get("host")?;

Expand Down Expand Up @@ -694,12 +729,14 @@ impl Db {
let record = CreateCommunityWithOwnerResult::Created(CreatedCommunityRecord {
id: CommunityId::from_uuid(id),
host,
created: true,
requires_nip_fi: row.try_get("requires_nip_fi")?,
});
relay_members::commit_membership_write(tx, record, true, relay_keypair).await
} else {
let existing = sqlx::query(
r#"
SELECT c.id, c.host
SELECT c.id, c.host, c.requires_nip_fi
FROM communities c
JOIN relay_members rm ON rm.community_id = c.id
WHERE lower(c.host) = lower($1)
Expand All @@ -723,6 +760,8 @@ impl Db {
let record = CreateCommunityWithOwnerResult::Created(CreatedCommunityRecord {
id: CommunityId::from_uuid(existing.try_get("id")?),
host: existing.try_get("host")?,
created: false,
requires_nip_fi: existing.try_get("requires_nip_fi")?,
});
tx.commit().await?;
Ok(MembershipWrite::unchanged(record))
Expand Down Expand Up @@ -1152,7 +1191,10 @@ mod postgres_tests {
.expect("same-owner retry");
assert_eq!(
retry,
CreateCommunityWithOwnerResult::Created(created.clone()),
CreateCommunityWithOwnerResult::Created(CreatedCommunityRecord {
created: false,
..created.clone()
}),
"retry returns the original row"
);

Expand Down Expand Up @@ -1629,8 +1671,23 @@ mod postgres_tests {
let first = first.expect("first concurrent create");
let second = second.expect("second concurrent create");

assert!(matches!(first, CreateCommunityWithOwnerResult::Created(_)));
assert_eq!(first, second, "conflict loser re-reads the winning row");
let (
CreateCommunityWithOwnerResult::Created(first),
CreateCommunityWithOwnerResult::Created(second),
) = (first, second)
else {
panic!("both concurrent callers must receive the stored community");
};
assert_eq!(
first.id, second.id,
"conflict loser re-reads the winning row"
);
assert_eq!(first.host, second.host);
assert_eq!(first.requires_nip_fi, second.requires_nip_fi);
assert_ne!(
first.created, second.created,
"exactly one caller inserted the row"
);
}

#[tokio::test]
Expand Down
93 changes: 93 additions & 0 deletions crates/buzz-db/src/store/nip_fi_binding.rs
Original file line number Diff line number Diff line change
Expand Up @@ -613,6 +613,99 @@ mod postgres_tests {
assert!(!community_exists(&db, &bootstrap).await);
}

#[tokio::test]
#[ignore = "requires Postgres"]
async fn initial_policy_is_atomic_and_replays_preserve_current_state() {
let db = setup_db().await;
let keys = nostr::Keys::generate();
for required in [false, true] {
let host = unique_host("initial-policy");
let owner = nostr::Keys::generate().public_key().to_hex();
let write = db
.create_community_with_owner_and_nip_fi_policy(
&host,
&owner,
&issuers(),
required,
&keys,
)
.await
.unwrap();
let CreateCommunityWithOwnerResult::Created(record) = write.outcome else {
panic!("not created")
};
assert!(record.created);
assert_eq!(record.requires_nip_fi, required);
let before = db.community_auth_policy(record.id).await.unwrap().unwrap();
assert_eq!(before.requires_nip_fi, required);
assert_eq!(before.policy_generation, i64::from(required));
assert_eq!(bound(&db, &host).await, Some(issuers()));
let owners = db.list_communities_owned_by(&owner).await.unwrap();
assert_eq!(owners.communities[0].id, record.id);
// Changed policy and issuer configuration never rewrite a committed row.
let changed = ["https://other.example".to_owned()];
for bindings in [&[][..], changed.as_slice()] {
let retry = db
.create_community_with_owner_and_nip_fi_policy(
&host, &owner, bindings, !required, &keys,
)
.await
.unwrap();
let CreateCommunityWithOwnerResult::Created(retry) = retry.outcome else {
panic!("not replayed")
};
assert!(!retry.created);
assert_eq!(retry.requires_nip_fi, required);
assert_eq!(
db.community_auth_policy(record.id).await.unwrap().unwrap(),
before
);
assert_eq!(bound(&db, &host).await, Some(issuers()));
}
// Model an operator disable. A later true replay must report the stored false.
sqlx::query("UPDATE communities SET requires_nip_fi = false, policy_generation = policy_generation + 1 WHERE id = $1")
.bind(record.id.as_uuid()).execute(&db.pool).await.unwrap();
let disabled = db.community_auth_policy(record.id).await.unwrap().unwrap();
let replay = db
.create_community_with_owner_and_nip_fi_policy(&host, &owner, &[], true, &keys)
.await
.unwrap();
let CreateCommunityWithOwnerResult::Created(replay) = replay.outcome else {
panic!("not replayed")
};
assert!(!replay.created);
assert!(!replay.requires_nip_fi);
assert_eq!(
db.community_auth_policy(record.id).await.unwrap().unwrap(),
disabled
);
assert_eq!(bound(&db, &host).await, Some(issuers()));
let conflict = db
.create_community_with_owner_and_nip_fi_policy(
&host,
&nostr::Keys::generate().public_key().to_hex(),
&[],
true,
&keys,
)
.await
.unwrap();
assert_eq!(conflict.outcome, CreateCommunityWithOwnerResult::HostExists);
}
for bindings in [vec![], vec![String::new()]] {
let host = unique_host("refused-initial-policy");
let refusal = db
.create_community_with_owner_and_nip_fi_policy(&host, OWNER, &bindings, true, &keys)
.await;
if bindings.is_empty() {
assert!(matches!(refusal, Err(crate::DbError::NipFiBindingRequired)));
} else {
assert!(refusal.is_err());
}
assert!(!community_exists(&db, &host).await);
}
}

// Deletion takes its exclusive lock, then the community row. A bind that
// held the row while waiting for that lock would deadlock with it.
// Mutation: locking the row before `guard_transaction` in
Expand Down
13 changes: 13 additions & 0 deletions crates/buzz-relay/src/api/operator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -423,6 +423,18 @@ pub async fn provision_community(
Err(msg) if msg.starts_with("actor not authorized") => {
Err(api_error(StatusCode::FORBIDDEN, &msg))
}
Err(msg) if msg.starts_with("nip_fi_binding_required:") => Err(coded_api_error(
StatusCode::BAD_REQUEST,
"nip_fi_binding_required",
&msg,
)),
Err(msg) if msg.starts_with("initial_policy_requires_create_only:") => {
Err(coded_api_error(
StatusCode::BAD_REQUEST,
"initial_policy_requires_create_only",
&msg,
))
}
Err(msg) if msg.starts_with("limit_reached:") => {
Err(coded_api_error(StatusCode::CONFLICT, "limit_reached", &msg))
}
Expand Down Expand Up @@ -1387,6 +1399,7 @@ pub async fn no_store_upgrade_response(

#[cfg(test)]
mod postgres_tests {
mod initial_policy_postgres_tests;
use std::{
collections::HashSet,
sync::{Arc, Mutex},
Expand Down
Loading
Loading