diff --git a/.env.example b/.env.example index 3c8992a725..6e30b9dc30 100644 --- a/.env.example +++ b/.env.example @@ -482,3 +482,7 @@ RUST_LOG=buzz_relay=debug,buzz_datastore=info,buzz_db=debug,buzz_auth=debug,buzz # verified_at_refresh_seconds=3600 (integer 0..86400). Requires a stable relay # key and NIP-FI enforcement. Profiles are historical records and are never # retracted. + +# New irreversible community FI acceptances stay disabled until the fleet's +# enforcement and activation baseline is verified. Disabling preserves receipts. +BUZZ_NIP_FI_UPGRADES_ENABLED=false diff --git a/crates/buzz-admin/src/nip_fi.rs b/crates/buzz-admin/src/nip_fi.rs index 0fd62977bd..75eed82202 100644 --- a/crates/buzz-admin/src/nip_fi.rs +++ b/crates/buzz-admin/src/nip_fi.rs @@ -188,6 +188,10 @@ async fn unbind( { NipFiUnbind::Removed => true, NipFiUnbind::NotBound => false, + NipFiUnbind::RequiredIssuer => anyhow::bail!( + "refusing to remove the last NIP-FI issuer: this community requires NIP-FI; \ + bind a replacement first, or archive or delete the community to stop it logging in" + ), NipFiUnbind::LastIssuer => anyhow::bail!( "refusing to remove the community's last NIP-FI issuer: backfill would rebind it; \ bind a replacement first, or archive or delete the community to stop it logging in" diff --git a/crates/buzz-db/src/lib.rs b/crates/buzz-db/src/lib.rs index 6fd74d5f32..4bd7e76209 100644 --- a/crates/buzz-db/src/lib.rs +++ b/crates/buzz-db/src/lib.rs @@ -88,11 +88,11 @@ pub(crate) use runtime::{ }; pub use store::{ admin_moderation, allowlist, api_token, archived_identities, artifact, artifact_sync, channel, - channel_members, community, deletion, dm, entity_deletion, event, feed, git_repo, moderation, - nip_fi_binding, operator_listener, partition, personal_read, product_feedback, push, reaction, - read_state, relay_admin_actions, relay_invite, relay_members, relay_operators, reminder, - replaceable, storage_accounting, thread, thread_window, usage, user, user_deletion, - user_deletion_fences, workflow, + channel_members, community, community_auth, deletion, dm, entity_deletion, event, feed, + git_repo, moderation, nip_fi_binding, operator_listener, partition, personal_read, + product_feedback, push, reaction, read_state, relay_admin_actions, relay_invite, relay_members, + relay_operators, reminder, replaceable, storage_accounting, thread, thread_window, usage, user, + user_deletion, user_deletion_fences, workflow, }; pub use allowlist::AllowlistEntry; diff --git a/crates/buzz-db/src/runtime/migration.rs b/crates/buzz-db/src/runtime/migration.rs index c8487b6002..5bd47588b6 100644 --- a/crates/buzz-db/src/runtime/migration.rs +++ b/crates/buzz-db/src/runtime/migration.rs @@ -564,6 +564,7 @@ mod postgres_tests { "storage_accounting_snapshots", "operator_listener_pubkeys", "operator_listener_outbox", + "community_auth_upgrades", ] { if normalized[insert_pos..].contains(&format!("'{value}'")) { globals.insert(value.to_owned()); @@ -777,7 +778,7 @@ mod postgres_tests { let mut migrations: Vec<_> = MIGRATOR.iter().collect(); migrations.sort_by_key(|migration| migration.version); - assert_eq!(migrations.len(), 81); + assert_eq!(migrations.len(), 82); for (index, migration) in migrations.iter().enumerate() { assert_eq!(migration.version, (index + 1) as i64); } @@ -2889,6 +2890,7 @@ mod postgres_tests { "relay_admin_actions", "relay_admin_outbox", "relay_operator_audit", + "community_auth_upgrades", "entity_deletion_approvals", "entity_deletion_requests", "artifact_heads", @@ -2931,6 +2933,8 @@ mod postgres_tests { ); for table in [ + "communities", + "community_auth_upgrades", "entity_deletion_requests", "entity_deletion_stage_history", "user_deletion_fences", diff --git a/crates/buzz-db/src/store/community_auth.rs b/crates/buzz-db/src/store/community_auth.rs new file mode 100644 index 0000000000..a69b908112 --- /dev/null +++ b/crates/buzz-db/src/store/community_auth.rs @@ -0,0 +1,269 @@ +//! Monotonic community authentication requirements and durable operator receipts. +//! +//! Receipts are deployment-global and intentionally survive target deletion: +//! an operation UUID must never acquire a new meaning after a lost response. + +use buzz_core::CommunityId; +use buzz_datastore_tracing::datastore_span; +use serde::Serialize; +use sqlx::{FromRow, Row}; +use uuid::Uuid; + +use crate::{Db, Result}; + +/// Canonical request tuple, with the authenticated operator added by the relay. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct UpgradeRequest { + /// Caller-generated, deployment-global idempotency UUID. + pub operation_id: Uuid, + /// Immutable community identity. + pub community_id: Uuid, + /// Normalized expected community host. + pub expected_host: String, + /// Registered key of the authenticated invoking owner. + pub actor_pubkey: String, + /// Owner precondition, checked under the community row lock. + pub expected_owner_pubkey: String, + /// Exact issuer selected by trusted deployment configuration. + pub expected_issuer: String, + /// NIP-98 authenticated deployment operator; never taken from request JSON. + pub operator_pubkey: String, +} + +/// Durable acceptance tuple plus the current availability of its target. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, FromRow)] +pub struct UpgradeRecord { + /// Caller idempotency UUID. + pub operation_id: Uuid, + /// Target identity, preserved after deletion. + #[sqlx(rename = "target_community_id")] + pub community_id: Uuid, + /// Host at acceptance. + pub expected_host: String, + /// Invoking owner's registered key at acceptance. + pub actor_pubkey: String, + /// Owner precondition at acceptance. + pub expected_owner_pubkey: String, + /// Issuer precondition at acceptance. + pub expected_issuer: String, + /// Operator authorized to recover this receipt. + pub operator_pubkey: String, + /// Accepted generation; concurrent enables converge on this value. + pub policy_generation: i64, + /// Receipt proves acceptance; availability does not undo this fact. + pub requires_nip_fi: bool, + /// Pending until BUZZ-512 supplies applied-and-drained evidence. + pub state: String, + /// Extensible activation obligations; unknown reasons must remain pending. + pub pending_reasons: Vec, +} + +impl UpgradeRecord { + fn matches(&self, request: &UpgradeRequest) -> bool { + self.operation_id == request.operation_id + && self.community_id == request.community_id + && self.expected_host == request.expected_host + && self.actor_pubkey == request.actor_pubkey + && self.expected_owner_pubkey == request.expected_owner_pubkey + && self.expected_issuer == request.expected_issuer + && self.operator_pubkey == request.operator_pubkey + } +} + +/// 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 { + /// Durable monotonic requirement. Global enforce remains an additional floor. + pub requires_nip_fi: bool, + /// Zero for legacy optional communities; positive after the first enable. + pub policy_generation: i64, +} + +// One statement gives status a single primary-database snapshot. Missing trust, +// host/policy drift and terminal lifecycle never become a usable active result. +const RECEIPT: &str = "SELECT u.*, true AS requires_nip_fi, + CASE WHEN c.id IS NOT NULL AND c.host = u.expected_host + AND c.archived_at IS NULL AND c.deleted_at IS NULL + AND c.deletion_state = 'active' AND c.requires_nip_fi + AND c.policy_generation = u.policy_generation + AND NOT EXISTS (SELECT 1 FROM community_deletion_requests d + WHERE d.community_id = c.id AND d.stage <> 'aborted') + AND EXISTS (SELECT 1 FROM community_nip_fi_issuers i + WHERE i.community_id = c.id AND i.issuer = u.expected_issuer) + THEN 'pending' ELSE 'unavailable' END AS state, + ARRAY['registry_baseline_unverified']::text[] AS pending_reasons + FROM community_auth_upgrades u + LEFT JOIN communities c ON c.id = u.target_community_id + WHERE u.operation_id = $1"; + +impl Db { + /// Read current active-community policy from the writer, never a replica. + #[datastore_span(name = "community_auth_policy", system = "postgresql")] + pub async fn community_auth_policy( + &self, + community: CommunityId, + ) -> Result> { + let mut connection = crate::observability::acquire_writer( + &self.pool, + crate::observability::WriterOperation::Authorization, + ) + .await?; + Ok(sqlx::query_as("SELECT requires_nip_fi, policy_generation FROM communities + WHERE id = $1 AND archived_at IS NULL AND deleted_at IS NULL AND deletion_state = 'active'") + .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> { + let mut connection = crate::observability::acquire_writer( + &self.pool, + crate::observability::WriterOperation::Authorization, + ) + .await?; + let record: Option = sqlx::query_as(RECEIPT) + .bind(operation) + .fetch_optional(&mut *connection) + .await?; + Ok(record.filter(|record| record.operator_pubkey == operator)) + } + + /// 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 { + let connection = crate::observability::acquire_writer( + &self.pool, + crate::observability::WriterOperation::Authorization, + ) + .await?; + let mut tx = sqlx::Transaction::begin(connection, None).await?; + sqlx::query("SET LOCAL lock_timeout = '5s'") + .execute(&mut *tx) + .await?; + // Lock UUID first across all targets, so cross-community UUID reuse + // cannot race the INSERT. Hash collisions merely serialize callers. + sqlx::query("SELECT pg_advisory_xact_lock(hashtextextended($1, 510))") + .bind(request.operation_id.to_string()) + .execute(&mut *tx) + .await?; + let existing: Option = sqlx::query_as(RECEIPT) + .bind(request.operation_id) + .fetch_optional(&mut *tx) + .await?; + if let Some(record) = existing { + return Ok(if record.matches(request) { + UpgradeOutcome::Accepted(record) + } else { + UpgradeOutcome::Refused("idempotency_conflict") + }); + } + if let Err(code) = configured_trust { + return Ok(UpgradeOutcome::Refused(code)); + } + let community = CommunityId::from_uuid(request.community_id); + crate::deletion::lock_community_deletion_shared(&mut tx, community).await?; + // Same order and lock strength as ownership transfer; archive/delete + // also take this row lock. Read ownership only after acquiring it. + let row = sqlx::query( + "SELECT host, archived_at IS NULL AND deleted_at IS NULL + AND deletion_state = 'active' AS active, requires_nip_fi, policy_generation + FROM communities WHERE id = $1 FOR NO KEY UPDATE", + ) + .bind(request.community_id) + .fetch_optional(&mut *tx) + .await?; + let Some(row) = row else { + return Ok(UpgradeOutcome::Refused("community_not_found")); + }; + if row.try_get::("host")? != request.expected_host { + return Ok(UpgradeOutcome::Refused("host_conflict")); + } + let deletion_pending: bool = sqlx::query_scalar( + "SELECT EXISTS(SELECT 1 FROM + community_deletion_requests WHERE community_id = $1 AND stage <> 'aborted')", + ) + .bind(request.community_id) + .fetch_one(&mut *tx) + .await?; + if !row.try_get::("active")? || deletion_pending { + return Ok(UpgradeOutcome::Refused("lifecycle_conflict")); + } + let owners: Vec = sqlx::query_scalar( + "SELECT pubkey FROM relay_members + WHERE community_id = $1 AND role = 'owner' ORDER BY pubkey FOR UPDATE", + ) + .bind(request.community_id) + .fetch_all(&mut *tx) + .await?; + if owners != [request.expected_owner_pubkey.clone()] + || request.actor_pubkey != request.expected_owner_pubkey + { + return Ok(UpgradeOutcome::Refused("owner_conflict")); + } + let issuer: Option = sqlx::query_scalar( + "SELECT issuer FROM community_nip_fi_issuers + WHERE community_id = $1 AND issuer = $2 FOR UPDATE", + ) + .bind(request.community_id) + .bind(&request.expected_issuer) + .fetch_optional(&mut *tx) + .await?; + if issuer.is_none() { + return Ok(UpgradeOutcome::Refused("issuer_conflict")); + } + // Admission is also required for this communities-row mutation. + self.deletion_store() + .guard_transaction(&mut tx, community) + .await?; + let generation: i64 = sqlx::query_scalar("UPDATE communities SET + policy_generation = CASE WHEN requires_nip_fi THEN policy_generation ELSE policy_generation + 1 END, + requires_nip_fi = true WHERE id = $1 RETURNING policy_generation") + .bind(request.community_id).fetch_one(&mut *tx).await?; + 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,$5,$6,$7,$8)", + ) + .bind(request.operation_id) + .bind(request.community_id) + .bind(&request.expected_host) + .bind(&request.actor_pubkey) + .bind(&request.expected_owner_pubkey) + .bind(&request.expected_issuer) + .bind(&request.operator_pubkey) + .bind(generation) + .execute(&mut *tx) + .await?; + let record = sqlx::query_as(RECEIPT) + .bind(request.operation_id) + .fetch_one(&mut *tx) + .await?; + tx.commit().await?; + Ok(UpgradeOutcome::Accepted(record)) + } +} + +#[cfg(test)] +mod postgres_tests; diff --git a/crates/buzz-db/src/store/community_auth/postgres_tests.rs b/crates/buzz-db/src/store/community_auth/postgres_tests.rs new file mode 100644 index 0000000000..53cd4733f3 --- /dev/null +++ b/crates/buzz-db/src/store/community_auth/postgres_tests.rs @@ -0,0 +1,500 @@ +use super::*; +use crate::CreateCommunityWithOwnerResult; +use nostr::Keys; + +const ISSUER: &str = "https://identity.example/api/goose"; + +async fn fixture() -> (Db, UpgradeRequest, Keys) { + let db = Db::from_pool( + sqlx::PgPool::connect(&crate::test_support::database_url()) + .await + .unwrap(), + ); + let keys = Keys::generate(); + let owner = Keys::generate().public_key().to_hex(); + let host = format!("auth-{}.example", Uuid::new_v4().simple()); + let write = db + .create_community_with_owner_and_nip_fi_issuers(&host, &owner, &[ISSUER.to_owned()], &keys) + .await + .unwrap(); + let CreateCommunityWithOwnerResult::Created(record) = write.outcome else { + panic!("create failed") + }; + let request = UpgradeRequest { + operation_id: Uuid::new_v4(), + community_id: *record.id.as_uuid(), + expected_host: host, + actor_pubkey: owner.clone(), + expected_owner_pubkey: owner, + expected_issuer: ISSUER.to_owned(), + operator_pubkey: keys.public_key().to_hex(), + }; + (db, request, keys) +} + +async fn accepted(db: &Db, request: &UpgradeRequest) -> UpgradeRecord { + let UpgradeOutcome::Accepted(record) = + db.enable_community_nip_fi(request, Ok(())).await.unwrap() + else { + panic!("acceptance refused") + }; + record +} + +async fn policy(db: &Db, request: &UpgradeRequest) -> CommunityAuthPolicy { + db.community_auth_policy(CommunityId::from_uuid(request.community_id)) + .await + .unwrap() + .unwrap() +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn response_loss_recovery_and_payload_binding() { + let (db, request, _) = fixture().await; + assert!(!policy(&db, &request).await.requires_nip_fi); + let first = accepted(&db, &request).await; + assert_eq!(first.state, "pending"); + 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) + .await + .unwrap(), + Some(first.clone()) + ); + assert_eq!(accepted(&db, &request).await, first); + assert!(db + .community_auth_upgrade_status(request.operation_id, "another operator") + .await + .unwrap() + .is_none()); + for field in 0..6 { + let mut changed = request.clone(); + match field { + 0 => changed.community_id = Uuid::new_v4(), + 1 => changed.expected_host = "another.example".to_owned(), + 2 => changed.actor_pubkey = "b".repeat(64), + 3 => changed.expected_owner_pubkey = "c".repeat(64), + 4 => changed.expected_issuer = "https://another.example".to_owned(), + _ => changed.operator_pubkey = "d".repeat(64), + } + assert_eq!( + db.enable_community_nip_fi(&changed, Ok(())).await.unwrap(), + UpgradeOutcome::Refused("idempotency_conflict") + ); + } + assert_eq!(policy(&db, &request).await.policy_generation, 1); + let community = CommunityId::from_uuid(request.community_id); + for allow_last in [true, false] { + assert_eq!( + db.unbind_nip_fi_issuer(community, ISSUER, allow_last) + .await + .unwrap(), + crate::NipFiUnbind::RequiredIssuer + ); + } + assert_eq!(accepted(&db, &request).await, first); + // Rotation can remove the accepted issuer only after a replacement is bound. + db.bind_nip_fi_issuers(community, &["https://replacement.example".to_owned()]) + .await + .unwrap(); + assert_eq!( + db.unbind_nip_fi_issuer(community, ISSUER, true) + .await + .unwrap(), + crate::NipFiUnbind::Removed + ); + assert_eq!(accepted(&db, &request).await.state, "unavailable"); + assert_eq!( + db.unbind_nip_fi_issuer(community, "https://replacement.example", true) + .await + .unwrap(), + crate::NipFiUnbind::RequiredIssuer + ); + assert!(policy(&db, &request).await.requires_nip_fi); + // Out-of-band trust loss requires an explicit repair, never a bulk rebind. + sqlx::query("DELETE FROM community_nip_fi_issuers WHERE community_id = $1") + .bind(request.community_id) + .execute(&db.pool) + .await + .unwrap(); + assert_eq!( + db.bind_nip_fi_issuers_to_active(&[community], &[ISSUER.to_owned()]) + .await + .unwrap(), + 0 + ); + assert_eq!(accepted(&db, &request).await.state, "unavailable"); + db.bind_nip_fi_issuers(community, &[ISSUER.to_owned()]) + .await + .unwrap(); + assert_eq!(accepted(&db, &request).await, first); +} + +#[tokio::test] +#[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) + ); + assert_eq!(a, b); + assert_eq!(a.policy_generation, c.policy_generation); + assert_eq!(policy(&db, &request).await.policy_generation, 1); + let (_, mut other, _) = fixture().await; + other.operation_id = request.operation_id; + assert_eq!( + db.enable_community_nip_fi(&other, Ok(())).await.unwrap(), + UpgradeOutcome::Refused("idempotency_conflict") + ); +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn failed_preconditions_leave_free_policy_and_no_receipt() { + let (db, request, _) = fixture().await; + for (mut changed, code) in [ + (request.clone(), "host_conflict"), + (request.clone(), "owner_conflict"), + (request.clone(), "issuer_conflict"), + (request.clone(), "community_not_found"), + ] { + match code { + "host_conflict" => changed.expected_host = "wrong.example".to_owned(), + "owner_conflict" => changed.actor_pubkey = "b".repeat(64), + "issuer_conflict" => changed.expected_issuer = "https://wrong.example".to_owned(), + _ => changed.community_id = Uuid::new_v4(), + } + assert_eq!( + db.enable_community_nip_fi(&changed, Ok(())).await.unwrap(), + UpgradeOutcome::Refused(code) + ); + } + assert_eq!( + db.enable_community_nip_fi(&request, Err("authorization_unavailable")) + .await + .unwrap(), + UpgradeOutcome::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") + ); + assert_eq!( + policy(&db, &request).await, + CommunityAuthPolicy { + requires_nip_fi: false, + policy_generation: 0 + } + ); + assert!(db + .community_auth_upgrade_status(request.operation_id, &request.operator_pubkey) + .await + .unwrap() + .is_none()); +} + +// Observe the actual PostgreSQL blocker and statement, rather than treating +// elapsed time or either possible race outcome as evidence of serialization. +async fn blocked_upgrade( + db: &Db, + request: &UpgradeRequest, + locker_pid: i32, + statement: &str, +) -> 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()) + .unwrap() + .application_name(&application); + let upgrade_db = Db::from_pool( + sqlx::postgres::PgPoolOptions::new() + .max_connections(1) + .connect_with(options) + .await + .unwrap(), + ); + let request = request.clone(); + let task = + tokio::spawn(async move { upgrade_db.enable_community_nip_fi(&request, Ok(())).await }); + tokio::time::timeout(std::time::Duration::from_secs(10), async { + loop { + assert!( + !task.is_finished(), + "enable escaped before taking its precondition lock" + ); + let row: Option<(String, Vec)> = sqlx::query_as( + "SELECT query, pg_blocking_pids(pid) FROM pg_stat_activity + WHERE application_name = $1 AND wait_event_type = 'Lock'", + ) + .bind(&application) + .fetch_optional(&db.pool) + .await + .unwrap(); + if let Some((query, blockers)) = row { + if blockers.contains(&locker_pid) { + let query = query.split_whitespace().collect::>().join(" "); + assert!( + query.starts_with(statement), + "enable reached a later statement without the precondition lock: {query}" + ); + break; + } + } + tokio::task::yield_now().await; + } + }) + .await + .expect("observe the enable statement blocked by the held transaction"); + task +} + +async fn assert_refused_without_mutation( + db: &Db, + request: &UpgradeRequest, + task: tokio::task::JoinHandle>, + code: &'static str, +) { + assert_eq!(task.await.unwrap().unwrap(), UpgradeOutcome::Refused(code)); + assert!(db + .community_auth_upgrade_status(request.operation_id, &request.operator_pubkey) + .await + .unwrap() + .is_none()); + let policy: (bool, i64) = + sqlx::query_as("SELECT requires_nip_fi, policy_generation FROM communities WHERE id = $1") + .bind(request.community_id) + .fetch_one(&db.pool) + .await + .unwrap(); + assert_eq!(policy, (false, 0)); +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn owner_lock_blocks_uncommitted_owner_change_then_refuses_enable() { + let (db, request, _) = fixture().await; + let mut locker = db.pool.begin().await.unwrap(); + let pid: i32 = sqlx::query_scalar("SELECT pg_backend_pid()") + .fetch_one(&mut *locker) + .await + .unwrap(); + sqlx::query("UPDATE relay_members SET role = 'member' WHERE community_id = $1 AND pubkey = $2") + .bind(request.community_id) + .bind(&request.expected_owner_pubkey) + .execute(&mut *locker) + .await + .unwrap(); + let task = blocked_upgrade(&db, &request, pid, "SELECT pubkey FROM relay_members").await; + locker.commit().await.unwrap(); + assert_refused_without_mutation(&db, &request, task, "owner_conflict").await; +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn community_lock_blocks_uncommitted_archive_then_refuses_enable() { + let (db, request, _) = fixture().await; + let mut locker = db.pool.begin().await.unwrap(); + let pid: i32 = sqlx::query_scalar("SELECT pg_backend_pid()") + .fetch_one(&mut *locker) + .await + .unwrap(); + sqlx::query("UPDATE communities SET archived_at = now() WHERE id = $1") + .bind(request.community_id) + .execute(&mut *locker) + .await + .unwrap(); + let task = blocked_upgrade(&db, &request, pid, "SELECT host, archived_at").await; + locker.commit().await.unwrap(); + assert_refused_without_mutation(&db, &request, task, "lifecycle_conflict").await; +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn issuer_lock_blocks_uncommitted_unbind_then_refuses_enable() { + let (db, request, _) = fixture().await; + let mut locker = db.pool.begin().await.unwrap(); + let pid: i32 = sqlx::query_scalar("SELECT pg_backend_pid()") + .fetch_one(&mut *locker) + .await + .unwrap(); + sqlx::query("DELETE FROM community_nip_fi_issuers WHERE community_id = $1 AND issuer = $2") + .bind(request.community_id) + .bind(ISSUER) + .execute(&mut *locker) + .await + .unwrap(); + let task = blocked_upgrade( + &db, + &request, + pid, + "SELECT issuer FROM community_nip_fi_issuers", + ) + .await; + locker.commit().await.unwrap(); + assert_refused_without_mutation(&db, &request, task, "issuer_conflict").await; +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn accepted_receipt_survives_transfer_without_granting_fresh_owner_authority() { + let (db, request, keys) = fixture().await; + let first = accepted(&db, &request).await; + let new_owner = Keys::generate().public_key().to_hex(); + let _ = db + .transfer_ownership( + CommunityId::from_uuid(request.community_id), + &new_owner, + &request.expected_owner_pubkey, + &keys, + ) + .await + .unwrap(); + assert_eq!(accepted(&db, &request).await, first); + 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") + ); +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn authoritative_lookup_failure_is_error_not_fabricated_acceptance() { + let db = Db::from_pool( + sqlx::postgres::PgPoolOptions::new() + .acquire_timeout(std::time::Duration::from_millis(100)) + .connect_lazy("postgres://127.0.0.1:1/unavailable") + .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) + .await + .is_err()); +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn deletion_intent_refuses_enable_before_quiescing() { + let (db, request, _) = fixture().await; + db.deletion_store() + .submit( + Some(&request.expected_host), + "operator", + None, + Some(CommunityId::from_uuid(request.community_id)), + ) + .await + .unwrap(); + assert_eq!( + db.enable_community_nip_fi(&request, Ok(())).await.unwrap(), + UpgradeOutcome::Refused("lifecycle_conflict") + ); + assert!(!policy(&db, &request).await.requires_nip_fi); + assert!(db + .community_auth_upgrade_status(request.operation_id, &request.operator_pubkey) + .await + .unwrap() + .is_none()); +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn migration_schema_expansion_preserves_existing_optional_rows_with_and_without_trust() { + let pool = sqlx::PgPool::connect(&crate::test_support::database_url()) + .await + .unwrap(); + crate::migration::run_migrations_through(&pool, 81) + .await + .unwrap(); + let db = Db::from_pool(pool.clone()); + let free = db + .ensure_configured_community("free-before.example") + .await + .unwrap() + .id; + let bound = db + .ensure_configured_community_with_nip_fi_issuers( + "bound-before.example", + &[ISSUER.to_owned()], + ) + .await + .unwrap() + .id; + crate::migration::run_migrations(&pool).await.unwrap(); + for community in [free, bound] { + assert_eq!( + db.community_auth_policy(community).await.unwrap(), + Some(CommunityAuthPolicy { + requires_nip_fi: false, + policy_generation: 0, + }) + ); + } + assert_eq!( + db.lookup_nip_fi_binding_by_host("bound-before.example") + .await + .unwrap() + .unwrap() + .issuers, + vec![ISSUER.to_owned()] + ); + let receipts: i64 = sqlx::query_scalar("SELECT count(*) FROM community_auth_upgrades") + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(receipts, 0); + let global: bool = sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM _operator_global_tables WHERE table_name = 'community_auth_upgrades')") + .fetch_one(&pool).await.unwrap(); + assert!(global); +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn supported_writer_maintains_generation_while_direct_sql_is_not_constrained() { + let (db, mut request, _) = fixture().await; + assert_eq!(policy(&db, &request).await.policy_generation, 0); + sqlx::query("UPDATE communities SET policy_generation = -1 WHERE id = $1") + .bind(request.community_id) + .execute(db.pool()) + .await + .unwrap(); + assert_eq!(policy(&db, &request).await.policy_generation, -1); + sqlx::query("UPDATE communities SET policy_generation = DEFAULT WHERE id = $1") + .bind(request.community_id) + .execute(db.pool()) + .await + .unwrap(); + assert_eq!(policy(&db, &request).await.policy_generation, 0); + 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); + let invalid_receipt = sqlx::query( + "UPDATE community_auth_upgrades SET policy_generation = 0 WHERE operation_id = $1", + ) + .bind(request.operation_id) + .execute(db.pool()) + .await + .unwrap_err(); + assert_eq!( + invalid_receipt + .as_database_error() + .unwrap() + .code() + .as_deref(), + Some("23514") + ); + assert_eq!(policy(&db, &request).await.policy_generation, 1); +} diff --git a/crates/buzz-db/src/store/deletion.rs b/crates/buzz-db/src/store/deletion.rs index 9c1eb2f433..66754b45d4 100644 --- a/crates/buzz-db/src/store/deletion.rs +++ b/crates/buzz-db/src/store/deletion.rs @@ -8657,6 +8657,21 @@ DROP FUNCTION community_write_fence_excluded_table(NAME); .await .unwrap() .approvals; + // Upgrade receipts are operator recovery evidence, intentionally retained + // through purge so their UUID cannot be rebound to another community. + let upgrade_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,'owner','owner','https://issuer.example','operator',1)", + ) + .bind(upgrade_id) + .bind(community) + .bind(&request.community_host) + .execute(&db.pool) + .await + .unwrap(); let history_id: Uuid = sqlx::query_scalar( "INSERT INTO entity_deletion_stage_history (request_id, target_community_id, stage, is_current) @@ -8700,6 +8715,13 @@ DROP FUNCTION community_write_fence_excluded_table(NAME); .expect("bindings"); let deleted = store.purge_postgres(&token).await.expect("purge postgres"); + let upgrade = db + .community_auth_upgrade_status(upgrade_id, "operator") + .await + .unwrap() + .expect("receipt survives purge"); + assert_eq!(upgrade.community_id, community); + assert_eq!(upgrade.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-db/src/store/mod.rs b/crates/buzz-db/src/store/mod.rs index bd2d709151..aa2abfde23 100644 --- a/crates/buzz-db/src/store/mod.rs +++ b/crates/buzz-db/src/store/mod.rs @@ -14,6 +14,8 @@ pub mod channel; pub mod channel_members; /// Community lifecycle and host-map persistence. pub mod community; +/// Monotonic community auth policy and operator acceptance receipts. +pub mod community_auth; /// Durable whole-community deletion lifecycle and PostgreSQL adapter. pub mod deletion; /// Direct message channel persistence. diff --git a/crates/buzz-db/src/store/nip_fi_binding.rs b/crates/buzz-db/src/store/nip_fi_binding.rs index cca7a2e2b6..ffd25cba96 100644 --- a/crates/buzz-db/src/store/nip_fi_binding.rs +++ b/crates/buzz-db/src/store/nip_fi_binding.rs @@ -19,6 +19,8 @@ pub enum NipFiUnbind { NotBound, /// The issuer is the community's last one and was kept. LastIssuer, + /// The last issuer is required by durable community policy, even with `allow_last`. + RequiredIssuer, } /// A community's stored NIP-FI binding, as returned to the operator route. @@ -94,10 +96,11 @@ impl Db { } /// Remove one issuer from a community, refusing to remove its last one - /// unless `allow_last`. + /// unless `allow_last` and the community does not require NIP-FI. /// - /// The bound rows are locked first, so two concurrent unbinds of a - /// community's two issuers cannot both succeed and leave it empty. An + /// The community row is locked before issuer rows, serializing with policy + /// acceptance and binding edits. Two concurrent unbinds of a community's + /// two issuers cannot both succeed and leave it empty. An /// empty community looks never-provisioned and the next backfill would /// rebind it; archive or delete the community to stop it logging in. #[datastore_span(name = "unbind_nip_fi_issuer", system = "postgresql")] @@ -116,6 +119,12 @@ impl Db { self.deletion_store() .guard_transaction(&mut tx, community) .await?; + let requires_nip_fi: Option = sqlx::query_scalar( + "SELECT requires_nip_fi FROM communities WHERE id = $1 FOR NO KEY UPDATE", + ) + .bind(community.as_uuid()) + .fetch_optional(&mut *tx) + .await?; let bound: Vec = sqlx::query_scalar( "SELECT issuer FROM community_nip_fi_issuers WHERE community_id = $1 FOR UPDATE", ) @@ -124,6 +133,8 @@ impl Db { .await?; let outcome = if !bound.iter().any(|bound| bound == issuer) { NipFiUnbind::NotBound + } else if bound.len() == 1 && requires_nip_fi == Some(true) { + NipFiUnbind::RequiredIssuer } else if bound.len() == 1 && !allow_last { NipFiUnbind::LastIssuer } else { @@ -234,7 +245,8 @@ impl Db { /// than aborting the batch. The community rows are then locked, as /// [`Db::bind_nip_fi_issuers`] does, so an operator bind that commits after /// the caller listed the community wins and the default is not added - /// beside it. Returns the number of new rows. + /// beside it. Required communities need explicit operator repair and are + /// never rebound by this bulk backfill. Returns the number of new rows. #[datastore_span(name = "bind_nip_fi_issuers_to_active", system = "postgresql")] pub async fn bind_nip_fi_issuers_to_active( &self, @@ -270,6 +282,7 @@ impl Db { WHERE c.id = ANY($1) AND c.deletion_state = 'active' AND c.deleted_at IS NULL + AND NOT c.requires_nip_fi AND NOT EXISTS ( SELECT 1 FROM community_nip_fi_issuers b WHERE b.community_id = c.id ) diff --git a/crates/buzz-relay/src/api/operator.rs b/crates/buzz-relay/src/api/operator.rs index 2bda337cfb..ae81ea1483 100644 --- a/crates/buzz-relay/src/api/operator.rs +++ b/crates/buzz-relay/src/api/operator.rs @@ -1183,6 +1183,202 @@ 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, + issuer: &str, +) -> Result<(), &'static str> { + let policy = state + .config + .nip_fi_provision_binding + .as_ref() + .ok_or("authorization_unavailable")?; + let [configured_issuer] = policy.authorized_issuers() else { + return Err("authorization_unavailable"); + }; + if !policy.is_bindable(host) || configured_issuer != issuer { + return Err("issuer_conflict"); + } + if state + .config + .nip_fi + .registry + .policy_for_issuer(issuer) + .is_none() + { + return Err("authorization_unavailable"); + } + 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_upgrades_enabled { + 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) => { + 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( + request: axum::extract::Request, + next: axum::middleware::Next, +) -> axum::response::Response { + let upgrade = request.uri().path() == "/operator/communities/nip-fi-upgrade"; + let mut response = next.run(request).await; + if upgrade { + response.headers_mut().insert( + axum::http::header::CACHE_CONTROL, + axum::http::HeaderValue::from_static("no-store"), + ); + } + response +} + #[cfg(test)] mod postgres_tests { use std::{ @@ -2889,6 +3085,444 @@ mod postgres_tests { (policy, reserved, format!("acme.{suffix}")) } + fn fresh_upgrade_auth(keys: &Keys, url: &str, method: &str, body: Option<&[u8]>) -> String { + let mut tags = vec![ + Tag::parse(["u", url]).unwrap(), + Tag::parse(["method", method]).unwrap(), + Tag::parse(["nonce", &Uuid::new_v4().to_string()]).unwrap(), + ]; + if let Some(body) = body { + tags.push(Tag::parse(["payload", &hex::encode(Sha256::digest(body))]).unwrap()); + } + let event = EventBuilder::new(Kind::HttpAuth, "") + .tags(tags) + .sign_with_keys(keys) + .unwrap(); + format!( + "Nostr {}", + base64::engine::general_purpose::STANDARD.encode(serde_json::to_vec(&event).unwrap()) + ) + } + + async fn fresh_upgrade_request( + state: Arc, + keys: &Keys, + method: &str, + path: &str, + body: Option, + ) -> axum::response::Response { + let auth = fresh_upgrade_auth( + keys, + &format!("http://{INGRESS_HOST}{path}"), + method, + body.as_deref().map(str::as_bytes), + ); + 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_upgrades_enabled = + false; + assert!(!state.config.nip_fi_upgrades_enabled); + + 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_upgrades_enabled = 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!(["registry_baseline_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_upgrades_enabled = + 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_upgrades_enabled = 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, diff --git a/crates/buzz-relay/src/config.rs b/crates/buzz-relay/src/config.rs index e0d484d1ee..54820413d2 100644 --- a/crates/buzz-relay/src/config.rs +++ b/crates/buzz-relay/src/config.rs @@ -414,6 +414,10 @@ pub struct Config { /// Parsed whatever the NIP-FI mode, since creation happens in every mode. pub nip_fi_provision_binding: Option, + /// Permit new irreversible FI upgrade acceptances. Default off; receipt + /// recovery/status remain available (`BUZZ_NIP_FI_UPGRADES_ENABLED`). + pub nip_fi_upgrades_enabled: bool, + /// Database resolver bounds (`BUZZ_NIP_FI_RESOLVER_*`, /// `BUZZ_NIP_FI_SNAPSHOT_*`). Validated in every mode, so a bad value /// fails the deploy that sets it, not the later switch to shadow. @@ -1524,6 +1528,7 @@ impl Config { web_dir, serve_git_web_gui, nip_fi_provision_binding, + nip_fi_upgrades_enabled: parse_bool("BUZZ_NIP_FI_UPGRADES_ENABLED", false)?, nip_fi_resolver_bounds, nip_fi, }) @@ -1596,6 +1601,37 @@ mod tests { (fi, cfg) } + #[test] + fn nip_fi_upgrade_acceptance_is_explicitly_default_off() { + let (_fi, _cfg) = env_guards(); + let before = std::env::var("BUZZ_NIP_FI_UPGRADES_ENABLED").ok(); + std::env::remove_var("BUZZ_NIP_FI_UPGRADES_ENABLED"); + assert!(!Config::from_env().unwrap().nip_fi_upgrades_enabled); + for value in ["true", "TRUE", "1", "on", " On "] { + std::env::set_var("BUZZ_NIP_FI_UPGRADES_ENABLED", value); + assert!( + Config::from_env().unwrap().nip_fi_upgrades_enabled, + "{value}" + ); + } + for value in ["false", "FALSE", "0", "off", "", " Off "] { + std::env::set_var("BUZZ_NIP_FI_UPGRADES_ENABLED", value); + assert!( + !Config::from_env().unwrap().nip_fi_upgrades_enabled, + "{value}" + ); + } + std::env::set_var("BUZZ_NIP_FI_UPGRADES_ENABLED", "treu"); + assert!( + matches!(Config::from_env(), Err(ConfigError::InvalidValue(message)) + if message.contains("BUZZ_NIP_FI_UPGRADES_ENABLED must be true or false")) + ); + match before { + Some(value) => std::env::set_var("BUZZ_NIP_FI_UPGRADES_ENABLED", value), + None => std::env::remove_var("BUZZ_NIP_FI_UPGRADES_ENABLED"), + } + } + // The three `source=database` startup guards. Mutation: dropping any one // lets that configuration start. #[test] diff --git a/crates/buzz-relay/src/router.rs b/crates/buzz-relay/src/router.rs index 2719907057..fdf36acb8b 100644 --- a/crates/buzz-relay/src/router.rs +++ b/crates/buzz-relay/src/router.rs @@ -443,6 +443,11 @@ pub fn build_router(state: Arc) -> Router { "/operator/communities/nip-fi-binding", get(api::operator::community_nip_fi_binding), ) + .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), @@ -559,6 +564,10 @@ 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, + )) } type MakeHttpSpan = fn(&Request) -> tracing::Span; diff --git a/desktop/src/features/agents/useKnownAgentPubkeys.test.mjs b/desktop/src/features/agents/useKnownAgentPubkeys.test.mjs index 7c2c82f0bf..fe37d9ad52 100644 --- a/desktop/src/features/agents/useKnownAgentPubkeys.test.mjs +++ b/desktop/src/features/agents/useKnownAgentPubkeys.test.mjs @@ -20,7 +20,9 @@ Object.assign(globalThis, { after(() => dom.window.close()); test("provenance context follows exact local inventory and rejects failed cached reads", async () => { - const { act, renderHook, cleanup } = await import("@testing-library/react"); + const { act, renderHook, cleanup, waitFor } = await import( + "@testing-library/react" + ); const owner = "a".repeat(64), remote = "b".repeat(64), local = "c".repeat(64); @@ -59,15 +61,15 @@ test("provenance context follows exact local inventory and rejects failed cached [{ pubkey: remote, status: "deployed" }], ), ); - assert.deepEqual(result.current, [false, true, true]); + // Query observers notify on a scheduled task; act alone need not flush it. + await waitFor(() => assert.deepEqual(result.current, [false, true, true])); await act(async () => { client .getQueryCache() .find({ queryKey: ["managed-agents"] }) .setState({ error: new Error("inventory unavailable"), status: "error" }); - await new Promise((resolve) => setTimeout(resolve, 5)); }); - assert.deepEqual(result.current, [false, false, false]); + await waitFor(() => assert.deepEqual(result.current, [false, false, false])); cleanup(); client.clear(); }); diff --git a/docs/community-auth-upgrades.md b/docs/community-auth-upgrades.md new file mode 100644 index 0000000000..45f5e41eb8 --- /dev/null +++ b/docs/community-auth-upgrades.md @@ -0,0 +1,187 @@ +# Community NIP-FI upgrade operator contract + +The acceptance layer records durable policy. The per-request enforcement layer +adds community checks and discovery; the activation layer updates existing sessions. +Do not expose upgrade calls in a deployment until those releases and the serving +fleet baseline have been verified. This change alone cannot establish enforcement. +It never reports `active` and never substitutes a timer for activation evidence. + +The operator HTTP plane is separate from tenant event ingest. These management +operations extend the existing provisioning API; community clients cannot invoke +them with ordinary membership credentials. Both routes require an allowlisted +operator's NIP-98 signature bound to the configured operator API origin, exact +method and URL, including the query string on GET, and the POST body hash. +Operator POST verification does not bind query strings. Each attempt needs a fresh +signature; operation idempotency does not bypass transport replay protection. +Responses use `Cache-Control: no-store`. + +`BUZZ_NIP_FI_UPGRADES_ENABLED` defaults to `false`. While disabled, new +acceptances return 503 `upgrades_disabled` with no policy mutation or +receipt. Authorized GET and identical POST receipt recovery remain available; +principal checks and payload conflicts are unchanged. Enabling this deployment +switch is an explicit operator action after the enforcement and activation fleet +baseline is verified; caller release discipline alone cannot enable acceptance. + +## Generation storage + +`communities.policy_generation` is `BIGINT NOT NULL DEFAULT 0`, without a +nonnegative database CHECK. Supported writers retain the current generation +when the community already requires FI, or increment it by one on acceptance. +Starting from zero, those writes preserve the nonnegative invariant. Direct SQL +can insert a negative community generation and is outside that writer contract. +Positive-generation CHECKs on acceptance receipts remain in place; activation +and receipt matching still require the exact accepted generation. + +The community CHECK is omitted to avoid validating existing rows while adding +the column. The current schema planner folds a column and its CHECK together, +and can rebuild and validate a CHECK when its validation metadata differs. +Migration, fresh desired-state creation and later reconciliation therefore all +omit this community CHECK. No bootstrap or schema-tooling workaround is required. + +## Enable + +`POST /operator/communities/nip-fi-upgrade` + +```json +{ + "operation_id": "c2ffcc20-9505-48fd-b6c7-446aa833bcd1", + "community_id": "c9acf9a6-37d5-4af1-8df9-92a165637b01", + "expected_host": "team.communities.example", + "actor_pubkey": "", + "expected_owner_pubkey": "", + "expected_issuer": "https://identity.example/api/goose" +} +``` + +The trusted operator mediates the authenticated actor. Kgoose must resolve that +actor's usable registered key from its session; it must never accept a user-supplied +actor key as identity. The actor must equal the expected and current owner. +`actor_pubkey` records the mediator's claimed authenticated actor for audit and +idempotency; it is not an independently verified end-user credential. The relay +authenticates the operator and checks the stored current owner. +Host and keys normalize before comparison; issuer bytes match exactly. UUIDs +must be non-nil. Unknown JSON fields are rejected. + +The deployment selects the expected issuer from the **single** authorized issuer +in `BUZZ_NIP_FI_PROVISION_BINDING`. An absent or ambiguous selection is unavailable. +The target must be bindable under that policy and the issuer must have a configured +`BUZZ_NIP_FI_ISSUERS` policy. The same issuer must already be bound to the community +in `community_nip_fi_issuers`. The call never adds or broadens trust. + +A transaction serializes UUID reuse across targets, then community lifecycle, +host and owner checks against transfer/archive/delete. It locks the exact issuer +row against unbind. Only a first optional-to-required transition increments +`policy_generation`; concurrent distinct operations converge on that generation. +Acceptance and its receipt commit together. No timeout or retry clears the policy. + +## Readback and response + +`GET /operator/communities/nip-fi-upgrade?operation_id=` + +Only the original authenticated operator can recover the receipt. Readback +survives owner transfer, archive and deletion; the recorded owner is historical +acceptance evidence, not authority for a fresh enable. + +Enable and readback return HTTP 200 for an accepted receipt: + +```json +{ + "operation_id": "c2ffcc20-9505-48fd-b6c7-446aa833bcd1", + "community_id": "c9acf9a6-37d5-4af1-8df9-92a165637b01", + "expected_host": "team.communities.example", + "actor_pubkey": "", + "expected_owner_pubkey": "", + "expected_issuer": "https://identity.example/api/goose", + "operator_pubkey": "", + "policy_generation": 1, + "requires_nip_fi": true, + "state": "pending", + "pending_reasons": ["registry_baseline_unverified"] +} +``` + +`requires_nip_fi: true` records durable acceptance. `state` reports availability +and activation separately. The acceptance layer returns `pending` while the target's policy, +lifecycle and issuer binding still match, and `unavailable` if they do not or +trusted issuer configuration is no longer applicable. A missing database response +is HTTP 503, never a fabricated receipt. A receipt is not proof of current service +health. No response in this version reports `active`. + +`pending_reasons` applies only while `state` is `pending`; clients must ignore +those obligations when state is `unavailable`, which always takes precedence. +`pending_reasons` is extensible. Clients must preserve pending on unknown values. +`registry_baseline_unverified` prevents completion until the activation layer verifies fleet +membership. That layer may add `boot_application_pending`, `boot_drain_pending` +and `boot_unreachable`, and must bind activation evidence to the exact operation +and generation. Baseline invalidation must prevent `active`. + +The UUID binds the normalized community/host/actor/owner/issuer/operator tuple. +Identical retries recover that tuple and generation before rechecking mutable +preconditions. Changed payload or principal conflicts. After a lost POST response, +read by UUID or retry the same payload with a fresh NIP-98 signature. Do not generate +a new UUID to hide an ambiguous response. No rollback endpoint exists. + +| HTTP | `code` | Meaning | +|---|---|---| +| 400 | `invalid_request` | Invalid enable JSON, UUID, host or key | +| 401/403 | Existing operator auth errors | Missing/invalid credentials or operator not allowlisted | +| 404 | `community_not_found` | No target for a first acceptance | +| 404 | `operation_not_found` | No receipt owned by this operator | +| 409 | `idempotency_conflict` | UUID already bound to a different tuple/principal | +| 409 | `host_conflict` | ID does not map to expected host | +| 409 | `owner_conflict` | Actor/expected owner/current owner disagree | +| 409 | `lifecycle_conflict` | Archive/deletion or durable deletion intent | +| 409 | `issuer_conflict` | Expected issuer/host not approved, or exact stored binding absent | +| 503 | `upgrades_disabled` | New acceptances disabled; wait for operator enablement, preserving the same UUID | +| 503 | `authorization_unavailable` | Trusted config or authoritative DB unavailable; retry/read back same UUID | + +Malformed GET queries use Axum's existing query-extractor rejection. Transport +signature replay uses the existing operator replay error independently of this table. + +## Schema and rollout + +Migration 0082 is additive: legacy rows keep `requires_nip_fi=false` and generation +zero. It adds no trigger, function, procedure, rule or foreign key and rewrites no +existing rows. Lock and statement budgets are 5 and 30 seconds. Existing global +`enforce` remains an additional floor; this layer changes no data-plane gate. +Do not infer requirements from issuer rows, backfill free communities, or remove +the enterprise enforce floor as part of this migration. + +Apply schema before new code. Desired-state and migration definitions match; +The registry entry is static lint metadata, so it is present in the migration +and `schema/seeds/`, not the pgschema reconciliation script (database.md Rule 3). +This follows the other operator-global registry entries. Receipts intentionally survive community purge: +the target ID/host/owner tuple prevents reusing a lost-response UUID for another +community, including after deletion. They are operator-only recovery evidence. +No tenant serving writer owns this table and no fence trigger is attached. + +The active-community primary accessor is +`Db::community_auth_policy(CommunityId) -> Result>`. +`None` means no active target; errors must fail closed. Per-request enforcement must combine +its stored requirement with global enforce and honor repair denial first. + +## Mistaken acceptance and containment + +Stop the initiating caller and set `BUZZ_NIP_FI_UPGRADES_ENABLED=false` to prevent +new acceptances. Keep authenticated status/readback available. Record the exact +operation, target and generation for operator-led incident assessment and recovery. +Preserve the durable requirement, issuer trust and receipts while restoring a +working FI path or containing the affected community through the established +archive/lifecycle controls. Coordinate any necessary issuer/configuration repair +through its existing operator procedure; never broaden trust to make a probe pass. + +Disabling calls or rolling back code does not undo acceptance. Never restore a +serving binary that ignores the durable requirement; retain effective enforcement +or contain the community while repairing the FI path. There is no +clear-policy API or routine SQL reset, and neither a timeout nor a mistaken call +is permission to return the community to free admission. Recovery must retain +the accepted requirement and operation evidence. No fleet-wide drain or deployment +is an automatic fallback. + +Last-issuer removal is refused once the community requires NIP-FI, including +when the operator binding path requests `allow_last`. Bind a replacement before +removing an issuer, or use archive/lifecycle controls for containment. Removing +the issuer recorded by a receipt while another remains makes that receipt +`unavailable`; it never clears the requirement or destroys the receipt. +Bulk backfill does not recreate missing bindings for required communities; +its unbound report still exposes them for explicit operator repair. diff --git a/docs/operator-nip-fi-bindings.md b/docs/operator-nip-fi-bindings.md index afebc7de47..8afecc7a10 100644 --- a/docs/operator-nip-fi-bindings.md +++ b/docs/operator-nip-fi-bindings.md @@ -13,6 +13,12 @@ assertion audience. The relay refuses to start while a reserved host holds a binding, and the operator lookup route reports no issuers for a host that is not bindable or for an issuer that is not configured. +A community with durable `requires_nip_fi=true` always retains its last issuer. +The internal `allow_last` exception for unbindable hosts cannot override this +policy. Bind a replacement first, or archive/delete the community for containment. +Bulk backfill skips required communities whose bindings are missing; its closing +report still counts them as unbound. Repair those bindings with an explicit bind. + ## Configuration `BUZZ_NIP_FI_PROVISION_BINDING` is read in every NIP-FI mode. Unset or empty diff --git a/migrations/0082_community_auth_upgrades.sql b/migrations/0082_community_auth_upgrades.sql new file mode 100644 index 0000000000..aa3a2c63bc --- /dev/null +++ b/migrations/0082_community_auth_upgrades.sql @@ -0,0 +1,24 @@ +SET LOCAL lock_timeout = '5s'; +SET LOCAL statement_timeout = '30s'; + +ALTER TABLE communities + ADD COLUMN requires_nip_fi BOOLEAN NOT NULL DEFAULT false, + ADD COLUMN policy_generation BIGINT NOT NULL DEFAULT 0; + +-- Operator-global acceptance receipts. Retained across community deletion so +-- lost-response retries cannot reuse an old operation ID for a different target. +-- target_community_id is provenance, not tenant-owned data. No serving writer. +CREATE TABLE community_auth_upgrades ( + operation_id UUID PRIMARY KEY, + target_community_id UUID NOT NULL, + expected_host TEXT NOT NULL, + actor_pubkey TEXT NOT NULL, + expected_owner_pubkey TEXT NOT NULL, + expected_issuer TEXT NOT NULL, + operator_pubkey TEXT NOT NULL, + policy_generation BIGINT NOT NULL CHECK (policy_generation > 0), + accepted_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +INSERT INTO _operator_global_tables (table_name, reason) VALUES + ('community_auth_upgrades', 'operator acceptance receipts retained for idempotency after target deletion'); diff --git a/schema/schema.sql b/schema/schema.sql index 710345e527..84d06d7b40 100644 --- a/schema/schema.sql +++ b/schema/schema.sql @@ -217,3 +217,6 @@ CREATE EXTENSION IF NOT EXISTS pgcrypto; \i seeds/storage_accounting_snapshots.sql \i tables/public/nip_fi_profile_heads.sql + +\i tables/public/community_auth_upgrades.sql +\i seeds/community_auth_upgrades.sql diff --git a/schema/seeds/community_auth_upgrades.sql b/schema/seeds/community_auth_upgrades.sql new file mode 100644 index 0000000000..81065c2ec6 --- /dev/null +++ b/schema/seeds/community_auth_upgrades.sql @@ -0,0 +1,2 @@ +INSERT INTO _operator_global_tables (table_name, reason) VALUES + ('community_auth_upgrades', 'operator acceptance receipts retained for idempotency after target deletion'); diff --git a/schema/tables/public/communities.sql b/schema/tables/public/communities.sql index 7f9d6f0965..4c328109f6 100644 --- a/schema/tables/public/communities.sql +++ b/schema/tables/public/communities.sql @@ -16,6 +16,8 @@ CREATE TABLE communities ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), host VARCHAR(255) NOT NULL, signing_key BYTEA, + requires_nip_fi BOOLEAN NOT NULL DEFAULT false, + policy_generation BIGINT NOT NULL DEFAULT 0, -- Per-community workspace icon (NIP-11 `icon`), set via kind:9033. -- Added by migration 0003; kept here so desired-state applies match. icon TEXT, diff --git a/schema/tables/public/community_auth_upgrades.sql b/schema/tables/public/community_auth_upgrades.sql new file mode 100644 index 0000000000..cd4785adcc --- /dev/null +++ b/schema/tables/public/community_auth_upgrades.sql @@ -0,0 +1,14 @@ +-- Operator-global acceptance receipts. Retained across community deletion so +-- lost-response retries cannot reuse an old operation ID for a different target. +-- target_community_id is provenance, not tenant-owned data. No serving writer. +CREATE TABLE community_auth_upgrades ( + operation_id UUID PRIMARY KEY, + target_community_id UUID NOT NULL, + expected_host TEXT NOT NULL, + actor_pubkey TEXT NOT NULL, + expected_owner_pubkey TEXT NOT NULL, + expected_issuer TEXT NOT NULL, + operator_pubkey TEXT NOT NULL, + policy_generation BIGINT NOT NULL CHECK (policy_generation > 0), + accepted_at TIMESTAMPTZ NOT NULL DEFAULT now() +);