diff --git a/crates/buzz-db/src/error.rs b/crates/buzz-db/src/error.rs index 56cf748c34..9ce2cb0405 100644 --- a/crates/buzz-db/src/error.rs +++ b/crates/buzz-db/src/error.rs @@ -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, @@ -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() diff --git a/crates/buzz-db/src/store/community.rs b/crates/buzz-db/src/store/community.rs index b0c35890df..50d1b20f45 100644 --- a/crates/buzz-db/src/store/community.rs +++ b/crates/buzz-db/src/store/community.rs @@ -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. @@ -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" @@ -622,6 +628,31 @@ impl Db { owner_pubkey: &str, nip_fi_issuers: &[String], relay_keypair: &nostr::Keys, + ) -> Result> { + 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> { use relay_members::MembershipWrite; let owner_pubkey = owner_pubkey.to_ascii_lowercase(); @@ -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")?; @@ -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) @@ -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)) @@ -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" ); @@ -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] diff --git a/crates/buzz-db/src/store/nip_fi_binding.rs b/crates/buzz-db/src/store/nip_fi_binding.rs index ffd25cba96..46dd231f34 100644 --- a/crates/buzz-db/src/store/nip_fi_binding.rs +++ b/crates/buzz-db/src/store/nip_fi_binding.rs @@ -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 diff --git a/crates/buzz-relay/src/api/operator.rs b/crates/buzz-relay/src/api/operator.rs index 755b41f240..92c57e188c 100644 --- a/crates/buzz-relay/src/api/operator.rs +++ b/crates/buzz-relay/src/api/operator.rs @@ -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)) } @@ -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}, diff --git a/crates/buzz-relay/src/api/operator/postgres_tests/initial_policy_postgres_tests.rs b/crates/buzz-relay/src/api/operator/postgres_tests/initial_policy_postgres_tests.rs new file mode 100644 index 0000000000..d0dc9ff1ba --- /dev/null +++ b/crates/buzz-relay/src/api/operator/postgres_tests/initial_policy_postgres_tests.rs @@ -0,0 +1,205 @@ +mod postgres_tests { + use super::super::*; + + async fn create( + state: &Arc, + operator: &Keys, + owner: &Keys, + host: &str, + create_only: bool, + policy: Option, + ) -> axum::response::Response { + let mut body = serde_json::json!({"host":host,"initial_owner_pubkey":owner.public_key().to_hex(),"create_only":create_only}); + if let Some(policy) = policy { + body["initial_requires_nip_fi"] = policy; + } + signed_operator_request( + state.clone(), + operator, + "POST", + "/operator/communities", + Some(body.to_string()), + ) + .await + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn initial_policy_and_same_owner_replay_journey() { + let operator = Keys::generate(); + let (binding, reserved, control) = reserved_host_policy(); + let mut state = + operator_test_state_with_policy(std::slice::from_ref(&operator), Some(binding)) + .await + .unwrap(); + let owners: Vec<_> = (0..4).map(|_| Keys::generate()).collect(); + let owner = &owners[3]; + assert!(!state.config.nip_fi_upgrades_enabled); + for (index, policy) in [ + None, + Some(Value::Null), + Some(Value::Bool(false)), + Some(Value::Bool(true)), + ] + .into_iter() + .enumerate() + { + let host = control.replacen("acme.", &format!("policy-{index}."), 1); + let required = policy == Some(Value::Bool(true)); + let owner = &owners[index]; + let response = create(&state, &operator, owner, &host, true, policy).await; + assert!(response.status().is_success()); + let body = read_json(response).await; + assert_eq!(body["status"], "created"); + assert_eq!(body["requires_nip_fi"], required); + let record = state + .db + .lookup_community_by_host(&host) + .await + .unwrap() + .unwrap(); + let stored = state + .db + .community_auth_policy(record.id) + .await + .unwrap() + .unwrap(); + assert_eq!(stored.requires_nip_fi, required); + assert_eq!(stored.policy_generation, i64::from(required)); + let response = create( + &state, + &operator, + owner, + &host, + true, + Some(Value::Bool(!required)), + ) + .await; + assert!(response.status().is_success()); + let replay = read_json(response).await; + assert_eq!(replay["status"], "existed"); + assert_eq!(replay["requires_nip_fi"], required); + assert_eq!( + state + .db + .community_auth_policy(record.id) + .await + .unwrap() + .unwrap(), + stored + ); + } + for value in [false, true] { + let response = create( + &state, + &operator, + owner, + &control, + false, + Some(Value::Bool(value)), + ) + .await; + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + assert_eq!( + read_json(response).await["code"], + "initial_policy_requires_create_only" + ); + } + let refused = create( + &state, + &operator, + owner, + &reserved, + true, + Some(Value::Bool(true)), + ) + .await; + assert_eq!(refused.status(), StatusCode::BAD_REQUEST); + assert_eq!(read_json(refused).await["code"], "reserved_host"); + let outside = format!("outside-{}.example", Uuid::new_v4().simple()); + let refusal = create( + &state, + &operator, + owner, + &outside, + true, + Some(Value::Bool(true)), + ) + .await; + assert_eq!(refusal.status(), StatusCode::BAD_REQUEST); + assert_eq!(read_json(refusal).await["code"], "nip_fi_binding_required"); + assert!(state + .db + .lookup_community_by_host(&outside) + .await + .unwrap() + .is_none()); + let host = control.replacen("acme.", "policy-3.", 1); + let record = state + .db + .lookup_community_by_host(&host) + .await + .unwrap() + .unwrap(); + sqlx::query("UPDATE communities SET requires_nip_fi = false, policy_generation = policy_generation + 1 WHERE id = $1") + .bind(record.id.as_uuid()).execute(state.db.pool()).await.unwrap(); + let disabled = state + .db + .community_auth_policy(record.id) + .await + .unwrap() + .unwrap(); + Arc::make_mut(&mut Arc::get_mut(&mut state).unwrap().config).nip_fi_provision_binding = + None; + let response = create( + &state, + &operator, + owner, + &host, + true, + Some(Value::Bool(true)), + ) + .await; + assert!(response.status().is_success()); + let replay = read_json(response).await; + assert_eq!(replay["status"], "existed"); + assert_eq!(replay["requires_nip_fi"], false); + assert_eq!( + state + .db + .community_auth_policy(record.id) + .await + .unwrap() + .unwrap(), + disabled + ); + let outside = format!("unbound-{}.example", Uuid::new_v4().simple()); + let response = create( + &state, + &operator, + owner, + &outside, + true, + Some(Value::Bool(true)), + ) + .await; + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + assert_eq!(read_json(response).await["code"], "nip_fi_binding_required"); + assert!(state + .db + .lookup_community_by_host(&outside) + .await + .unwrap() + .is_none()); + let unauthorized = create( + &state, + &Keys::generate(), + owner, + &outside, + true, + Some(Value::Bool(true)), + ) + .await; + assert_eq!(unauthorized.status(), StatusCode::FORBIDDEN); + } +} diff --git a/crates/buzz-relay/src/handlers/community_provisioning.rs b/crates/buzz-relay/src/handlers/community_provisioning.rs index 377783abec..e99f50b8f2 100644 --- a/crates/buzz-relay/src/handlers/community_provisioning.rs +++ b/crates/buzz-relay/src/handlers/community_provisioning.rs @@ -48,6 +48,9 @@ pub struct ProvisionCommunityRequest { /// host instead of converging or rotating ownership. #[serde(default)] pub create_only: bool, + /// Initial policy for an atomic create only; absent/null preserves legacy behavior. + #[serde(default)] + pub initial_requires_nip_fi: Option, } /// JSON response from `POST /operator/communities`. @@ -63,6 +66,9 @@ pub struct ProvisionCommunityResponse { /// Echoes the validated owner pubkey when an owner bootstrap/rotation ran. #[serde(skip_serializing_if = "Option::is_none")] pub owner_pubkey: Option, + /// Current stored policy, present for the create-only writer. + #[serde(skip_serializing_if = "Option::is_none")] + pub requires_nip_fi: Option, } pub(crate) fn validate_pubkey_hex(value: &str) -> Option { @@ -253,6 +259,12 @@ pub async fn provision_community( return Err("actor not authorized: not a relay operator".to_string()); } + if request.initial_requires_nip_fi.is_some() && !request.create_only { + return Err( + "initial_policy_requires_create_only: initial_requires_nip_fi requires create_only" + .to_owned(), + ); + } validate_host(&request.host)?; let nip_fi_policy = state.config.nip_fi_provision_binding.as_ref(); // Unbound either way: `issuers_for_new_host` binds nothing on a reserved host. @@ -278,14 +290,18 @@ pub async fn provision_community( })?; let write = state .db - .create_community_with_owner_and_nip_fi_issuers( + .create_community_with_owner_and_nip_fi_policy( &request.host, owner_hex, nip_fi_issuers, + request.initial_requires_nip_fi.unwrap_or(false), &state.relay_keypair, ) .await - .map_err(|e| format!("failed to create community: {e}"))?; + .map_err(|e| match e { + buzz_db::DbError::NipFiBindingRequired => format!("nip_fi_binding_required: {e}"), + _ => format!("failed to create community: {e}"), + })?; let record = match write.outcome { buzz_db::CreateCommunityWithOwnerResult::Created(record) => record, buzz_db::CreateCommunityWithOwnerResult::HostExists => { @@ -301,7 +317,9 @@ pub async fn provision_community( community = %record.id, host = %record.host, owner = %owner_hex, - "community created via operator endpoint" + created = record.created, + requires_nip_fi = record.requires_nip_fi, + "community provisioned via operator endpoint" ); record_binding_creation( state.nip_fi_resolver.as_deref(), @@ -313,8 +331,9 @@ pub async fn provision_community( return Ok(ProvisionCommunityResponse { community_id: record.id.to_string(), host: record.host, - status: "created", + status: if record.created { "created" } else { "existed" }, owner_pubkey: initial_owner, + requires_nip_fi: Some(record.requires_nip_fi), }); } @@ -369,6 +388,7 @@ pub async fn provision_community( host: record.host, status: if record.created { "created" } else { "existed" }, owner_pubkey: initial_owner, + requires_nip_fi: None, }) } diff --git a/docs/operator-nip-fi-bindings.md b/docs/operator-nip-fi-bindings.md index 8afecc7a10..683d82fef3 100644 --- a/docs/operator-nip-fi-bindings.md +++ b/docs/operator-nip-fi-bindings.md @@ -65,6 +65,36 @@ they end at their own session deadline. Alert on `time() - buzz_nip_fi_binding_snapshot_loaded_timestamp_seconds > 120` and on `absent(buzz_nip_fi_binding_snapshot_loaded_timestamp_seconds)`. +## Initial policy on operator creation + +A trusted NIP-98 operator may send `initial_requires_nip_fi: true` with +`create_only: true` to `POST /operator/communities`. The community, owner, +issuer bindings and required policy (generation 1) commit in one transaction. +Omitted, null or false creates an optional community at generation 0. A non-null +policy field without `create_only: true` returns 400 +`initial_policy_requires_create_only`. + +Required insertion needs an effective provision binding for the host; otherwise +400 `nip_fi_binding_required` leaves no row. Reserved hosts retain the existing +`reserved_host` refusal. The successful response includes the stored +`requires_nip_fi` and `status: "created"` for insertion or `"existed"` for a +same-owner replay. A replay changes neither policy nor bindings, including after +an operator disable or a change in issuer configuration. A different owner +retains the existing conflict. Legacy ensure and deployment bootstrap remain +optional and do not accept an explicit initial policy. + +This policy is independent of `BUZZ_NIP_FI_UPGRADES_ENABLED`. Mode `off` stores +it but bypasses enforcement. The production creation paths are operator +create-only, legacy operator ensure, and deployment bootstrap; only create-only +accepts this choice. + +Upgrade the **whole serving relay fleet** before callers request true. Older +writers may ignore the unknown field and insert an optional row. Checking the +created response for stored true detects that incompatible writer, but cannot +undo its insertion; a later existing-row lookup remains read-only. Turn caller +targeting off before rolling back the relay. Turning it off affects future +insertions only; it does not clear existing policy. + ## Commands ```sh