From 7b495d126f4b9d9441bef5b602ce5636cd003dcf Mon Sep 17 00:00:00 2001 From: Brad Seiler Date: Fri, 6 Nov 2026 13:26:35 -0500 Subject: [PATCH] Remove unfenced FI policy writers and routes Keep old receipts readable through policy status without retaining an unfenced mutation path. Require explicit CAS for every new write. Co-authored-by: Honor Signed-off-by: Brad Seiler --- crates/buzz-db/src/store/community_auth.rs | 97 +-- .../store/community_auth/postgres_tests.rs | 246 ++++---- crates/buzz-db/src/store/deletion.rs | 6 +- crates/buzz-relay/src/api/operator.rs | 594 +----------------- .../src/nip_fi_policy/postgres_tests.rs | 29 +- crates/buzz-relay/src/router.rs | 9 +- .../tests/policy_sweep_postgres_tests.rs | 20 +- docs/community-auth-upgrades.md | 35 +- docs/operator-nip-fi-policy-client.md | 46 ++ docs/operator-nip-fi-upgrade.md | 13 +- 10 files changed, 286 insertions(+), 809 deletions(-) create mode 100644 docs/operator-nip-fi-policy-client.md diff --git a/crates/buzz-db/src/store/community_auth.rs b/crates/buzz-db/src/store/community_auth.rs index 6f08b8ef33..21cee02ad2 100644 --- a/crates/buzz-db/src/store/community_auth.rs +++ b/crates/buzz-db/src/store/community_auth.rs @@ -69,16 +69,6 @@ pub struct PolicyRecord { pub current_requires_nip_fi: Option, } -impl PolicyRecord { - fn legacy(mut self) -> UpgradeRecord { - if self.acceptance.state == "superseded" { - self.acceptance.state = "unavailable".into(); - } - self.acceptance.pending_reasons = vec!["connection_closure_unverified".into()]; - self.acceptance - } -} - /// Refusal or an immutable acceptance with current status. #[derive(Debug, Clone, PartialEq, Eq)] pub enum PolicyOutcome { @@ -129,15 +119,6 @@ impl UpgradeRecord { } } -/// A refused enable never mutates policy or creates a receipt. -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum UpgradeOutcome { - /// Committed or recovered acceptance, including current target state. - Accepted(UpgradeRecord), - /// Stable machine-readable precondition failure. - Refused(&'static str), -} - /// Authoritative policy for a community in the active lifecycle. #[derive(Debug, Clone, Copy, PartialEq, Eq, FromRow)] pub struct CommunityAuthPolicy { @@ -286,21 +267,6 @@ impl Db { .bind(community.as_uuid()).fetch_optional(&mut *connection).await?) } - /// Recover a receipt only for its authenticated originating operator. - /// Ownership changes do not destroy the original caller's recovery authority. - #[datastore_span(name = "community_auth_upgrade_status", system = "postgresql")] - pub async fn community_auth_upgrade_status( - &self, - operation: Uuid, - operator: &str, - ) -> Result> { - Ok(self - .community_auth_policy_status(operation, operator) - .await? - .filter(|record| record.expected_policy_generation.is_none()) - .map(PolicyRecord::legacy)) - } - /// Recover any policy receipt for its original authenticated operator. pub async fn community_auth_policy_status( &self, @@ -325,49 +291,10 @@ impl Db { request: &PolicyRequest, configured_trust: std::result::Result<(), &'static str>, ) -> Result { - self.write_community_nip_fi_policy( - &request.tuple, - Some(( - request.requires_nip_fi, - request.expected_policy_generation, - request.expected_requires_nip_fi, - )), - configured_trust, - ) - .await - } - - /// Atomically enable FI and persist the full idempotency tuple. - /// - /// `configured_trust` is the relay's trusted configuration check. Stored - /// binding presence is rechecked under a row lock here. No issuer is added. - /// Retries recover acceptance before rechecking mutable preconditions. - #[datastore_span(name = "enable_community_nip_fi", system = "postgresql")] - pub async fn enable_community_nip_fi( - &self, - request: &UpgradeRequest, - configured_trust: std::result::Result<(), &'static str>, - ) -> Result { - Ok( - match self - .write_community_nip_fi_policy(request, None, configured_trust) - .await? - { - PolicyOutcome::Accepted(record) => UpgradeOutcome::Accepted((*record).legacy()), - PolicyOutcome::Refused(code) => UpgradeOutcome::Refused(code), - }, - ) - } - - async fn write_community_nip_fi_policy( - &self, - request: &UpgradeRequest, - intent: Option<(bool, i64, bool)>, - configured_trust: std::result::Result<(), &'static str>, - ) -> Result { - let desired = intent.is_none_or(|i| i.0); - let expected_generation = intent.map(|i| i.1); - let expected_state = intent.map(|i| i.2); + let desired = request.requires_nip_fi; + let expected_generation = request.expected_policy_generation; + let expected_state = request.expected_requires_nip_fi; + let request = &request.tuple; let connection = crate::observability::acquire_writer( &self.pool, crate::observability::WriterOperation::Authorization, @@ -391,8 +318,8 @@ impl Db { return Ok( if record.acceptance.matches(request) && record.acceptance.requires_nip_fi == desired - && record.expected_policy_generation == expected_generation - && record.expected_requires_nip_fi == expected_state + && record.expected_policy_generation == Some(expected_generation) + && record.expected_requires_nip_fi == Some(expected_state) { PolicyOutcome::Accepted(Box::new(record)) } else { @@ -423,16 +350,10 @@ impl Db { } let old_required: bool = row.try_get("requires_nip_fi")?; let old_generation: i64 = row.try_get("policy_generation")?; - if let Some((_, generation, required)) = intent { - if generation < 0 || generation != old_generation || required != old_required { - return Ok(PolicyOutcome::Refused("policy_conflict")); - } - } else if !old_required && old_generation != 0 { - // An uncommitted legacy retry cannot reverse a later disable. + if expected_generation != old_generation || expected_state != old_required { return Ok(PolicyOutcome::Refused("policy_conflict")); } - let bump = intent.is_some() || !old_required; - if bump && old_generation == i64::MAX { + if old_generation == i64::MAX { return Ok(PolicyOutcome::Refused("policy_generation_exhausted")); } // Lock order: deletion-shared, community row, host lease. Fills take @@ -489,7 +410,7 @@ impl Db { self.deletion_store() .guard_transaction(&mut tx, community) .await?; - let generation = old_generation + i64::from(bump); + let generation = old_generation + 1; sqlx::query( "UPDATE communities SET policy_generation = $2, requires_nip_fi = $3 WHERE id = $1", ) diff --git a/crates/buzz-db/src/store/community_auth/postgres_tests.rs b/crates/buzz-db/src/store/community_auth/postgres_tests.rs index e4aff87ecb..19ccb3e6ac 100644 --- a/crates/buzz-db/src/store/community_auth/postgres_tests.rs +++ b/crates/buzz-db/src/store/community_auth/postgres_tests.rs @@ -32,13 +32,24 @@ async fn fixture() -> (Db, UpgradeRequest, Keys) { (db, request, keys) } +fn initial_enable(tuple: &UpgradeRequest) -> PolicyRequest { + PolicyRequest { + tuple: tuple.clone(), + requires_nip_fi: true, + expected_policy_generation: 0, + expected_requires_nip_fi: false, + } +} + async fn accepted(db: &Db, request: &UpgradeRequest) -> UpgradeRecord { - let UpgradeOutcome::Accepted(record) = - db.enable_community_nip_fi(request, Ok(())).await.unwrap() + let PolicyOutcome::Accepted(record) = db + .set_community_nip_fi_policy(&initial_enable(request), Ok(())) + .await + .unwrap() else { panic!("acceptance refused") }; - record + record.acceptance } async fn policy(db: &Db, request: &UpgradeRequest) -> CommunityAuthPolicy { @@ -58,16 +69,18 @@ async fn response_loss_recovery_and_payload_binding() { assert_eq!(first.policy_generation, 1); // Discard the POST result and recover by original operator/UUID. assert_eq!( - db.community_auth_upgrade_status(request.operation_id, &request.operator_pubkey) + db.community_auth_policy_status(request.operation_id, &request.operator_pubkey) .await - .unwrap(), + .unwrap() + .map(|record| record.acceptance), Some(first.clone()) ); assert_eq!(accepted(&db, &request).await, first); assert!(db - .community_auth_upgrade_status(request.operation_id, "another operator") + .community_auth_policy_status(request.operation_id, "another operator") .await .unwrap() + .map(|record| record.acceptance) .is_none()); for field in 0..6 { let mut changed = request.clone(); @@ -80,8 +93,10 @@ async fn response_loss_recovery_and_payload_binding() { _ => changed.operator_pubkey = "d".repeat(64), } assert_eq!( - db.enable_community_nip_fi(&changed, Ok(())).await.unwrap(), - UpgradeOutcome::Refused("idempotency_conflict") + db.set_community_nip_fi_policy(&initial_enable(&changed), Ok(())) + .await + .unwrap(), + PolicyOutcome::Refused("idempotency_conflict") ); } assert_eq!(policy(&db, &request).await.policy_generation, 1); @@ -136,21 +151,29 @@ async fn response_loss_recovery_and_payload_binding() { #[ignore = "requires Postgres"] async fn concurrent_ids_and_duplicate_requests_converge() { let (db, request, _) = fixture().await; - let mut second = request.clone(); - second.operation_id = Uuid::new_v4(); - let (a, b, c) = tokio::join!( - accepted(&db, &request), - accepted(&db, &request), - accepted(&db, &second) + let first = initial_enable(&request); + let second = intent(&request, true, 0, false); + let (a, b) = tokio::join!( + db.set_community_nip_fi_policy(&first, Ok(())), + db.set_community_nip_fi_policy(&second, Ok(())) ); - assert_eq!(a, b); - assert_eq!(a.policy_generation, c.policy_generation); + let (winner, loser) = match (a.unwrap(), b.unwrap()) { + (PolicyOutcome::Accepted(record), other) => (&first, (record, other)), + (other, PolicyOutcome::Accepted(record)) => (&second, (record, other)), + outcomes => panic!("no CAS winner: {outcomes:?}"), + }; + assert_eq!(loser.1, PolicyOutcome::Refused("policy_conflict")); + assert_eq!(apply(&db, winner).await, *loser.0); + let (replay_a, replay_b) = tokio::join!(apply(&db, winner), apply(&db, winner)); + assert_eq!(replay_a, replay_b); assert_eq!(policy(&db, &request).await.policy_generation, 1); let (_, mut other, _) = fixture().await; - other.operation_id = request.operation_id; + other.operation_id = winner.tuple.operation_id; assert_eq!( - db.enable_community_nip_fi(&other, Ok(())).await.unwrap(), - UpgradeOutcome::Refused("idempotency_conflict") + db.set_community_nip_fi_policy(&initial_enable(&other), Ok(())) + .await + .unwrap(), + PolicyOutcome::Refused("idempotency_conflict") ); } @@ -171,22 +194,26 @@ async fn failed_preconditions_leave_free_policy_and_no_receipt() { _ => changed.community_id = Uuid::new_v4(), } assert_eq!( - db.enable_community_nip_fi(&changed, Ok(())).await.unwrap(), - UpgradeOutcome::Refused(code) + db.set_community_nip_fi_policy(&initial_enable(&changed), Ok(())) + .await + .unwrap(), + PolicyOutcome::Refused(code) ); } assert_eq!( - db.enable_community_nip_fi(&request, Err("authorization_unavailable")) + db.set_community_nip_fi_policy(&initial_enable(&request), Err("authorization_unavailable")) .await .unwrap(), - UpgradeOutcome::Refused("authorization_unavailable") + PolicyOutcome::Refused("authorization_unavailable") ); db.unbind_nip_fi_issuer(CommunityId::from_uuid(request.community_id), ISSUER, true) .await .unwrap(); assert_eq!( - db.enable_community_nip_fi(&request, Ok(())).await.unwrap(), - UpgradeOutcome::Refused("issuer_conflict") + db.set_community_nip_fi_policy(&initial_enable(&request), Ok(())) + .await + .unwrap(), + PolicyOutcome::Refused("issuer_conflict") ); assert_eq!( policy(&db, &request).await, @@ -197,9 +224,10 @@ async fn failed_preconditions_leave_free_policy_and_no_receipt() { } ); assert!(db - .community_auth_upgrade_status(request.operation_id, &request.operator_pubkey) + .community_auth_policy_status(request.operation_id, &request.operator_pubkey) .await .unwrap() + .map(|record| record.acceptance) .is_none()); } @@ -210,7 +238,7 @@ async fn blocked_upgrade( request: &UpgradeRequest, locker_pid: i32, statement: &str, -) -> tokio::task::JoinHandle> { +) -> tokio::task::JoinHandle> { use std::str::FromStr; let application = format!("upgrade-lock-{}", request.operation_id.simple()); let options = sqlx::postgres::PgConnectOptions::from_str(&crate::test_support::database_url()) @@ -224,8 +252,11 @@ async fn blocked_upgrade( .unwrap(), ); let request = request.clone(); - let task = - tokio::spawn(async move { upgrade_db.enable_community_nip_fi(&request, Ok(())).await }); + let task = tokio::spawn(async move { + upgrade_db + .set_community_nip_fi_policy(&initial_enable(&request), Ok(())) + .await + }); tokio::time::timeout(std::time::Duration::from_secs(10), async { loop { assert!( @@ -261,14 +292,15 @@ async fn blocked_upgrade( async fn assert_refused_without_mutation( db: &Db, request: &UpgradeRequest, - task: tokio::task::JoinHandle>, + task: tokio::task::JoinHandle>, code: &'static str, ) { - assert_eq!(task.await.unwrap().unwrap(), UpgradeOutcome::Refused(code)); + assert_eq!(task.await.unwrap().unwrap(), PolicyOutcome::Refused(code)); assert!(db - .community_auth_upgrade_status(request.operation_id, &request.operator_pubkey) + .community_auth_policy_status(request.operation_id, &request.operator_pubkey) .await .unwrap() + .map(|record| record.acceptance) .is_none()); let policy: (bool, i64) = sqlx::query_as("SELECT requires_nip_fi, policy_generation FROM communities WHERE id = $1") @@ -363,8 +395,18 @@ async fn accepted_receipt_survives_transfer_without_granting_fresh_owner_authori let mut fresh = request.clone(); fresh.operation_id = Uuid::new_v4(); assert_eq!( - db.enable_community_nip_fi(&fresh, Ok(())).await.unwrap(), - UpgradeOutcome::Refused("owner_conflict") + db.set_community_nip_fi_policy( + &PolicyRequest { + tuple: fresh.clone(), + requires_nip_fi: true, + expected_policy_generation: 1, + expected_requires_nip_fi: true + }, + Ok(()) + ) + .await + .unwrap(), + PolicyOutcome::Refused("owner_conflict") ); } @@ -378,9 +420,12 @@ async fn authoritative_lookup_failure_is_error_not_fabricated_acceptance() { .unwrap(), ); let (_, request, _) = fixture().await; - assert!(db.enable_community_nip_fi(&request, Ok(())).await.is_err()); assert!(db - .community_auth_upgrade_status(request.operation_id, &request.operator_pubkey) + .set_community_nip_fi_policy(&initial_enable(&request), Ok(())) + .await + .is_err()); + assert!(db + .community_auth_policy_status(request.operation_id, &request.operator_pubkey) .await .is_err()); } @@ -399,14 +444,17 @@ async fn deletion_intent_refuses_enable_before_quiescing() { .await .unwrap(); assert_eq!( - db.enable_community_nip_fi(&request, Ok(())).await.unwrap(), - UpgradeOutcome::Refused("lifecycle_conflict") + db.set_community_nip_fi_policy(&initial_enable(&request), Ok(())) + .await + .unwrap(), + PolicyOutcome::Refused("lifecycle_conflict") ); assert!(!policy(&db, &request).await.requires_nip_fi); assert!(db - .community_auth_upgrade_status(request.operation_id, &request.operator_pubkey) + .community_auth_policy_status(request.operation_id, &request.operator_pubkey) .await .unwrap() + .map(|record| record.acceptance) .is_none()); } @@ -482,7 +530,21 @@ async fn supported_writer_maintains_generation_while_direct_sql_is_not_constrain let first = accepted(&db, &request).await; assert_eq!(first.policy_generation, 1); request.operation_id = Uuid::new_v4(); - assert_eq!(accepted(&db, &request).await.policy_generation, 1); + assert_eq!( + apply( + &db, + &PolicyRequest { + tuple: request.clone(), + requires_nip_fi: true, + expected_policy_generation: 1, + expected_requires_nip_fi: true + } + ) + .await + .acceptance + .policy_generation, + 2 + ); let invalid_receipt = sqlx::query( "UPDATE community_auth_upgrades SET policy_generation = 0 WHERE operation_id = $1", ) @@ -498,7 +560,7 @@ async fn supported_writer_maintains_generation_while_direct_sql_is_not_constrain .as_deref(), Some("23514") ); - assert_eq!(policy(&db, &request).await.policy_generation, 1); + assert_eq!(policy(&db, &request).await.policy_generation, 2); } #[tokio::test] @@ -630,17 +692,6 @@ async fn policy_enable_disable_reenable_history_and_identity() { "superseded" } ); - assert!(db - .community_auth_upgrade_status(request.tuple.operation_id, &tuple.operator_pubkey) - .await - .unwrap() - .is_none()); - assert_eq!( - db.enable_community_nip_fi(&request.tuple, Ok(())) - .await - .unwrap(), - UpgradeOutcome::Refused("idempotency_conflict") - ); } for change in 0..5 { let mut changed = enable.clone(); @@ -674,7 +725,7 @@ async fn policy_enable_disable_reenable_history_and_identity() { #[tokio::test] #[ignore = "requires Postgres"] -async fn policy_same_state_barrier_refuses_stale_cas_and_legacy_retry() { +async fn policy_same_state_barrier_and_retained_legacy_receipt_identity() { let (db, tuple, _) = fixture().await; let stale = intent(&tuple, true, 0, false); let barrier = intent(&tuple, false, 0, false); @@ -687,11 +738,6 @@ async fn policy_same_state_barrier_refuses_stale_cas_and_legacy_retry() { .unwrap(), PolicyOutcome::Refused("policy_conflict") ); - assert_eq!( - db.enable_community_nip_fi(&tuple, Ok(())).await.unwrap(), - UpgradeOutcome::Refused("policy_conflict") - ); - // Generation equality alone is insufficient: the state precondition matters too. let wrong_state = intent(&tuple, true, 1, true); assert_eq!( db.set_community_nip_fi_policy(&wrong_state, Ok(())) @@ -699,62 +745,50 @@ async fn policy_same_state_barrier_refuses_stale_cas_and_legacy_retry() { .unwrap(), PolicyOutcome::Refused("policy_conflict") ); - for id in [ - stale.tuple.operation_id, - tuple.operation_id, - wrong_state.tuple.operation_id, - ] { - assert!(db - .community_auth_policy_status(id, &tuple.operator_pubkey) - .await - .unwrap() - .is_none()); - } assert_eq!(apply(&db, &barrier).await, record); let enable = intent(&tuple, true, 1, false); - assert_eq!(apply(&db, &enable).await.acceptance.policy_generation, 2); + apply(&db, &enable).await; + // Emulate an acceptance written before migration 0084, without restoring + // an unfenced writer just to create test data. Old INSERTs remain valid. + let legacy_id = Uuid::new_v4(); + sqlx::query( + "INSERT INTO community_auth_upgrades + (operation_id,target_community_id,expected_host,actor_pubkey,expected_owner_pubkey, + expected_issuer,operator_pubkey,policy_generation) VALUES ($1,$2,$3,$4,$4,$5,$6,2)", + ) + .bind(legacy_id) + .bind(tuple.community_id) + .bind(&tuple.expected_host) + .bind(&tuple.actor_pubkey) + .bind(&tuple.expected_issuer) + .bind(&tuple.operator_pubkey) + .execute(db.pool()) + .await + .unwrap(); + let retained = db + .community_auth_policy_status(legacy_id, &tuple.operator_pubkey) + .await + .unwrap() + .unwrap(); + assert_eq!(retained.expected_policy_generation, None); + assert_eq!(retained.expected_requires_nip_fi, None); + assert_eq!(retained.acceptance.state, "pending"); + let mut reuse = intent(&tuple, true, 2, true); + reuse.tuple.operation_id = legacy_id; assert_eq!( - db.enable_community_nip_fi(&enable.tuple, Ok(())) - .await - .unwrap(), - UpgradeOutcome::Refused("idempotency_conflict") - ); - // Legacy receipt identity includes absence of CAS preconditions too. - let mut legacy = tuple.clone(); - legacy.operation_id = Uuid::new_v4(); - let old = accepted(&db, &legacy).await; - let mut generic = intent(&legacy, true, 2, true); - generic.tuple.operation_id = legacy.operation_id; - assert_eq!( - db.set_community_nip_fi_policy(&generic, Ok(())) + db.set_community_nip_fi_policy(&reuse, Ok(())) .await .unwrap(), PolicyOutcome::Refused("idempotency_conflict") ); - assert_eq!( - db.community_auth_upgrade_status(legacy.operation_id, &tuple.operator_pubkey) - .await - .unwrap(), - Some(old) - ); apply(&db, &intent(&tuple, false, 2, true)).await; - assert_eq!( - db.community_auth_upgrade_status(legacy.operation_id, &tuple.operator_pubkey) - .await - .unwrap() - .unwrap() - .state, - "unavailable" - ); - assert_eq!( - db.community_auth_policy_status(legacy.operation_id, &tuple.operator_pubkey) - .await - .unwrap() - .unwrap() - .acceptance - .state, - "superseded" - ); + let retained = db + .community_auth_policy_status(legacy_id, &tuple.operator_pubkey) + .await + .unwrap() + .unwrap(); + assert_eq!(retained.acceptance.state, "superseded"); + assert_eq!(retained.current_policy_generation, Some(3)); } #[tokio::test] diff --git a/crates/buzz-db/src/store/deletion.rs b/crates/buzz-db/src/store/deletion.rs index 66754b45d4..c326fe81dc 100644 --- a/crates/buzz-db/src/store/deletion.rs +++ b/crates/buzz-db/src/store/deletion.rs @@ -8716,12 +8716,12 @@ DROP FUNCTION community_write_fence_excluded_table(NAME); let deleted = store.purge_postgres(&token).await.expect("purge postgres"); let upgrade = db - .community_auth_upgrade_status(upgrade_id, "operator") + .community_auth_policy_status(upgrade_id, "operator") .await .unwrap() .expect("receipt survives purge"); - assert_eq!(upgrade.community_id, community); - assert_eq!(upgrade.state, "unavailable"); + assert_eq!(upgrade.acceptance.community_id, community); + assert_eq!(upgrade.acceptance.state, "unavailable"); let retained: Uuid = sqlx::query_scalar( "SELECT target_community_id FROM entity_deletion_stage_history WHERE id = $1", ) diff --git a/crates/buzz-relay/src/api/operator.rs b/crates/buzz-relay/src/api/operator.rs index 9af82c6d92..41a28de2b7 100644 --- a/crates/buzz-relay/src/api/operator.rs +++ b/crates/buzz-relay/src/api/operator.rs @@ -1188,24 +1188,6 @@ mod tests { } } -#[derive(Debug, Deserialize)] -#[serde(deny_unknown_fields)] -struct CommunityAuthUpgradeRequest { - operation_id: Uuid, - community_id: Uuid, - expected_host: String, - actor_pubkey: String, - expected_owner_pubkey: String, - expected_issuer: String, -} - -/// Query for operator-scoped upgrade receipt recovery. -#[derive(Debug, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct CommunityAuthUpgradeQuery { - operation_id: Uuid, -} - fn upgrade_configured_trust( state: &AppState, host: &str, @@ -1234,155 +1216,14 @@ fn upgrade_configured_trust( Ok(()) } -fn upgrade_response( - state: &AppState, - mut record: buzz_db::community_auth::UpgradeRecord, -) -> axum::response::Response { - use axum::response::IntoResponse; - if upgrade_configured_trust(state, &record.expected_host, &record.expected_issuer).is_err() { - record.state = "unavailable".to_owned(); - } - Json(record).into_response() -} - -/// Accept or recover a monotonic FI upgrade through the existing operator plane. -/// The NIP-98 principal is the trusted mediator; the request actor is the owner -/// authenticated by that mediator. No public user credential can select it. -pub async fn enable_community_nip_fi( - State(state): State>, - slot: AccessSlot, - headers: HeaderMap, - body: axum::body::Bytes, -) -> Result)> { - let operator = authorize_operator_request( - &state, - &slot, - &headers, - "POST", - "/operator/communities/nip-fi-upgrade", - None, - Some(&body), - ) - .await?; - let request: CommunityAuthUpgradeRequest = serde_json::from_slice(&body).map_err(|_| { - coded_api_error( - StatusCode::BAD_REQUEST, - "invalid_request", - "invalid upgrade request", - ) - })?; - let invalid = || { - coded_api_error( - StatusCode::BAD_REQUEST, - "invalid_request", - "invalid upgrade request", - ) - }; - if request.operation_id.is_nil() || request.community_id.is_nil() { - return Err(invalid()); - } - let host = normalize_candidate_host(&request.expected_host).map_err(|_| invalid())?; - let actor = validate_pubkey_hex(&request.actor_pubkey).ok_or_else(invalid)?; - let owner = validate_pubkey_hex(&request.expected_owner_pubkey).ok_or_else(invalid)?; - let trust = if state.config.nip_fi_changes { - upgrade_configured_trust(&state, &host, &request.expected_issuer) - } else { - Err("upgrades_disabled") - }; - let tuple = buzz_db::community_auth::UpgradeRequest { - operation_id: request.operation_id, - community_id: request.community_id, - expected_host: host, - actor_pubkey: actor, - expected_owner_pubkey: owner, - expected_issuer: request.expected_issuer, - operator_pubkey: operator.to_hex(), - }; - let outcome = state - .db - .enable_community_nip_fi(&tuple, trust) - .await - .map_err(|error| { - tracing::warn!(%error, "community auth upgrade unavailable"); - coded_api_error( - StatusCode::SERVICE_UNAVAILABLE, - "authorization_unavailable", - "authorization unavailable", - ) - })?; - match outcome { - buzz_db::community_auth::UpgradeOutcome::Accepted(record) => { - state.invalidate_community_host(&TenantContext::resolved( - buzz_core::CommunityId::from_uuid(record.community_id), - &record.expected_host, - )); - Ok(upgrade_response(&state, record)) - } - buzz_db::community_auth::UpgradeOutcome::Refused(code) => { - let status = match code { - "community_not_found" => StatusCode::NOT_FOUND, - "authorization_unavailable" | "upgrades_disabled" => { - StatusCode::SERVICE_UNAVAILABLE - } - _ => StatusCode::CONFLICT, - }; - Err(coded_api_error(status, code, code)) - } - } -} - -/// Recover the original operator's exact durable tuple with truthful state. -/// A fresh NIP-98 signature is required for every status read and POST retry. -pub async fn community_nip_fi_upgrade_status( - State(state): State>, - slot: AccessSlot, - headers: HeaderMap, - RawQuery(raw_query): RawQuery, - Query(query): Query, -) -> Result)> { - let operator = authorize_operator_request( - &state, - &slot, - &headers, - "GET", - "/operator/communities/nip-fi-upgrade", - raw_query.as_deref(), - None, - ) - .await?; - let record = state - .db - .community_auth_upgrade_status(query.operation_id, &operator.to_hex()) - .await - .map_err(|error| { - tracing::warn!(%error, "community auth upgrade status unavailable"); - coded_api_error( - StatusCode::SERVICE_UNAVAILABLE, - "authorization_unavailable", - "authorization unavailable", - ) - })? - .ok_or_else(|| { - coded_api_error( - StatusCode::NOT_FOUND, - "operation_not_found", - "operation not found", - ) - })?; - Ok(upgrade_response(&state, record)) -} - -/// Keep all upgrade responses, including auth and dependency errors, out of caches. -pub async fn no_store_upgrade_response( +/// Keep all policy responses, including auth and dependency errors, out of caches. +pub async fn no_store_policy_response( request: axum::extract::Request, next: axum::middleware::Next, ) -> axum::response::Response { - let upgrade = matches!( - request.uri().path(), - "/operator/communities/nip-fi-upgrade" | "/operator/communities/nip-fi-policy" - ); + let policy = request.uri().path() == "/operator/communities/nip-fi-policy"; let mut response = next.run(request).await; - if upgrade { + if policy { response.headers_mut().insert( axum::http::header::CACHE_CONTROL, axum::http::HeaderValue::from_static("no-store"), @@ -3130,420 +2971,6 @@ mod postgres_tests { operator_request_with_auth(state, method, path, body, auth).await } - #[tokio::test] - #[ignore = "requires Postgres"] - async fn upgrade_operator_http_contract_recovers_pending_and_scopes_auth() { - let operator = Keys::generate(); - let other_operator = Keys::generate(); - let owner = Keys::generate(); - let (policy, _, host) = reserved_host_policy(); - let Some(mut state) = operator_test_state_with_policy( - &[operator.clone(), other_operator.clone()], - Some(policy), - ) - .await - else { - panic!("test database unavailable"); - }; - Arc::get_mut(&mut state).unwrap().nip98_replay = - Arc::new(SeenOnceReplayGuard(Mutex::new(HashSet::new()))); - let issuer_policy = buzz_auth::IssuerPolicy::new( - NIP_FI_ISSUER.to_owned(), - vec!["https://commands.example".to_owned()], - buzz_auth::TokenClass::DedicatedNipFi, - buzz_auth::FreshnessClass::OfflineJwt, - vec![jsonwebtoken::Algorithm::ES256], - 30, - 3600, - None, - buzz_auth::JwksSourceContract::new(format!("{NIP_FI_ISSUER}/jwks"), 300, 86400) - .unwrap(), - ) - .unwrap(); - Arc::make_mut(&mut Arc::get_mut(&mut state).expect("unique test state").config) - .nip_fi - .registry - .insert(issuer_policy); - Arc::make_mut(&mut Arc::get_mut(&mut state).unwrap().config).nip_fi_changes = false; - assert!(!state.config.nip_fi_changes); - - assert_eq!( - provision_community(state.clone(), &operator, &host, &owner) - .await - .status(), - StatusCode::OK - ); - let community = state - .db - .lookup_community_by_host(&host) - .await - .unwrap() - .unwrap(); - let id = Uuid::new_v4(); - let body = serde_json::json!({ - "operation_id": id, "community_id": community.id.to_string(), - "expected_host": host.to_uppercase(), "actor_pubkey": owner.public_key().to_hex(), - "expected_owner_pubkey": owner.public_key().to_hex(), "expected_issuer": NIP_FI_ISSUER, - }) - .to_string(); - let path = "/operator/communities/nip-fi-upgrade"; - // The cache guard also surrounds router/middleware rejections, not only - // responses produced inside the authenticated upgrade handler. - let oversized = build_router(state.clone()) - .oneshot( - Request::builder() - .method("POST") - .uri(path) - .header(header::HOST, INGRESS_HOST) - .header(header::CONTENT_LENGTH, (2 * 1024 * 1024).to_string()) - .body(Body::empty()) - .unwrap(), - ) - .await - .unwrap(); - assert_eq!(oversized.status(), StatusCode::PAYLOAD_TOO_LARGE); - assert_eq!(oversized.headers()[header::CACHE_CONTROL], "no-store"); - let unsupported = build_router(state.clone()) - .oneshot( - Request::builder() - .method("PUT") - .uri(path) - .header(header::HOST, INGRESS_HOST) - .body(Body::empty()) - .unwrap(), - ) - .await - .unwrap(); - assert_eq!(unsupported.status(), StatusCode::METHOD_NOT_ALLOWED); - assert_eq!(unsupported.headers()[header::CACHE_CONTROL], "no-store"); - let disabled = - fresh_upgrade_request(state.clone(), &operator, "POST", path, Some(body.clone())).await; - assert_eq!(disabled.status(), StatusCode::SERVICE_UNAVAILABLE); - assert_eq!(disabled.headers()[header::CACHE_CONTROL], "no-store"); - assert_eq!(read_json(disabled).await["code"], "upgrades_disabled"); - assert!( - !state - .db - .community_auth_policy(community.id) - .await - .unwrap() - .unwrap() - .requires_nip_fi - ); - assert!(state - .db - .community_auth_upgrade_status(id, &operator.public_key().to_hex()) - .await - .unwrap() - .is_none()); - Arc::make_mut(&mut Arc::get_mut(&mut state).unwrap().config).nip_fi_changes = true; - let response = - fresh_upgrade_request(state.clone(), &operator, "POST", path, Some(body.clone())).await; - assert_eq!(response.status(), StatusCode::OK); - assert_eq!(response.headers()[header::CACHE_CONTROL], "no-store"); - let record = read_json(response).await; - assert_eq!(record["expected_host"], host); - assert_eq!(record["operator_pubkey"], operator.public_key().to_hex()); - assert_eq!(record["policy_generation"], 1); - assert_eq!(record["state"], "pending"); - assert_eq!( - record["pending_reasons"], - serde_json::json!(["connection_closure_unverified"]) - ); - // A fresh signed retry recovers the same tuple despite the lost response. - let retry = - fresh_upgrade_request(state.clone(), &operator, "POST", path, Some(body.clone())).await; - assert_eq!(retry.status(), StatusCode::OK); - assert_eq!(read_json(retry).await, record); - let status_path = format!("{path}?operation_id={id}"); - let status = - fresh_upgrade_request(state.clone(), &operator, "GET", &status_path, None).await; - assert_eq!(status.status(), StatusCode::OK); - assert_eq!(read_json(status).await, record); - let other = - fresh_upgrade_request(state.clone(), &other_operator, "GET", &status_path, None).await; - assert_eq!(other.status(), StatusCode::NOT_FOUND); - assert_eq!(other.headers()[header::CACHE_CONTROL], "no-store"); - let reuse = fresh_upgrade_request( - state.clone(), - &other_operator, - "POST", - path, - Some(body.clone()), - ) - .await; - assert_eq!(reuse.status(), StatusCode::CONFLICT); - assert_eq!(reuse.headers()[header::CACHE_CONTROL], "no-store"); - assert_eq!(read_json(reuse).await["code"], "idempotency_conflict"); - let outsider = fresh_upgrade_request( - state.clone(), - &Keys::generate(), - "POST", - path, - Some(body.clone()), - ) - .await; - assert_eq!(outsider.status(), StatusCode::FORBIDDEN); - assert_eq!(outsider.headers()[header::CACHE_CONTROL], "no-store"); - let url = format!("http://{INGRESS_HOST}{path}"); - let auth = fresh_upgrade_auth(&operator, &url, "POST", Some(body.as_bytes())); - let raw = || { - Request::builder() - .method("POST") - .uri(path) - .header(header::HOST, INGRESS_HOST) - .header(header::AUTHORIZATION, &auth) - .body(Body::from(body.clone())) - .unwrap() - }; - let first = build_router(state.clone()).oneshot(raw()).await.unwrap(); - // Repeat the exact signature: transport replay protection remains independent of operation idempotency. - let second = build_router(state.clone()).oneshot(raw()).await.unwrap(); - assert_eq!(first.status(), StatusCode::OK); - assert_eq!(second.status(), StatusCode::UNAUTHORIZED); - assert_eq!(second.headers()[header::CACHE_CONTROL], "no-store"); - assert_eq!( - state - .db - .community_auth_policy(community.id) - .await - .unwrap() - .unwrap() - .policy_generation, - 1 - ); - Arc::make_mut(&mut Arc::get_mut(&mut state).unwrap().config).nip_fi_changes = false; - let while_off = - fresh_upgrade_request(state.clone(), &operator, "POST", path, Some(body.clone())).await; - assert_eq!(while_off.status(), StatusCode::OK); - assert_eq!(read_json(while_off).await, record); - let while_off = - fresh_upgrade_request(state.clone(), &operator, "GET", &status_path, None).await; - assert_eq!(while_off.status(), StatusCode::OK); - assert_eq!(read_json(while_off).await, record); - let denied = - fresh_upgrade_request(state.clone(), &other_operator, "GET", &status_path, None).await; - assert_eq!(denied.status(), StatusCode::NOT_FOUND); - let reuse_off = fresh_upgrade_request( - state.clone(), - &other_operator, - "POST", - path, - Some(body.clone()), - ) - .await; - assert_eq!(reuse_off.status(), StatusCode::CONFLICT); - assert_eq!(read_json(reuse_off).await["code"], "idempotency_conflict"); - let new_id = Uuid::new_v4(); - let new_body = body.replace(&id.to_string(), &new_id.to_string()); - let disabled_after_acceptance = - fresh_upgrade_request(state.clone(), &operator, "POST", path, Some(new_body)).await; - assert_eq!( - disabled_after_acceptance.status(), - StatusCode::SERVICE_UNAVAILABLE - ); - assert_eq!( - disabled_after_acceptance.headers()[header::CACHE_CONTROL], - "no-store" - ); - assert_eq!( - read_json(disabled_after_acceptance).await["code"], - "upgrades_disabled" - ); - assert!(state - .db - .community_auth_upgrade_status(new_id, &operator.public_key().to_hex()) - .await - .unwrap() - .is_none()); - Arc::make_mut(&mut Arc::get_mut(&mut state).unwrap().config).nip_fi_changes = true; - - // Exercise the real HTTP-to-config-to-transaction seam: no raw trust - // precondition can bypass the deployment's configured issuer selection. - for invalid in [ - "{".to_owned(), - "{}".to_owned(), - body.replace(&id.to_string(), "not-a-uuid"), - ] { - let response = - fresh_upgrade_request(state.clone(), &operator, "POST", path, Some(invalid)).await; - assert_eq!(response.status(), StatusCode::BAD_REQUEST); - assert_eq!(response.headers()[header::CACHE_CONTROL], "no-store"); - assert_eq!(read_json(response).await["code"], "invalid_request"); - } - let mut changed: Value = serde_json::from_str(&body).unwrap(); - let mismatched_id = Uuid::new_v4(); - changed["operation_id"] = serde_json::json!(mismatched_id); - changed["expected_issuer"] = serde_json::json!("https://unapproved.example"); - let mismatch = fresh_upgrade_request( - state.clone(), - &operator, - "POST", - path, - Some(changed.to_string()), - ) - .await; - assert_eq!(mismatch.status(), StatusCode::CONFLICT); - assert_eq!(mismatch.headers()[header::CACHE_CONTROL], "no-store"); - assert_eq!(read_json(mismatch).await["code"], "issuer_conflict"); - assert!(state - .db - .community_auth_upgrade_status(mismatched_id, &operator.public_key().to_hex()) - .await - .unwrap() - .is_none()); - - for bad_host in [ - "outside.example".to_owned(), - host.replacen("acme.", "cmd.", 1), - ] { - changed["operation_id"] = serde_json::json!(Uuid::new_v4()); - changed["expected_host"] = serde_json::json!(bad_host); - changed["expected_issuer"] = serde_json::json!(NIP_FI_ISSUER); - let response = fresh_upgrade_request( - state.clone(), - &operator, - "POST", - path, - Some(changed.to_string()), - ) - .await; - assert_eq!(response.status(), StatusCode::CONFLICT); - assert_eq!(response.headers()[header::CACHE_CONTROL], "no-store"); - assert_eq!(read_json(response).await["code"], "issuer_conflict"); - } - changed["expected_host"] = serde_json::json!(host); - let original_policy = state.config.nip_fi_provision_binding.clone(); - let suffix = host.split_once('.').unwrap().1; - let multiple = buzz_auth::ProvisionBindingPolicy::parse( - &serde_json::json!({"host_suffix": suffix, "authorized_issuers": [NIP_FI_ISSUER, "https://second.example"]}).to_string(), - &serde_json::json!([ - {"issuer": NIP_FI_ISSUER, "command_audiences": [format!("https://cmd.{suffix}")]}, - {"issuer": "https://second.example", "command_audiences": ["https://commands.example"]} - ]).to_string()).unwrap(); - Arc::make_mut(&mut Arc::get_mut(&mut state).unwrap().config).nip_fi_provision_binding = - Some(multiple); - changed["operation_id"] = serde_json::json!(Uuid::new_v4()); - let ambiguous = fresh_upgrade_request( - state.clone(), - &operator, - "POST", - path, - Some(changed.to_string()), - ) - .await; - assert_eq!(ambiguous.status(), StatusCode::SERVICE_UNAVAILABLE); - assert_eq!(ambiguous.headers()[header::CACHE_CONTROL], "no-store"); - assert_eq!( - read_json(ambiguous).await["code"], - "authorization_unavailable" - ); - Arc::make_mut(&mut Arc::get_mut(&mut state).unwrap().config).nip_fi_provision_binding = - original_policy; - - assert_eq!( - state - .db - .unbind_nip_fi_issuer(community.id, NIP_FI_ISSUER, true) - .await - .unwrap(), - buzz_db::NipFiUnbind::RequiredIssuer - ); - // Simulate out-of-band trust loss to exercise receipt recovery and - // configured-trust refusal independently of the operator removal guard. - sqlx::query("DELETE FROM community_nip_fi_issuers WHERE community_id = $1") - .bind(community.id.as_uuid()) - .execute(state.db.pool()) - .await - .unwrap(); - let missing_id = Uuid::new_v4(); - changed["operation_id"] = serde_json::json!(missing_id); - changed["expected_issuer"] = serde_json::json!(NIP_FI_ISSUER); - let missing = fresh_upgrade_request( - state.clone(), - &operator, - "POST", - path, - Some(changed.to_string()), - ) - .await; - assert_eq!(missing.status(), StatusCode::CONFLICT); - assert_eq!(read_json(missing).await["code"], "issuer_conflict"); - state - .db - .bind_nip_fi_issuers(community.id, &[NIP_FI_ISSUER.to_owned()]) - .await - .unwrap(); - - Arc::make_mut(&mut Arc::get_mut(&mut state).unwrap().config) - .nip_fi - .registry = buzz_auth::IssuerRegistry::new(); - let inactive_id = Uuid::new_v4(); - changed["operation_id"] = serde_json::json!(inactive_id); - let inactive = fresh_upgrade_request( - state.clone(), - &operator, - "POST", - path, - Some(changed.to_string()), - ) - .await; - assert_eq!(inactive.status(), StatusCode::SERVICE_UNAVAILABLE); - assert_eq!(inactive.headers()[header::CACHE_CONTROL], "no-store"); - assert_eq!( - read_json(inactive).await["code"], - "authorization_unavailable" - ); - let recovered = - fresh_upgrade_request(state.clone(), &operator, "POST", path, Some(body.clone())).await; - assert_eq!(recovered.status(), StatusCode::OK); - let recovered = read_json(recovered).await; - assert_eq!(recovered["operation_id"], record["operation_id"]); - assert_eq!(recovered["policy_generation"], record["policy_generation"]); - assert_eq!(recovered["state"], "unavailable"); - Arc::make_mut(&mut Arc::get_mut(&mut state).unwrap().config).nip_fi_provision_binding = - None; - let unconfigured = fresh_upgrade_request( - state.clone(), - &operator, - "POST", - path, - Some(changed.to_string()), - ) - .await; - assert_eq!(unconfigured.status(), StatusCode::SERVICE_UNAVAILABLE); - for operation in [missing_id, inactive_id] { - assert!(state - .db - .community_auth_upgrade_status(operation, &operator.public_key().to_hex()) - .await - .unwrap() - .is_none()); - } - assert!( - state - .db - .community_auth_policy(community.id) - .await - .unwrap() - .unwrap() - .requires_nip_fi - ); - } - - async fn nip_fi_lookup(state: Arc, operator: &Keys, host: &str) -> Value { - let response = signed_operator_request( - state, - operator, - "GET", - &format!("/operator/communities/nip-fi-binding?host={host}"), - None, - ) - .await; - assert_eq!(response.status(), StatusCode::OK, "{host}"); - read_json(response).await - } - #[tokio::test] #[ignore = "requires Postgres"] async fn nip_fi_provisioning_never_binds_a_reserved_host() { @@ -4985,5 +4412,18 @@ mod postgres_tests { let body = read_json(response).await; assert_eq!(body["error"], "internal server error"); } + async fn nip_fi_lookup(state: Arc, operator: &Keys, host: &str) -> Value { + let response = signed_operator_request( + state, + operator, + "GET", + &format!("/operator/communities/nip-fi-binding?host={host}"), + None, + ) + .await; + assert_eq!(response.status(), StatusCode::OK, "{host}"); + read_json(response).await + } + mod policy_tests; } diff --git a/crates/buzz-relay/src/nip_fi_policy/postgres_tests.rs b/crates/buzz-relay/src/nip_fi_policy/postgres_tests.rs index 6fc5d9b4cd..a26063b4a5 100644 --- a/crates/buzz-relay/src/nip_fi_policy/postgres_tests.rs +++ b/crates/buzz-relay/src/nip_fi_policy/postgres_tests.rs @@ -60,7 +60,7 @@ async fn wait_for_blocker(db: &buzz_db::Db, waiter: i32, blocker: i32) { #[ignore = "requires Postgres"] #[allow(clippy::result_large_err)] // Exercise the real Axum admission response. async fn optional_leases_on_another_pod_expire_before_acceptance_with_or_without_invalidation() { - use buzz_db::community_auth::{UpgradeOutcome, UpgradeRequest}; + use buzz_db::community_auth::{PolicyOutcome, PolicyRequest, UpgradeRequest}; let base = crate::api::bridge::postgres_tests::bridge_handler_test_state() .await .unwrap(); @@ -125,10 +125,18 @@ async fn optional_leases_on_another_pod_expire_before_acceptance_with_or_without assert_eq!( pod_a .db - .enable_community_nip_fi(&wrong_host, Ok(())) + .set_community_nip_fi_policy( + &PolicyRequest { + tuple: wrong_host.clone(), + requires_nip_fi: true, + expected_policy_generation: 0, + expected_requires_nip_fi: false + }, + Ok(()) + ) .await .unwrap(), - UpgradeOutcome::Refused("host_conflict") + PolicyOutcome::Refused("host_conflict") ); // Keep acceptance paused AFTER it locks the community row, without @@ -169,7 +177,18 @@ async fn optional_leases_on_another_pod_expire_before_acceptance_with_or_without let acceptance = { let db = pod_a.db.clone(); let request = request.clone(); - tokio::spawn(async move { db.enable_community_nip_fi(&request, Ok(())).await }) + tokio::spawn(async move { + db.set_community_nip_fi_policy( + &PolicyRequest { + tuple: request.clone(), + requires_nip_fi: true, + expected_policy_generation: 0, + expected_requires_nip_fi: false, + }, + Ok(()), + ) + .await + }) }; let acceptance_pid = host_lock_pid(&base.db, &host, false).await; let mut remap_connection = base.db.pool().acquire().await.unwrap(); @@ -218,7 +237,7 @@ async fn optional_leases_on_another_pod_expire_before_acceptance_with_or_without "single/double remap must remain blocked until acceptance commits" ); issuer_gate.commit().await.unwrap(); - let UpgradeOutcome::Accepted(_) = acceptance.await.unwrap().unwrap() else { + let PolicyOutcome::Accepted(_) = acceptance.await.unwrap().unwrap() else { panic!("acceptance"); }; let prime_to_commit = primed_at.elapsed(); diff --git a/crates/buzz-relay/src/router.rs b/crates/buzz-relay/src/router.rs index 172895d0ca..45cd825133 100644 --- a/crates/buzz-relay/src/router.rs +++ b/crates/buzz-relay/src/router.rs @@ -518,11 +518,6 @@ pub fn build_router(state: Arc) -> Router { get(api::operator::community_nip_fi_policy_status) .post(api::operator::set_community_nip_fi_policy), ) - .route( - "/operator/communities/nip-fi-upgrade", - get(api::operator::community_nip_fi_upgrade_status) - .post(api::operator::enable_community_nip_fi), - ) .route( "/operator/communities/transfer", post(api::operator::transfer_community), @@ -640,9 +635,7 @@ pub fn build_router(state: Arc) -> Router { .layer(http_trace_layer()) .layer(build_cors_layer(&state.config.cors_origins)) // Outside all rejection-producing layers, including the API body limit. - .layer(middleware::from_fn( - api::operator::no_store_upgrade_response, - )) + .layer(middleware::from_fn(api::operator::no_store_policy_response)) } type MakeHttpSpan = fn(&Request) -> tracing::Span; diff --git a/crates/buzz-relay/src/router/tests/policy_sweep_postgres_tests.rs b/crates/buzz-relay/src/router/tests/policy_sweep_postgres_tests.rs index 5c3426d376..32121467c7 100644 --- a/crates/buzz-relay/src/router/tests/policy_sweep_postgres_tests.rs +++ b/crates/buzz-relay/src/router/tests/policy_sweep_postgres_tests.rs @@ -116,7 +116,7 @@ mod postgres_tests { use crate::nip_fi_core::test_support::{ assertion_verifier, sign_assertion, TEST_COMMUNITY_URI, TEST_ISSUER, }; - use buzz_db::community_auth::{UpgradeOutcome, UpgradeRequest}; + use buzz_db::community_auth::{PolicyOutcome, PolicyRequest, UpgradeRequest}; let base = real_db_state().await.unwrap(); let mut configured = (*base).clone(); let config = Arc::make_mut(&mut configured.config); @@ -223,9 +223,17 @@ mod postgres_tests { expected_issuer: TEST_ISSUER.into(), operator_pubkey: owner, }; - let UpgradeOutcome::Accepted(record) = state + let PolicyOutcome::Accepted(record) = state .db - .enable_community_nip_fi(&request, Ok(())) + .set_community_nip_fi_policy( + &PolicyRequest { + tuple: request.clone(), + requires_nip_fi: true, + expected_policy_generation: 0, + expected_requires_nip_fi: false, + }, + Ok(()), + ) .await .unwrap() else { @@ -311,13 +319,13 @@ mod postgres_tests { } let status = state .db - .community_auth_upgrade_status(record.operation_id, &request.operator_pubkey) + .community_auth_policy_status(record.acceptance.operation_id, &request.operator_pubkey) .await .unwrap() .unwrap(); - assert_eq!(status.state, "pending"); + assert_eq!(status.acceptance.state, "pending"); assert_eq!( - status.pending_reasons, + status.acceptance.pending_reasons, vec!["connection_closure_unverified"] ); drop((compliant, compliant_audio, free)); diff --git a/docs/community-auth-upgrades.md b/docs/community-auth-upgrades.md index 602d389b68..ff889edc75 100644 --- a/docs/community-auth-upgrades.md +++ b/docs/community-auth-upgrades.md @@ -42,7 +42,7 @@ Acceptance serializes UUID reuse, then locks deletion-shared → community `FOR NO KEY UPDATE` → exclusive host lease, preserving lifecycle/deletion-intent, exact host, single owner and actor==owner checks and community write admission. Acceptance and receipt commit atomically. UUID identity binds the full tuple, -action, both preconditions, request form and authenticated operator. Different +action, both preconditions and authenticated operator. Different meaning under a committed UUID returns 409 `idempotency_conflict`. Identical committed replay recovers history before mutable preflight and never writes policy. @@ -59,7 +59,8 @@ GET `?operation_id=` returns only the originating operator's receipt. Mixed query forms are invalid. The response separates `receipt` (original tuple, accepted generation/action/preconditions and current target generation/flag) from `mode` and `effective_required`. Current-state reads also print exact expected -precondition fields. Missing/deleted targets have null current fields. +`expected_policy_generation` and `expected_requires_nip_fi` fields, without a +duplicate unprefixed pair. Missing/deleted targets have null current fields. | Receipt state | Meaning | CLI exit | |---|---|---| @@ -85,17 +86,27 @@ return 409 with corresponding `*_conflict` codes; generation exhaustion returns Migration 0084 adds desired action and nullable CAS preconditions to existing receipts. True/default and null preconditions preserve old receipt meaning and old INSERT compatibility. No data backfill, trigger, function or foreign key is -added. Apply schema first, then compatible management writers across the **whole -fleet** with `BUZZ_NIP_FI_CHANGES=false`, then explicitly enable mutations. -The gate controls both directions; reads and committed replay remain available. -Do not roll back to an unfenced management writer after the first general operation. +added. Historical rows with null CAS preconditions remain readable through +policy status, including after target deletion. A new fenced request cannot reuse +such a row's UUID: null historical preconditions differ from the mandatory new +preconditions, so it returns `idempotency_conflict` without mutation. -Legacy `/nip-fi-upgrade` remains enable-only. Fresh enables on optional/nonzero -generation are refused, so an old uncommitted retry cannot reverse disable. -Already-required legacy no-ops retain their generation. Legacy GET hides general -CAS receipts with 404, and POST cannot reuse their UUIDs. Old binaries cannot hide -those receipts or fence retries; the gate-off whole-fleet rollout is mandatory. -General status can read legacy receipts, with null CAS preconditions. +The old `/operator/communities/nip-fi-upgrade` endpoint and the `nip-fi-upgrade` +admin command have been removed. Its GET and POST requests receive 404; use +`nip-fi-policy` for both changes and receipt recovery. Upgrade existing clients +for the [policy client contract](operator-nip-fi-policy-client.md) before this +relay version reaches their fleet, with their mutations paused until rollout ends. +No client may guess CAS fields or refresh a saved request during recovery. + +Before rolling, close `BUZZ_NIP_FI_UPGRADES_ENABLED` on every old relay and keep +`BUZZ_NIP_FI_CHANGES=false` on new relays. Apply schema first, roll the whole +fleet to the supported policy writer, then explicitly enable the new gate and +resume migrated clients. Old binaries with their old gate open can still accept +unfenced enables and reverse a disable; a new-name setting alone cannot close +that old gate. Retire the old setting after the last old binary is gone. +Reads and committed replay remain available on the supported API with its gate +closed. Do not roll back to a writer that exposes unfenced enable after any +policy operation. This removal does not erase receipts or rewrite schema. ## Request enforcement and discovery diff --git a/docs/operator-nip-fi-policy-client.md b/docs/operator-nip-fi-policy-client.md new file mode 100644 index 0000000000..0faadc3f1e --- /dev/null +++ b/docs/operator-nip-fi-policy-client.md @@ -0,0 +1,46 @@ +# Operator NIP-FI policy client contract + +Use signed POST and GET `/operator/communities/nip-fi-policy`. The former +`/operator/communities/nip-fi-upgrade` route is removed and returns 404 for GET +and POST; it no longer performs operator authentication. Deployment proxies +may independently reject a request before it reaches the relay. + +For a new intent, read GET `?community_id=&expected_host=`. Copy its +`expected_policy_generation` and `expected_requires_nip_fi` into the request. +These are the only current-state field pair; the response also includes +`community_id`, `expected_host`, `mode` and `effective_required`. + +Durably save the complete immutable request before its first possible dispatch: +operation UUID, community identity, expected host, authenticated actor and owner, +desired `requires_nip_fi`, both CAS preconditions, and the trusted issuer for an +enable. Disable omits the issuer. After a crash, timeout or lost response, read +GET `?operation_id=` or replay that exact saved request with a fresh NIP-98 +signature. Never reread current policy and overwrite its saved CAS fields under +the same UUID. A `policy_conflict` requires a new explicitly authorized intent +with a new UUID and freshly observed preconditions. + +POST and operation-status GET return `{ "receipt": { ... }, "mode": "...", +"effective_required": true }`. Validate the authenticated operator, saved tuple, +desired action, CAS preconditions and positive accepted generation inside +`receipt`. Its `current_policy_generation` and `current_requires_nip_fi` describe +the current target, not the original intent. They are null after target deletion. +`pending`, `applied`, `superseded` and `unavailable` have the meanings in the +[operator contract](community-auth-upgrades.md#readback-and-response). +Historical acceptance never certifies current policy or fleet-wide socket closure. +No response reports `active`. + +Previously accepted enable receipts remain readable through operation-status +GET, with desired=true and null CAS preconditions. Recover them as history; do +not invent preconditions or convert them into a new write. Reusing their UUID +for a new fenced request returns `idempotency_conflict`, even after deletion. +Treat only the structured 404 `operation_not_found` from the supported endpoint +as an absent receipt. A 404 from the removed route is not evidence that an +operation was never accepted. + +Ship migrated callers with mutation dispatch paused before rolling out the +route removal. Close the old gate on all old binaries and keep the new gate +closed on new binaries. Apply additive schema, complete the whole-fleet rollout, +then open `BUZZ_NIP_FI_CHANGES` and resume supported callers. See +[rollout](community-auth-upgrades.md#schema-and-rollout). An uncertain operation +from the old caller needs explicit reconciliation; a missing receipt is not +permission to synthesize a replacement policy write. diff --git a/docs/operator-nip-fi-upgrade.md b/docs/operator-nip-fi-upgrade.md index 1ef4d1bad2..95652d65fa 100644 --- a/docs/operator-nip-fi-upgrade.md +++ b/docs/operator-nip-fi-upgrade.md @@ -5,8 +5,11 @@ stored requirement through the deployment operator API. The command needs the configured `RELAY_OPERATOR_API_ORIGIN` and an allowlisted operator signing key in a protected file. It sends one signed request and never retries automatically. -Before changing policy, apply the schema and roll compatible management writers -across the whole fleet with `BUZZ_NIP_FI_CHANGES=false`. Then explicitly +Before rolling, close `BUZZ_NIP_FI_UPGRADES_ENABLED` on every old relay and +keep `BUZZ_NIP_FI_CHANGES=false` on new relays. Pause callers and upgrade them +for the [supported API](operator-nip-fi-policy-client.md) before deploying this +relay version. Apply the schema and roll compatible management writers across +the whole fleet. Then explicitly enable that gate, which controls both enable and disable. Enable additionally requires the deployment-selected issuer to be trusted and already bound to the community. The active community must have exactly one owner; the authenticated @@ -71,5 +74,7 @@ A racing sweep may close one last non-FI socket; it can reconnect under optional policy. FI-admitted sockets retain their finite deadline. Re-enable repeats the optional-lease drain and existing periodic revalidation. -The `nip-fi-upgrade enable/status` command targets the legacy enable-only endpoint. -Use the general command for new operations; legacy GET hides general CAS receipts. +The old `nip-fi-upgrade` command and endpoint are removed. Requests to that +route now return 404. Recover historical receipts with `nip-fi-policy status +--operation-id`; their original null CAS preconditions remain visible. Never +reuse a historical operation UUID for a new intent.