From 9c962c35ca3b43ab31efdd710587570c29e7f201 Mon Sep 17 00:00:00 2001 From: Brad Seiler Date: Thu, 5 Nov 2026 22:56:25 -0500 Subject: [PATCH 01/16] feat(admin): invoke community FI upgrades Add a one-shot signed operator command and a pod runbook for testing a connected client across a policy change. Co-authored-by: Honor Signed-off-by: Brad Seiler --- Cargo.lock | 5 + Justfile | 2 +- crates/buzz-admin/Cargo.toml | 5 + crates/buzz-admin/src/main.rs | 8 + crates/buzz-admin/src/nip_fi_upgrade.rs | 526 ++++++++++++++++++++++++ docs/operator-nip-fi-bindings.md | 4 + docs/operator-nip-fi-upgrade.md | 213 ++++++++++ 7 files changed, 762 insertions(+), 1 deletion(-) create mode 100644 crates/buzz-admin/src/nip_fi_upgrade.rs create mode 100644 docs/operator-nip-fi-upgrade.md diff --git a/Cargo.lock b/Cargo.lock index d19342367f9..ec6e1806599 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -969,6 +969,8 @@ name = "buzz-admin" version = "0.1.0" dependencies = [ "anyhow", + "axum", + "base64 0.22.1", "buzz-audit", "buzz-auth", "buzz-core", @@ -981,10 +983,13 @@ dependencies = [ "clap", "hex", "nostr 0.44.7", + "reqwest 0.13.4", "rustls", "serde", "serde_json", + "sha2 0.11.0", "sqlx", + "tempfile", "tokio", "tracing", "url", diff --git a/Justfile b/Justfile index 80a6a56af78..9170ebb053f 100644 --- a/Justfile +++ b/Justfile @@ -468,7 +468,7 @@ test-unit: # parser, validation, and propagation-evidence tests, and the partition # audit outcome contract, NIP-FI policy/backfill, and user-deletion intake. cargo nextest run -p buzz-admin \ - -E 'test(storage_snapshot) + test(/^communities::tests::/) + test(/^tests::communities_/) + test(=tests::partition_audit_outcomes_keep_their_json_names_and_exit_codes) + test(/^nip_fi::tests::/) + test(/^user_deletions::tests::/)' + -E 'test(storage_snapshot) + test(/^communities::tests::/) + test(/^tests::communities_/) + test(=tests::partition_audit_outcomes_keep_their_json_names_and_exit_codes) + test(/^nip_fi::tests::/) + test(/^nip_fi_upgrade::tests::/) + test(/^user_deletions::tests::/)' # Multi-tenant conformance gate (buzz-conformance): the independent # replay checker + golden fixtures. No infra — pure in-process trace # replay — so it belongs in the unit job. Run all targets (lib + the diff --git a/crates/buzz-admin/Cargo.toml b/crates/buzz-admin/Cargo.toml index 10354c2f922..4d38d25582b 100644 --- a/crates/buzz-admin/Cargo.toml +++ b/crates/buzz-admin/Cargo.toml @@ -27,6 +27,9 @@ serde_json = { workspace = true } serde = { workspace = true } anyhow = { workspace = true } hex = { workspace = true } +base64 = { workspace = true } +reqwest = { workspace = true } +sha2 = { workspace = true } # Redis TLS (rediss://, e.g. ElastiCache) uses rustls, which needs a process # CryptoProvider installed at startup. The workspace redis TLS feature compiles # both aws-lc-rs and ring in transitively, so rustls can't auto-select one — we @@ -39,4 +42,6 @@ uuid = { workspace = true } clap = { version = "4", features = ["derive"] } [dev-dependencies] +axum = { workspace = true } +tempfile = "3" tokio = { workspace = true, features = ["test-util"] } diff --git a/crates/buzz-admin/src/main.rs b/crates/buzz-admin/src/main.rs index 36af8df3386..992514cd0bb 100644 --- a/crates/buzz-admin/src/main.rs +++ b/crates/buzz-admin/src/main.rs @@ -22,6 +22,7 @@ mod communities; mod deletions; mod nip_fi; +mod nip_fi_upgrade; mod user_deletions; use std::future::Future; @@ -124,6 +125,8 @@ enum Command { #[command(subcommand)] command: nip_fi::NipFiCommand, }, + /// Require NIP-FI through the relay's existing signed operator API. + NipFiUpgrade(nip_fi_upgrade::Args), /// Durable CLI-only whole-community deletion control plane. Deletions { #[command(subcommand)] @@ -213,6 +216,11 @@ async fn run(cli: Cli) -> Result { Command::Deletions { command } => deletions::run(command).await, Command::UserDeletions { command } => user_deletions::run(command).await, Command::NipFi { command } => nip_fi::run(command).await, + // Retain transport/file causes for this operator command without changing + // the diagnostics of other admin commands. Key parse errors are redacted. + Command::NipFiUpgrade(args) => nip_fi_upgrade::run(args) + .await + .map_err(|error| anyhow::anyhow!("{error:#}")), Command::ReconcileChannels { channel, relay_key } => { reconcile_channels(channel, relay_key).await?; Ok(0) diff --git a/crates/buzz-admin/src/nip_fi_upgrade.rs b/crates/buzz-admin/src/nip_fi_upgrade.rs new file mode 100644 index 00000000000..be64a45749d --- /dev/null +++ b/crates/buzz-admin/src/nip_fi_upgrade.rs @@ -0,0 +1,526 @@ +//! One-shot calls to the existing operator upgrade API; no database or worker startup. + +use std::{io::Read, path::Path, path::PathBuf, time::Duration}; + +use anyhow::{ensure, Context, Result}; +use base64::{engine::general_purpose::STANDARD, Engine}; +use clap::{Args as ClapArgs, Subcommand}; +use nostr::{EventBuilder, JsonUtil, Keys, Kind, Tag}; +use reqwest::{redirect::Policy, Method}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use sha2::{Digest, Sha256}; +use url::Url; +use uuid::Uuid; + +const PATH: &str = "/operator/communities/nip-fi-upgrade"; +const MAX_BYTES: usize = 64 * 1024; + +/// Explicit operator destination and protected signing material. +#[derive(Debug, ClapArgs)] +pub struct Args { + /// Must match the relay's RELAY_OPERATOR_API_ORIGIN (HTTP for a trusted mesh, or HTTPS). + #[arg(long)] + origin: String, + /// File containing the authorized deployment operator's hex secret key or nsec. + #[arg(long)] + key_file: PathBuf, + #[command(subcommand)] + command: Command, +} + +#[derive(Debug, Subcommand)] +enum Command { + /// Accept or recover an upgrade using the exact saved request tuple. + Enable { + /// JSON with operation_id, community_id, expected_host, actor_pubkey, + /// expected_owner_pubkey and expected_issuer. Reuse after response loss. + #[arg(long)] + request: PathBuf, + }, + /// Read the original operator's durable receipt without sending a mutation. + Status { + #[arg(long)] + operation_id: Uuid, + }, +} + +#[derive(Debug, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +struct UpgradeRequest { + operation_id: Uuid, + community_id: Uuid, + expected_host: String, + actor_pubkey: String, + expected_owner_pubkey: String, + expected_issuer: String, +} + +impl UpgradeRequest { + fn validate(&self) -> Result<()> { + ensure!( + !self.operation_id.is_nil() && !self.community_id.is_nil(), + "UUIDs must not be nil" + ); + let host = crate::communities::normalize_host_authority(&self.expected_host) + .map_err(anyhow::Error::msg)?; + ensure!( + host == self.expected_host && !host.contains(':'), + "expected_host must be canonical lowercase without a port or trailing dot" + ); + for key in [&self.actor_pubkey, &self.expected_owner_pubkey] { + ensure!( + key.len() == 64 + && key + .bytes() + .all(|c| c.is_ascii_digit() || (b'a'..=b'f').contains(&c)), + "owner public keys must be 64 lowercase hex characters" + ); + } + Ok(()) + } +} + +fn read_bounded(path: &Path, limit: usize) -> Result> { + let mut bytes = Vec::new(); + std::fs::File::open(path) + .with_context(|| format!("cannot open {}", path.display()))? + .take((limit + 1) as u64) + .read_to_end(&mut bytes) + .with_context(|| format!("cannot read {}", path.display()))?; + ensure!(bytes.len() <= limit, "input file exceeds size limit"); + Ok(bytes) +} + +fn endpoint(origin: &str, operation: Option) -> Result { + let base = Url::parse(origin).context("invalid operator origin")?; + ensure!( + matches!(base.scheme(), "http" | "https") + && base.host_str().is_some() + && base.username().is_empty() + && base.password().is_none() + && base.path() == "/" + && base.query().is_none() + && base.fragment().is_none(), + "operator origin must be an HTTP(S) origin without credentials, path, query or fragment" + ); + let mut url = base.join(PATH)?; + if let Some(id) = operation { + ensure!(!id.is_nil(), "operation_id must not be nil"); + url.query_pairs_mut() + .append_pair("operation_id", &id.to_string()); + } + Ok(url) +} + +fn authorization(keys: &Keys, method: &Method, url: &Url, body: Option<&[u8]>) -> Result { + let mut tags = vec![ + Tag::parse(["u", url.as_str()])?, + Tag::parse(["method", method.as_str()])?, + Tag::parse(["nonce", &Uuid::new_v4().to_string()])?, + ]; + if let Some(body) = body { + tags.push(Tag::parse(["payload", &hex::encode(Sha256::digest(body))])?); + } + let event = EventBuilder::new(Kind::HttpAuth, "") + .tags(tags) + .sign_with_keys(keys)?; + Ok(format!("Nostr {}", STANDARD.encode(event.as_json()))) +} + +fn receipt_exit_code( + receipt: &Value, + operation_id: Uuid, + operator: &str, + request: Option<&UpgradeRequest>, +) -> Result { + ensure!( + receipt["operation_id"].as_str() == Some(operation_id.to_string().as_str()) + && receipt["operator_pubkey"].as_str() == Some(operator) + && receipt["requires_nip_fi"].as_bool() == Some(true) + && receipt["policy_generation"].as_i64().is_some_and(|g| g > 0), + "unconfirmed: response is not the expected durable acceptance receipt" + ); + if let Some(request) = request { + let expected = serde_json::to_value(request)?; + ensure!( + expected + .as_object() + .context("request must be an object")? + .iter() + .all(|(k, v)| receipt[k] == *v), + "unconfirmed: receipt tuple does not match the saved request" + ); + } + let reasons = receipt["pending_reasons"] + .as_array() + .context("unconfirmed: missing pending reasons")?; + ensure!( + reasons.iter().all(Value::is_string), + "unconfirmed: invalid pending reasons" + ); + match receipt["state"].as_str() { + Some("active") if reasons.is_empty() => Ok(0), + Some("pending") => Ok(10), + Some("unavailable") => Ok(12), + _ => anyhow::bail!("unconfirmed: invalid upgrade state"), + } +} + +/// Send exactly one signed request. A timeout never means the upgrade was rejected. +pub async fn run(args: Args) -> Result { + let request = match &args.command { + Command::Enable { request } => { + let request: UpgradeRequest = + serde_json::from_slice(&read_bounded(request, MAX_BYTES)?)?; + request.validate()?; + Some(request) + } + Command::Status { .. } => None, + }; + let operation_id = match &args.command { + Command::Enable { .. } => request.as_ref().context("missing request")?.operation_id, + Command::Status { operation_id } => *operation_id, + }; + let url = endpoint(&args.origin, request.is_none().then_some(operation_id))?; + // Do not include the secret or its parse error in diagnostics. + let secret = read_bounded(&args.key_file, 1024)?; + let keys = std::str::from_utf8(&secret) + .ok() + .and_then(|s| Keys::parse(s.trim()).ok()) + .context("invalid operator key file")?; + let body = request.as_ref().map(serde_json::to_vec).transpose()?; + let method = if body.is_some() { + Method::POST + } else { + Method::GET + }; + let auth = authorization(&keys, &method, &url, body.as_deref())?; + let client = reqwest::Client::builder() + .redirect(Policy::none()) + .retry(reqwest::retry::never()) + .timeout(Duration::from_secs(30)) + .build()?; + let mut call = client.request(method, url).header("Authorization", auth); + if let Some(body) = body { + call = call.header("Content-Type", "application/json").body(body); + } + let mut response = call + .send() + .await + .context("unconfirmed: request failed; recover the same operation_id")?; + let status = response.status(); + let mut bytes = Vec::new(); + while let Some(chunk) = response + .chunk() + .await + .context("unconfirmed: response interrupted; recover the same operation_id")? + { + ensure!( + bytes.len() + chunk.len() <= MAX_BYTES, + "unconfirmed: response exceeds size limit" + ); + bytes.extend_from_slice(&chunk); + } + if !status.is_success() { + // Preserve known machine-readable codes, without echoing arbitrary response bodies. + let error = serde_json::from_slice::(&bytes).ok(); + let code = error.as_ref().and_then(|value| value["code"].as_str()); + let detail = match code { + Some( + code @ ("invalid_request" + | "community_not_found" + | "operation_not_found" + | "idempotency_conflict" + | "host_conflict" + | "owner_conflict" + | "lifecycle_conflict" + | "issuer_conflict" + | "upgrades_disabled" + | "authorization_unavailable"), + ) => code, + _ if error.is_none() => "non-JSON error response", + _ => "unrecognized error code", + }; + anyhow::bail!("operator API returned HTTP {} ({detail}); recover the same operation_id after an uncertain enable", status.as_u16()); + } + // Print only structured relay output, never transport headers or signing material. + let receipt: Value = serde_json::from_slice(&bytes).with_context(|| { + format!( + "unconfirmed: invalid JSON response (HTTP {})", + status.as_u16() + ) + })?; + let code = receipt_exit_code( + &receipt, + operation_id, + &keys.public_key().to_hex(), + request.as_ref(), + )?; + println!("{}", serde_json::to_string(&receipt)?); + Ok(code) +} + +#[cfg(test)] +mod tests { + use super::*; + use axum::{body::Bytes, http::HeaderMap, routing::any, Json, Router}; + use tokio::sync::mpsc; + + fn tuple() -> UpgradeRequest { + let owner = Keys::generate().public_key().to_hex(); + UpgradeRequest { + operation_id: Uuid::new_v4(), + community_id: Uuid::new_v4(), + expected_host: "test.communities.example.org".into(), + actor_pubkey: owner.clone(), + expected_owner_pubkey: owner, + expected_issuer: "https://issuer.example.org".into(), + } + } + + #[tokio::test] + async fn refuses_noncanonical_inputs_before_reading_key_or_sending_post() { + for (field, value) in [ + ("expected_host", "TEST.communities.example.org"), + ("expected_host", "test.communities.example.org."), + ("expected_host", "test.communities.example.org:443"), + ("expected_host", "https://test.communities.example.org"), + ("actor_pubkey", &"A".repeat(64)), + ("expected_owner_pubkey", &"A".repeat(64)), + ("actor_pubkey", &"a".repeat(63)), + ("expected_owner_pubkey", &"g".repeat(64)), + ] { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("request.json"); + let mut input = serde_json::to_value(tuple()).unwrap(); + input[field] = value.into(); + std::fs::write(&path, serde_json::to_vec(&input).unwrap()).unwrap(); + let error = run(Args { + origin: "http://127.0.0.1:1".into(), + key_file: dir.path().join("absent-key"), + command: Command::Enable { request: path }, + }) + .await + .unwrap_err(); + let message = format!("{error:#}"); + assert!( + message.contains("host") || message.contains("public keys"), + "{message}" + ); + assert!( + !message.contains("absent-key"), + "key read preceded validation: {message}" + ); + } + } + + #[tokio::test] + async fn error_responses_preserve_status_and_known_codes_without_echoing_bodies() { + use axum::http::StatusCode; + + for (status, body, expected) in [ + ( + 409, + r#"{"code":"owner_conflict","error":"private detail"}"#, + "owner_conflict", + ), + (409, r#"{"code":"issuer_conflict"}"#, "issuer_conflict"), + (409, r#"{"code":"host_conflict"}"#, "host_conflict"), + ( + 409, + r#"{"code":"idempotency_conflict"}"#, + "idempotency_conflict", + ), + (503, r#"{"code":"upgrades_disabled"}"#, "upgrades_disabled"), + ( + 502, + "private detail", + "non-JSON error response", + ), + ( + 409, + r#"{"code":"private detail"}"#, + "unrecognized error code", + ), + ] { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let origin = format!("http://{}", listener.local_addr().unwrap()); + let app = Router::new().route( + PATH, + any(move || async move { (StatusCode::from_u16(status).unwrap(), body) }), + ); + let server = tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); + let dir = tempfile::tempdir().unwrap(); + let key_file = dir.path().join("key"); + let request_file = dir.path().join("request.json"); + let keys = Keys::generate(); + std::fs::write(&key_file, keys.secret_key().display_secret().to_string()).unwrap(); + std::fs::write(&request_file, serde_json::to_vec(&tuple()).unwrap()).unwrap(); + let error = run(Args { + origin, + key_file, + command: Command::Enable { + request: request_file, + }, + }) + .await + .unwrap_err(); + let message = format!("{error:#}"); + assert!(message.contains(&format!("HTTP {status}")), "{message}"); + assert!(message.contains(expected), "{message}"); + assert!(!message.contains("private detail"), "{message}"); + assert!(!message.contains(&keys.secret_key().display_secret().to_string())); + server.abort(); + } + } + + fn receipt(request: &UpgradeRequest, keys: &Keys, state: &str) -> Value { + let mut value = serde_json::to_value(request).unwrap(); + value["operator_pubkey"] = keys.public_key().to_hex().into(); + value["policy_generation"] = 1.into(); + value["requires_nip_fi"] = true.into(); + value["state"] = state.into(); + value["pending_reasons"] = serde_json::json!([]); + value + } + + #[tokio::test] + async fn command_signs_exact_post_body_and_get_query_and_preserves_operation() { + let keys = Keys::generate(); + let tuple = tuple(); + let response = receipt(&tuple, &keys, "pending"); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let origin = format!("http://{}", listener.local_addr().unwrap()); + let (sender, mut requests) = mpsc::channel(4); + let app = Router::new().route( + PATH, + any( + move |method: Method, uri: axum::http::Uri, headers: HeaderMap, body: Bytes| { + let response = response.clone(); + let sender = sender.clone(); + async move { + sender.send((method, uri, headers, body)).await.unwrap(); + Json(response) + } + }, + ), + ); + let server = tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); + let dir = tempfile::tempdir().unwrap(); + let key_file = dir.path().join("key"); + let request_file = dir.path().join("request.json"); + std::fs::write(&key_file, keys.secret_key().display_secret().to_string()).unwrap(); + std::fs::write(&request_file, serde_json::to_vec(&tuple).unwrap()).unwrap(); + + for command in [ + Command::Enable { + request: request_file, + }, + Command::Status { + operation_id: tuple.operation_id, + }, + ] { + assert_eq!( + run(Args { + origin: origin.clone(), + key_file: key_file.clone(), + command + }) + .await + .unwrap(), + 10 + ); + let (method, uri, headers, body) = requests.recv().await.unwrap(); + let encoded = headers["Authorization"] + .to_str() + .unwrap() + .strip_prefix("Nostr ") + .unwrap(); + let event = String::from_utf8(STANDARD.decode(encoded).unwrap()).unwrap(); + let url = format!("{origin}{uri}"); + let pubkey = buzz_auth::verify_nip98_event( + &event, + &url, + method.as_str(), + (method == Method::POST).then_some(body.as_ref()), + ) + .unwrap(); + assert_eq!(pubkey, keys.public_key()); + if method == Method::POST { + assert_eq!( + serde_json::from_slice::(&body).unwrap(), + serde_json::to_value(&tuple).unwrap() + ); + assert!( + buzz_auth::verify_nip98_event(&event, &url, "POST", Some(b"changed")).is_err() + ); + } else { + assert_eq!( + uri.query(), + Some(format!("operation_id={}", tuple.operation_id).as_str()) + ); + assert!(buzz_auth::verify_nip98_event( + &event, + &format!("{origin}{PATH}"), + "GET", + None + ) + .is_err()); + } + } + assert!(requests.try_recv().is_err()); + server.abort(); + } + + #[test] + fn only_matching_required_receipts_can_report_success() { + let keys = Keys::generate(); + let request = tuple(); + let operator = keys.public_key().to_hex(); + for (state, code) in [("active", 0), ("pending", 10), ("unavailable", 12)] { + assert_eq!( + receipt_exit_code( + &receipt(&request, &keys, state), + request.operation_id, + &operator, + Some(&request) + ) + .unwrap(), + code + ); + } + for (field, bad) in [ + ("operation_id", Value::from(Uuid::new_v4().to_string())), + ( + "operator_pubkey", + Value::from(Keys::generate().public_key().to_hex()), + ), + ("community_id", Value::from(Uuid::new_v4().to_string())), + ("requires_nip_fi", Value::from(false)), + ("policy_generation", Value::from(0)), + ("state", Value::from("unknown")), + ("pending_reasons", serde_json::json!(["not_drained"])), + ] { + let mut value = receipt(&request, &keys, "active"); + value[field] = bad; + assert!( + receipt_exit_code(&value, request.operation_id, &operator, Some(&request)).is_err(), + "accepted invalid {field}" + ); + } + } + + #[test] + fn origin_cannot_select_a_different_path_or_embed_credentials() { + for origin in [ + "https://user:password@example.org", + "https://example.org/wrong", + "https://example.org/?q=x", + "https://example.org/#fragment", + "file:///tmp/key", + ] { + assert!(endpoint(origin, None).is_err(), "accepted {origin}"); + } + } +} diff --git a/docs/operator-nip-fi-bindings.md b/docs/operator-nip-fi-bindings.md index 8afecc7a103..4bc0b500c0f 100644 --- a/docs/operator-nip-fi-bindings.md +++ b/docs/operator-nip-fi-bindings.md @@ -5,6 +5,10 @@ A deployment that provisions communities under one host suffix, for example each community in `community_nip_fi_issuers`. The relay writes these rows when it creates a community; `buzz-admin nip-fi` backfills and edits them. +To make an existing community require federated sign-in, use the separate +[community upgrade command and manual test](operator-nip-fi-upgrade.md). +An issuer binding alone does not change that community's required policy. + A host may hold a binding only when it is exactly one DNS label under the suffix, has no port, and is not *reserved*. A host is reserved when `https://` is a `command_audiences` entry of any issuer in diff --git a/docs/operator-nip-fi-upgrade.md b/docs/operator-nip-fi-upgrade.md new file mode 100644 index 00000000000..40fb517e75d --- /dev/null +++ b/docs/operator-nip-fi-upgrade.md @@ -0,0 +1,213 @@ +# Require NIP-FI on an existing community + +`buzz-admin nip-fi-upgrade` makes one signed HTTP call to the existing relay +operator API. It needs no database access, application startup, sponsorship or +billing workflow. `enable` accepts the saved upgrade tuple; `status` reads its +durable receipt. Both require an authorized deployment operator key. +The [upgrade API contract](community-auth-upgrades.md) defines the receipt and +policy semantics used below. + +This command is a trusted operator surface. The operator must independently +authenticate the community owner and verify the owner's registered public key +before putting it in `actor_pubkey`. A caller-supplied public key is not proof +of the owner's identity. The relay still checks current ownership, community +identity, issuer trust and the upgrade admission gate. This command does not +replace an application's owner authentication or admission policy. + +## Before the test + +Use a disposable staging community whose policy is not yet required. Verify its +UUID, canonical lowercase host, current owner and active issuer binding against +authoritative records. Use the current owner's key for both public-key fields. +The issuer must match the relay's provisioning configuration and active trust. + +Verify `BUZZ_NIP_FI_MODE=shadow` on **every serving replica** before connecting +the test client. Shadow observes optional communities and enforces their stored +required policy after an upgrade. `off` ignores stored requirements; `enforce` +already requires sign-in everywhere. Either mode invalidates this transition +test. Keep shadow mode unchanged throughout the test and record it in evidence. + +All serving relay replicas must support persisted required policy and closing +existing sessions after an upgrade. Apply their required migrations and finish +the rollout before the test. The deployment must already have authorized +`BUZZ_NIP_FI_UPGRADES_ENABLED=true`; this command does not change that gate. +Only the deployment's authorized operator may enable that gate, after verifying +the fleet prerequisites in the API contract. Record that operator's approval. +Use a client that supports required-policy discovery and federated sign-in. + +Identify the configured `RELAY_OPERATOR_API_ORIGIN` and a key in +`RELAY_OPERATOR_PUBKEYS`. The origin is the operator API origin, not the target +community URL. Use HTTPS outside a trusted internal mesh. Provision the key as +a protected file/Secret through the deployment's existing secret process; never +paste it into arguments, logs or a committed manifest. Do not borrow a client +or managed agent key. + +## Save the operation, then leave the client connected + +Save `request.json` with one newly generated operation UUID and the verified +values. Keep this file unchanged for retries. The command refuses noncanonical +hosts or public keys before sending a POST. These are example values only: + +```json +{ + "operation_id": "deecfc7a-9ec2-4ec5-b15b-9e567ad59740", + "community_id": "00fb4822-7930-40fc-9eed-315926f83ca7", + "expected_host": "test.communities.example.org", + "actor_pubkey": "", + "expected_owner_pubkey": "", + "expected_issuer": "https://issuer.example.org" +} +``` + +Open the test community in an existing client and confirm it can read and send +a message without federated sign-in. Keep that session connected. Record the +client version, relay image digests and time, plus the community's `/info` +response showing `requires_nip_fi: false`. Do not sign out, restart the client, +or change relay configuration during the acceptance test. + +## Run one pod + +Use a relay image containing this command, pinned to its verified digest. The +normal relay image includes `/usr/local/bin/buzz-admin`. Check the command's +`--help` before the test if using a previously built image. + +Select the staging context and namespace explicitly. Create the input ConfigMap +from the saved request, and use an existing Secret with a `key` entry: + +```sh +kubectl --context "$STAGING_CONTEXT" -n "$NAMESPACE" create configmap nip-fi-upgrade-input \ + --from-file=request.json +``` + +Replace the image, origin and Secret name below with reviewed deployment +values. Save as `upgrade-pod.yaml`. This is a single Pod, with no Job controller, +retry policy or application/service labels. It mounts only the input and key. +Use the deployment's approved network/mesh settings if its operator API needs +them; confirm the pod can reach that API without adding it to serving workloads. +If a mesh sidecar is injected, use the platform's approved one-shot completion +mechanism (for example, native sidecars). An ordinary sidecar can keep this Pod +running after the command exits; retain the upgrade container's exit status and +logs, and arrange sidecar shutdown without weakening required network policy. + +```yaml +apiVersion: v1 +kind: Pod +metadata: + name: nip-fi-upgrade-enable +spec: + restartPolicy: Never + activeDeadlineSeconds: 60 + automountServiceAccountToken: false + securityContext: + runAsNonRoot: true + runAsUser: 1000 + runAsGroup: 1000 + fsGroup: 1000 + containers: + - name: upgrade + image: ghcr.io/block/buzz@sha256: + command: [/usr/local/bin/buzz-admin] + args: + - nip-fi-upgrade + - --origin + - https://operator.example.org + - --key-file + - /operator/key + - enable + - --request + - /input/request.json + volumeMounts: + - {name: operator, mountPath: /operator, readOnly: true} + - {name: input, mountPath: /input, readOnly: true} + volumes: + - name: operator + secret: + secretName: reviewed-upgrade-operator + defaultMode: 0440 + - name: input + configMap: + name: nip-fi-upgrade-input +``` + +```sh +kubectl --context "$STAGING_CONTEXT" -n "$NAMESPACE" create -f upgrade-pod.yaml +kubectl --context "$STAGING_CONTEXT" -n "$NAMESPACE" logs -f pod/nip-fi-upgrade-enable -c upgrade +kubectl --context "$STAGING_CONTEXT" -n "$NAMESPACE" get pod nip-fi-upgrade-enable -o json +``` + +The last command records the pod UID, observed image ID and container exit code. +Do not use Pod phase alone: the expected accepted outcome in this relay version +is exit **10**, which Kubernetes labels Failed. This is acceptance, not a command +failure or a certified fleet-wide connection drain. + +| Exit | Meaning | +| --- | --- | +| 10 | Expected success: valid matching receipt, `requires_nip_fi: true`, state `pending` | +| 12 | Valid acceptance receipt, target/trust currently `unavailable` | +| 5 | Request error or unconfirmed result; acceptance may already have happened | + +This relay version never reports `active`. The command reserves exit 0 for a +future valid `active` receipt with no pending reasons; do not wait for it here. +`connection_closure_unverified` is expected even after successful socket sweeps: +the receipt does not certify fleet-wide closure. Continue with the client test. +Retain and investigate other/unknown pending reasons; they remain unverified. + +Each HTTP call has a 30-second timeout, no automatic retry and no redirects. +The command verifies the returned operation, operator, required policy and +positive generation. For enable it also verifies every saved tuple field. + +## Confirm policy and recover the same operation + +Create another pod from the same manifest with a new pod name and replace the +`enable --request /input/request.json` arguments with: + +```yaml + - status + - --operation-id + - deecfc7a-9ec2-4ec5-b15b-9e567ad59740 +``` + +Use the actual saved operation UUID, same API origin and same operator key. +Compare the receipt's complete tuple and generation with the saved evidence. +A successful receipt must report `requires_nip_fi: true`. Also read the target +community's `/info` and confirm it advertises required policy. + +After timeout, response loss or pod termination, status may recover an accepted +receipt. If it cannot confirm acceptance, repeat enable with the **same saved +request** and a new pod name. Never create a new operation UUID to escape an +uncertain result. `pending` and `unavailable` do not undo required policy; retain +their state and reasons. A pending receipt with `connection_closure_unverified` +does not prevent the manual client test from passing on observed evidence. +An unavailable target or unknown obligations require investigation. +Turning off admission, deleting the pod or rolling back the command cannot undo +an accepted policy change. + +## Observe the existing client + +Record `BUZZ_COMMUNITY_REVALIDATE_INTERVAL_SECS` on every serving replica (default +30 seconds, clamped to 1–300). Before the test, choose and record a finite client +reconnect/sign-in budget from that client's configured reconnect behavior. Wait +at least the largest replica interval plus that client budget after acceptance. +Record the resulting observation deadline. If sign-in has not appeared by then, +record the test as failed or blocked with logs; do not wait indefinitely. + +1. After the upgrade, verify the already-connected client demands federated + sign-in before continuing to read or send in the community. Let the client + handle the disconnect/reconnect. Do not manually sign out or restart it. +2. Complete sign-in with the test user's allowed issuer, then verify reading and + sending work again in that same community. +3. Record a failure if access continues without sign-in, sign-in never appears, + or access does not resume. A changed `/info` response alone is not a pass. + +Record the exact invocation/manifest, saved request, operator public key, +community UUID/host, pod UID/observed image ID, all serving relay versions and +shadow modes, revalidator intervals, client reconnect budget/deadline, +client version, pre/post `/info`, receipt/generation/state, timestamps and observed +client behavior. Keep secret key bytes and authorization headers out of evidence. +Mark unexecuted steps as **not run**, and keep policy confirmation separate from +the client result. Export logs and pod metadata before deleting the temporary +pods and input ConfigMap. Use a fresh staging community for another first-upgrade +test; do not clear persisted policy to reset this one. +For a mistaken acceptance, stop the caller and follow the API contract's +containment procedure. Archive or isolate the affected community through the +deployment's authorized operator process; there is no policy rollback command. From 019ec5510ee9ceaf739465b93788faec0839d915 Mon Sep 17 00:00:00 2001 From: Brad Seiler Date: Fri, 6 Nov 2026 11:03:39 -0500 Subject: [PATCH 02/16] Manage community NIP-FI policy with fenced enable and disable Co-authored-by: Honor Signed-off-by: Brad Seiler --- Justfile | 2 +- crates/buzz-admin/src/main.rs | 6 + crates/buzz-admin/src/nip_fi_policy.rs | 368 ++++++++++++ crates/buzz-admin/src/nip_fi_upgrade.rs | 48 +- crates/buzz-db/src/runtime/migration.rs | 41 +- crates/buzz-db/src/store/community_auth.rs | 224 ++++++-- .../store/community_auth/postgres_tests.rs | 304 ++++++++++ crates/buzz-relay/src/api/operator.rs | 9 +- .../src/api/operator/community_policy.rs | 216 +++++++ .../operator/postgres_tests/policy_tests.rs | 212 +++++++ crates/buzz-relay/src/router.rs | 5 + docs/community-auth-upgrades.md | 534 ++++-------------- docs/operator-nip-fi-bindings.md | 2 +- docs/operator-nip-fi-upgrade.md | 260 ++------- migrations/0084_community_auth_policy.sql | 8 + .../tables/public/community_auth_upgrades.sql | 3 + 16 files changed, 1562 insertions(+), 680 deletions(-) create mode 100644 crates/buzz-admin/src/nip_fi_policy.rs create mode 100644 crates/buzz-relay/src/api/operator/community_policy.rs create mode 100644 crates/buzz-relay/src/api/operator/postgres_tests/policy_tests.rs create mode 100644 migrations/0084_community_auth_policy.sql diff --git a/Justfile b/Justfile index c87f04e5998..f96c8477854 100644 --- a/Justfile +++ b/Justfile @@ -468,7 +468,7 @@ test-unit: # parser, validation, and propagation-evidence tests, and the partition # audit outcome contract, NIP-FI policy/backfill, and user-deletion intake. cargo nextest run -p buzz-admin \ - -E 'test(storage_snapshot) + test(/^communities::tests::/) + test(/^tests::communities_/) + test(=tests::partition_audit_outcomes_keep_their_json_names_and_exit_codes) + test(/^nip_fi::tests::/) + test(/^nip_fi_upgrade::tests::/) + test(/^user_deletions::tests::/)' + -E 'test(storage_snapshot) + test(/^communities::tests::/) + test(/^tests::communities_/) + test(=tests::partition_audit_outcomes_keep_their_json_names_and_exit_codes) + test(/^nip_fi::tests::/) + test(/^nip_fi_upgrade::tests::/) + test(/^nip_fi_policy::tests::/) + test(/^user_deletions::tests::/)' # Multi-tenant conformance gate (buzz-conformance): the independent # replay checker + golden fixtures. No infra — pure in-process trace # replay — so it belongs in the unit job. Run all targets (lib + the diff --git a/crates/buzz-admin/src/main.rs b/crates/buzz-admin/src/main.rs index 992514cd0bb..52499781300 100644 --- a/crates/buzz-admin/src/main.rs +++ b/crates/buzz-admin/src/main.rs @@ -22,6 +22,7 @@ mod communities; mod deletions; mod nip_fi; +mod nip_fi_policy; mod nip_fi_upgrade; mod user_deletions; @@ -127,6 +128,8 @@ enum Command { }, /// Require NIP-FI through the relay's existing signed operator API. NipFiUpgrade(nip_fi_upgrade::Args), + /// Change or inspect a community stored NIP-FI requirement. + NipFiPolicy(nip_fi_policy::Args), /// Durable CLI-only whole-community deletion control plane. Deletions { #[command(subcommand)] @@ -218,6 +221,9 @@ async fn run(cli: Cli) -> Result { Command::NipFi { command } => nip_fi::run(command).await, // Retain transport/file causes for this operator command without changing // the diagnostics of other admin commands. Key parse errors are redacted. + Command::NipFiPolicy(args) => nip_fi_policy::run(args) + .await + .map_err(|e| anyhow::anyhow!("{e:#}")), Command::NipFiUpgrade(args) => nip_fi_upgrade::run(args) .await .map_err(|error| anyhow::anyhow!("{error:#}")), diff --git a/crates/buzz-admin/src/nip_fi_policy.rs b/crates/buzz-admin/src/nip_fi_policy.rs new file mode 100644 index 00000000000..21131c6209e --- /dev/null +++ b/crates/buzz-admin/src/nip_fi_policy.rs @@ -0,0 +1,368 @@ +//! General stored community policy management with immutable saved intent. +use std::path::PathBuf; + +use anyhow::{ensure, Context, Result}; +use clap::{Args as ClapArgs, Subcommand}; +use nostr::Keys; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use uuid::Uuid; + +use crate::nip_fi_upgrade::{endpoint, read_bounded, signed_call, UpgradeRequest}; + +/// Explicit operator destination and signing key for community policy management. +#[derive(Debug, ClapArgs)] +pub struct Args { + #[arg(long)] + origin: String, + #[arg(long)] + key_file: PathBuf, + #[command(subcommand)] + command: Command, +} + +#[derive(Debug, Subcommand)] +enum Command { + /// Apply a saved requires_nip_fi=true intent, or recover its acceptance. + Enable { + #[arg(long)] + request: PathBuf, + }, + /// Apply a saved requires_nip_fi=false intent, or recover its acceptance. + Disable { + #[arg(long)] + request: PathBuf, + }, + /// Read current preconditions by community/host, or a historical operation. + Status { + #[arg(long, conflicts_with_all = ["community_id", "host"])] + operation_id: Option, + #[arg(long, requires = "host")] + community_id: Option, + #[arg(long, requires = "community_id")] + host: Option, + }, +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct Request { + operation_id: Uuid, + community_id: Uuid, + expected_host: String, + actor_pubkey: String, + expected_owner_pubkey: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + expected_issuer: Option, + requires_nip_fi: bool, + expected_policy_generation: i64, + expected_requires_nip_fi: bool, +} + +impl Request { + fn validate(&self, desired: bool) -> Result<()> { + ensure!( + self.requires_nip_fi == desired, + "saved requires_nip_fi does not match the command" + ); + ensure!( + self.expected_policy_generation >= 0, + "generation must be nonnegative" + ); + ensure!( + if desired { + self.expected_issuer.as_ref().is_some_and(|s| !s.is_empty()) + } else { + self.expected_issuer.is_none() + }, + "enable requires expected_issuer; disable must omit it" + ); + UpgradeRequest { + operation_id: self.operation_id, + community_id: self.community_id, + expected_host: self.expected_host.clone(), + actor_pubkey: self.actor_pubkey.clone(), + expected_owner_pubkey: self.expected_owner_pubkey.clone(), + expected_issuer: self.expected_issuer.clone().unwrap_or_default(), + } + .validate() + } +} + +fn receipt_exit_code( + value: &Value, + id: Uuid, + operator: &str, + request: Option<&Request>, +) -> Result { + let receipt = &value["receipt"]; + ensure!( + receipt["operation_id"].as_str() == Some(id.to_string().as_str()) + && receipt["operator_pubkey"].as_str() == Some(operator) + && receipt["policy_generation"].as_i64().is_some_and(|g| g > 0) + && receipt["requires_nip_fi"].is_boolean(), + "unconfirmed: invalid policy receipt" + ); + if let Some(request) = request { + let expected = serde_json::to_value(request)?; + ensure!( + expected + .as_object() + .context("invalid saved request")? + .iter() + .all(|(key, v)| receipt[key] == *v), + "unconfirmed: receipt differs from saved intent" + ); + } + match ( + receipt["state"].as_str(), + receipt["requires_nip_fi"].as_bool(), + ) { + (Some("applied"), Some(false)) => Ok(0), + (Some("pending"), Some(true)) => Ok(10), + (Some("superseded"), _) => Ok(11), + (Some("unavailable"), _) => Ok(12), + _ => anyhow::bail!("unconfirmed: invalid policy state"), + } +} + +/// Execute one signed policy call; never change or automatically retry saved intent. +pub async fn run(args: Args) -> Result { + let request = match &args.command { + Command::Enable { request } | Command::Disable { request } => { + let saved: Request = serde_json::from_slice(&read_bounded(request, 64 * 1024)?)?; + saved.validate(matches!(args.command, Command::Enable { .. }))?; + Some(saved) + } + Command::Status { .. } => None, + }; + let mut url = endpoint(&args.origin, None)?; + url.set_path("/operator/communities/nip-fi-policy"); + if let Command::Status { + operation_id, + community_id, + host, + } = &args.command + { + match (operation_id, community_id, host) { + (Some(id), None, None) if !id.is_nil() => { + url.query_pairs_mut() + .append_pair("operation_id", &id.to_string()); + } + (None, Some(id), Some(host)) if !id.is_nil() => { + let canonical = crate::communities::normalize_host_authority(host) + .map_err(anyhow::Error::msg)?; + ensure!( + canonical == *host && !host.contains(':'), + "host must be canonical lowercase without a port" + ); + url.query_pairs_mut() + .append_pair("community_id", &id.to_string()) + .append_pair("expected_host", host); + } + _ => anyhow::bail!("status requires operation-id or community-id and host"), + } + } + // Validate the entire saved intent, including action, before touching the key. + let secret = read_bounded(&args.key_file, 1024)?; + let keys = std::str::from_utf8(&secret) + .ok() + .and_then(|s| Keys::parse(s.trim()).ok()) + .context("invalid operator key file")?; + let value = signed_call( + &keys, + url, + request.as_ref().map(serde_json::to_vec).transpose()?, + ) + .await?; + let code = if let Some(saved) = request.as_ref() { + receipt_exit_code( + &value, + saved.operation_id, + &keys.public_key().to_hex(), + Some(saved), + )? + } else if let Command::Status { + operation_id: Some(id), + .. + } = args.command + { + receipt_exit_code(&value, id, &keys.public_key().to_hex(), None)? + } else { + if let Command::Status { + community_id: Some(id), + host: Some(host), + .. + } = &args.command + { + ensure!( + value["community_id"].as_str() == Some(id.to_string().as_str()) + && value["expected_host"].as_str() == Some(host.as_str()) + && value["expected_policy_generation"] + .as_i64() + .is_some_and(|g| g >= 0) + && value["expected_requires_nip_fi"].is_boolean(), + "unconfirmed: invalid current policy" + ); + } + 0 + }; + let stored = value + .get("receipt") + .map_or(&value["requires_nip_fi"], |r| &r["current_requires_nip_fi"]); + if stored.is_boolean() + && value["effective_required"].is_boolean() + && *stored != value["effective_required"] + { + eprintln!("Global mode makes effective_required differ from the stored requirement; inspect mode in the response."); + } + println!("{}", serde_json::to_string(&value)?); + Ok(code) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn saved(desired: bool) -> Request { + Request { + operation_id: Uuid::new_v4(), + community_id: Uuid::new_v4(), + expected_host: "team.communities.example.org".into(), + actor_pubkey: "a".repeat(64), + expected_owner_pubkey: "a".repeat(64), + expected_issuer: desired.then(|| "https://issuer.example.org".into()), + requires_nip_fi: desired, + expected_policy_generation: 0, + expected_requires_nip_fi: false, + } + } + + #[tokio::test] + async fn immutable_action_and_preconditions_are_checked_before_key_read() { + let dir = tempfile::tempdir().unwrap(); + let file = dir.path().join("request.json"); + for (mut request, change, expected) in [ + (saved(true), 0, "does not match"), + (saved(false), 1, "generation"), + (saved(false), 2, "omit"), + (saved(false), 3, "canonical"), + ] { + match change { + 1 => request.expected_policy_generation = -1, + 2 => request.expected_issuer = Some("https://issuer.example.org".into()), + 3 => request.expected_host = "TEAM.communities.example.org".into(), + _ => (), + } + std::fs::write(&file, serde_json::to_vec(&request).unwrap()).unwrap(); + let error = run(Args { + origin: "http://127.0.0.1:1".into(), + key_file: dir.path().join("absent-key"), + command: Command::Disable { + request: file.clone(), + }, + }) + .await + .unwrap_err(); + assert!(error.to_string().contains(expected), "{error:#}"); + } + } + + #[tokio::test] + async fn policy_commands_send_saved_action_and_recover_current_and_history() { + use axum::{ + body::Bytes, + extract::RawQuery, + http::{HeaderMap, Method}, + routing::any, + Json, Router, + }; + let keys = Keys::generate(); + let dir = tempfile::tempdir().unwrap(); + let key_file = dir.path().join("key"); + let file = dir.path().join("request.json"); + std::fs::write(&key_file, keys.secret_key().display_secret().to_string()).unwrap(); + for desired in [true, false] { + let saved = saved(desired); + let id = saved.operation_id; + let community_id = saved.community_id; + let host = saved.expected_host.clone(); + let mut receipt = serde_json::to_value(&saved).unwrap(); + receipt["operator_pubkey"] = keys.public_key().to_hex().into(); + receipt["policy_generation"] = 1.into(); + receipt["current_requires_nip_fi"] = desired.into(); + receipt["state"] = if desired { "pending" } else { "applied" }.into(); + let response = serde_json::json!({"receipt": receipt, "mode": "shadow", "effective_required": desired}); + std::fs::write(&file, serde_json::to_vec(&saved).unwrap()).unwrap(); + let expected_body = serde_json::to_value(&saved).unwrap(); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let origin = format!("http://{}", listener.local_addr().unwrap()); + let app = Router::new().route("/operator/communities/nip-fi-policy", any( + move |method: Method, RawQuery(query): RawQuery, headers: HeaderMap, body: Bytes| { + let response = response.clone(); let expected_body = expected_body.clone(); let host = host.clone(); + async move { + assert!(headers["authorization"].to_str().unwrap().starts_with("Nostr ")); + if method == Method::POST { + assert_eq!(serde_json::from_slice::(&body).unwrap(), expected_body); + } else if query.as_ref().unwrap().starts_with("community_id=") { + return Json(serde_json::json!({"community_id":community_id,"expected_host":host, + "expected_policy_generation":1,"expected_requires_nip_fi":desired, + "requires_nip_fi":desired,"mode":"shadow","effective_required":desired})); + } else { assert_eq!(query, Some(format!("operation_id={id}"))); } + Json(response) + } + })); + let server = tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); + let command = if desired { + Command::Enable { + request: file.clone(), + } + } else { + Command::Disable { + request: file.clone(), + } + }; + for command in [ + command, + Command::Status { + operation_id: Some(id), + community_id: None, + host: None, + }, + ] { + assert_eq!( + run(Args { + origin: origin.clone(), + key_file: key_file.clone(), + command + }) + .await + .unwrap(), + if desired { 10 } else { 0 } + ); + } + assert_eq!( + run(Args { + origin, + key_file: key_file.clone(), + command: Command::Status { + operation_id: None, + community_id: Some(community_id), + host: Some(saved.expected_host.clone()), + } + }) + .await + .unwrap(), + 0 + ); + server.abort(); + for (state, code) in [("superseded", 11), ("unavailable", 12)] { + let value = serde_json::json!({"receipt": {"operation_id":id,"operator_pubkey":keys.public_key().to_hex(),"policy_generation":1,"requires_nip_fi":desired,"state":state}}); + assert_eq!( + receipt_exit_code(&value, id, &keys.public_key().to_hex(), None).unwrap(), + code + ); + } + } + } +} diff --git a/crates/buzz-admin/src/nip_fi_upgrade.rs b/crates/buzz-admin/src/nip_fi_upgrade.rs index be64a45749d..842e3fcc083 100644 --- a/crates/buzz-admin/src/nip_fi_upgrade.rs +++ b/crates/buzz-admin/src/nip_fi_upgrade.rs @@ -47,17 +47,17 @@ enum Command { #[derive(Debug, Deserialize, Serialize)] #[serde(deny_unknown_fields)] -struct UpgradeRequest { - operation_id: Uuid, - community_id: Uuid, - expected_host: String, - actor_pubkey: String, - expected_owner_pubkey: String, - expected_issuer: String, +pub(super) struct UpgradeRequest { + pub(super) operation_id: Uuid, + pub(super) community_id: Uuid, + pub(super) expected_host: String, + pub(super) actor_pubkey: String, + pub(super) expected_owner_pubkey: String, + pub(super) expected_issuer: String, } impl UpgradeRequest { - fn validate(&self) -> Result<()> { + pub(super) fn validate(&self) -> Result<()> { ensure!( !self.operation_id.is_nil() && !self.community_id.is_nil(), "UUIDs must not be nil" @@ -81,7 +81,7 @@ impl UpgradeRequest { } } -fn read_bounded(path: &Path, limit: usize) -> Result> { +pub(super) fn read_bounded(path: &Path, limit: usize) -> Result> { let mut bytes = Vec::new(); std::fs::File::open(path) .with_context(|| format!("cannot open {}", path.display()))? @@ -92,7 +92,7 @@ fn read_bounded(path: &Path, limit: usize) -> Result> { Ok(bytes) } -fn endpoint(origin: &str, operation: Option) -> Result { +pub(super) fn endpoint(origin: &str, operation: Option) -> Result { let base = Url::parse(origin).context("invalid operator origin")?; ensure!( matches!(base.scheme(), "http" | "https") @@ -190,12 +190,25 @@ pub async fn run(args: Args) -> Result { .and_then(|s| Keys::parse(s.trim()).ok()) .context("invalid operator key file")?; let body = request.as_ref().map(serde_json::to_vec).transpose()?; + let receipt = signed_call(&keys, url, body).await?; + let code = receipt_exit_code( + &receipt, + operation_id, + &keys.public_key().to_hex(), + request.as_ref(), + )?; + println!("{}", serde_json::to_string(&receipt)?); + Ok(code) +} + +/// Bounded, signed single call shared by the operator policy commands. +pub(super) async fn signed_call(keys: &Keys, url: Url, body: Option>) -> Result { let method = if body.is_some() { Method::POST } else { Method::GET }; - let auth = authorization(&keys, &method, &url, body.as_deref())?; + let auth = authorization(keys, &method, &url, body.as_deref())?; let client = reqwest::Client::builder() .redirect(Policy::none()) .retry(reqwest::retry::never()) @@ -232,6 +245,8 @@ pub async fn run(args: Args) -> Result { | "community_not_found" | "operation_not_found" | "idempotency_conflict" + | "policy_conflict" + | "policy_generation_exhausted" | "host_conflict" | "owner_conflict" | "lifecycle_conflict" @@ -242,7 +257,7 @@ pub async fn run(args: Args) -> Result { _ if error.is_none() => "non-JSON error response", _ => "unrecognized error code", }; - anyhow::bail!("operator API returned HTTP {} ({detail}); recover the same operation_id after an uncertain enable", status.as_u16()); + anyhow::bail!("operator API returned HTTP {} ({detail}); on policy_conflict re-read policy and use a new UUID; after response loss recover the same saved request and UUID", status.as_u16()); } // Print only structured relay output, never transport headers or signing material. let receipt: Value = serde_json::from_slice(&bytes).with_context(|| { @@ -251,14 +266,7 @@ pub async fn run(args: Args) -> Result { status.as_u16() ) })?; - let code = receipt_exit_code( - &receipt, - operation_id, - &keys.public_key().to_hex(), - request.as_ref(), - )?; - println!("{}", serde_json::to_string(&receipt)?); - Ok(code) + Ok(receipt) } #[cfg(test)] diff --git a/crates/buzz-db/src/runtime/migration.rs b/crates/buzz-db/src/runtime/migration.rs index ef2259dde37..2f0154ba9cc 100644 --- a/crates/buzz-db/src/runtime/migration.rs +++ b/crates/buzz-db/src/runtime/migration.rs @@ -898,7 +898,7 @@ mod postgres_tests { let mut migrations: Vec<_> = MIGRATOR.iter().collect(); migrations.sort_by_key(|migration| migration.version); - assert_eq!(migrations.len(), 83); + assert_eq!(migrations.len(), 84); for (index, migration) in migrations.iter().enumerate() { assert_eq!(migration.version, (index + 1) as i64); } @@ -3240,6 +3240,45 @@ mod postgres_tests { /// bounded inline build cannot take its lock it must fail closed without /// recording that migration, keeping earlier migrations and each index /// already built in its own transaction. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn policy_receipt_expand_preserves_populated_rows_and_legacy_inserts() { + let pool = connect_test_pool().await; + reset_public_schema(&pool).await; + run_migrations_through(&pool, 83).await.unwrap(); + let insert = "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,'team.example.org','actor','owner','https://issuer.example.org','operator',1)"; + let first = uuid::Uuid::new_v4(); + sqlx::query(insert) + .bind(first) + .bind(uuid::Uuid::new_v4()) + .execute(&pool) + .await + .unwrap(); + run_migrations(&pool).await.unwrap(); + let second = uuid::Uuid::new_v4(); + sqlx::query(insert) + .bind(second) + .bind(uuid::Uuid::new_v4()) + .execute(&pool) + .await + .unwrap(); + for id in [first, second] { + let row: (bool, Option, Option, i64) = sqlx::query_as( + "SELECT requested_requires_nip_fi, expected_policy_generation, + expected_requires_nip_fi, policy_generation + FROM community_auth_upgrades WHERE operation_id=$1", + ) + .bind(id) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(row, (true, None, None, 1)); + } + } + #[tokio::test] #[ignore = "requires Postgres"] async fn populated_artifact_tables_auto_migrate_through_every_migration() { diff --git a/crates/buzz-db/src/store/community_auth.rs b/crates/buzz-db/src/store/community_auth.rs index ec5a6387981..64ef7c2364d 100644 --- a/crates/buzz-db/src/store/community_auth.rs +++ b/crates/buzz-db/src/store/community_auth.rs @@ -1,4 +1,4 @@ -//! Monotonic community authentication requirements and durable operator receipts. +//! Community authentication policy management 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. @@ -39,6 +39,55 @@ pub struct UpgradeRequest { pub operator_pubkey: String, } +/// General policy intent; both preconditions are checked under the writer row lock. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PolicyRequest { + /// Canonical operation tuple. Disable uses an empty issuer. + pub tuple: UpgradeRequest, + /// Desired stored requirement. + pub requires_nip_fi: bool, + /// Last observed generation. + pub expected_policy_generation: i64, + /// Last observed stored requirement. + pub expected_requires_nip_fi: bool, +} + +/// Durable accepted intent and the current primary-database snapshot. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, FromRow)] +pub struct PolicyRecord { + /// Original operation tuple and accepted generation, never current authority. + #[serde(flatten)] + #[sqlx(flatten)] + pub acceptance: UpgradeRecord, + /// Null only for legacy, unfenced requests. + pub expected_policy_generation: Option, + /// Null only for legacy, unfenced requests. + pub expected_requires_nip_fi: Option, + /// Current target generation, absent after target deletion. + pub current_policy_generation: Option, + /// Current stored flag, absent after target deletion. + 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 { + /// Durable acceptance or identical read-only replay. + Accepted(PolicyRecord), + /// No mutation or receipt was committed. + Refused(&'static str), +} + /// Durable acceptance tuple plus the current availability of its target. #[derive(Debug, Clone, PartialEq, Eq, Serialize, FromRow)] pub struct UpgradeRecord { @@ -94,25 +143,34 @@ pub struct CommunityAuthPolicy { /// Immutable identity from the same row as this policy. #[sqlx(rename = "id")] pub community_id: Uuid, - /// Durable monotonic requirement. Global enforce remains an additional floor. + /// Durable stored requirement. Global enforce remains an additional floor. pub requires_nip_fi: bool, - /// Zero for legacy optional communities; positive after the first enable. + /// Zero before the first operation; increases with every accepted CAS intent. 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 +const RECEIPT: &str = "SELECT u.operation_id, u.target_community_id, u.expected_host, + u.actor_pubkey, u.expected_owner_pubkey, u.expected_issuer, u.operator_pubkey, + u.policy_generation, u.requested_requires_nip_fi AS requires_nip_fi, + u.expected_policy_generation, u.expected_requires_nip_fi, + c.policy_generation AS current_policy_generation, + c.requires_nip_fi AS current_requires_nip_fi, + CASE WHEN c.id IS NULL OR c.host <> u.expected_host + OR c.archived_at IS NOT NULL OR c.deleted_at IS NOT NULL + OR c.deletion_state <> 'active' + OR EXISTS (SELECT 1 FROM community_deletion_requests d + WHERE d.community_id = c.id AND d.stage <> 'aborted') THEN 'unavailable' + WHEN c.policy_generation <> u.policy_generation + OR c.requires_nip_fi <> u.requested_requires_nip_fi THEN 'superseded' + WHEN NOT u.requested_requires_nip_fi THEN 'applied' + WHEN 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['connection_closure_unverified']::text[] AS pending_reasons + THEN 'pending' ELSE 'unavailable' END AS state, + CASE WHEN u.requested_requires_nip_fi + THEN ARRAY['connection_closure_unverified']::text[] + ELSE ARRAY[]::text[] END AS pending_reasons FROM community_auth_upgrades u LEFT JOIN communities c ON c.id = u.target_community_id WHERE u.operation_id = $1"; @@ -235,16 +293,47 @@ impl Db { 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, + 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) + let record: Option = sqlx::query_as(RECEIPT) .bind(operation) .fetch_optional(&mut *connection) .await?; - Ok(record.filter(|record| record.operator_pubkey == operator)) + Ok(record.filter(|record| record.acceptance.operator_pubkey == operator)) + } + + /// Apply a compare-and-set policy intent; identical committed replay is read-only. + pub async fn set_community_nip_fi_policy( + &self, + 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. @@ -258,6 +347,26 @@ impl Db { 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.map_or(true, |i| i.0); + let expected_generation = intent.map(|i| i.1); + let expected_state = intent.map(|i| i.2); let connection = crate::observability::acquire_writer( &self.pool, crate::observability::WriterOperation::Authorization, @@ -273,19 +382,25 @@ impl Db { .bind(request.operation_id.to_string()) .execute(&mut *tx) .await?; - let existing: Option = sqlx::query_as(RECEIPT) + 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") - }); + 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 + { + PolicyOutcome::Accepted(record) + } else { + PolicyOutcome::Refused("idempotency_conflict") + }, + ); } if let Err(code) = configured_trust { - return Ok(UpgradeOutcome::Refused(code)); + return Ok(PolicyOutcome::Refused(code)); } let community = CommunityId::from_uuid(request.community_id); crate::deletion::lock_community_deletion_shared(&mut tx, community).await?; @@ -300,10 +415,24 @@ impl Db { .fetch_optional(&mut *tx) .await?; let Some(row) = row else { - return Ok(UpgradeOutcome::Refused("community_not_found")); + return Ok(PolicyOutcome::Refused("community_not_found")); }; if row.try_get::("host")? != request.expected_host { - return Ok(UpgradeOutcome::Refused("host_conflict")); + return Ok(PolicyOutcome::Refused("host_conflict")); + } + 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. + return Ok(PolicyOutcome::Refused("policy_conflict")); + } + let bump = intent.is_some() || !old_required; + if bump && old_generation == i64::MAX { + return Ok(PolicyOutcome::Refused("policy_generation_exhausted")); } // Lock order: deletion-shared, community row, host lease. Fills take // only the shared host lease and never a community row lock. Derive @@ -317,7 +446,7 @@ impl Db { // Keep the row stable while ALL optional leases expire, including // leases under former host keys. No remap (single or double) can // refill another host's optional cache while this drain is running. - if !row.try_get::("requires_nip_fi")? { + if desired && !old_required { tokio::time::sleep(POLICY_LEASE_DRAIN).await; } let deletion_pending: bool = sqlx::query_scalar( @@ -328,7 +457,7 @@ impl Db { .fetch_one(&mut *tx) .await?; if !row.try_get::("active")? || deletion_pending { - return Ok(UpgradeOutcome::Refused("lifecycle_conflict")); + return Ok(PolicyOutcome::Refused("lifecycle_conflict")); } let owners: Vec = sqlx::query_scalar( "SELECT pubkey FROM relay_members @@ -340,31 +469,39 @@ impl Db { if owners != [request.expected_owner_pubkey.clone()] || request.actor_pubkey != request.expected_owner_pubkey { - return Ok(UpgradeOutcome::Refused("owner_conflict")); + return Ok(PolicyOutcome::Refused("owner_conflict")); } - let issuer: Option = sqlx::query_scalar( - "SELECT issuer FROM community_nip_fi_issuers + if desired { + 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")); + ) + .bind(request.community_id) + .bind(&request.expected_issuer) + .fetch_optional(&mut *tx) + .await?; + if issuer.is_none() { + return Ok(PolicyOutcome::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?; + let generation = old_generation + i64::from(bump); + sqlx::query( + "UPDATE communities SET policy_generation = $2, requires_nip_fi = $3 WHERE id = $1", + ) + .bind(request.community_id) + .bind(generation) + .bind(desired) + .execute(&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)", + expected_issuer, operator_pubkey, policy_generation, requested_requires_nip_fi, + expected_policy_generation, expected_requires_nip_fi) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)", ) .bind(request.operation_id) .bind(request.community_id) @@ -374,6 +511,9 @@ impl Db { .bind(&request.expected_issuer) .bind(&request.operator_pubkey) .bind(generation) + .bind(desired) + .bind(expected_generation) + .bind(expected_state) .execute(&mut *tx) .await?; let record = sqlx::query_as(RECEIPT) @@ -381,7 +521,7 @@ impl Db { .fetch_one(&mut *tx) .await?; tx.commit().await?; - Ok(UpgradeOutcome::Accepted(record)) + Ok(PolicyOutcome::Accepted(record)) } } 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 e8ef0862c4c..8f38806a1e0 100644 --- a/crates/buzz-db/src/store/community_auth/postgres_tests.rs +++ b/crates/buzz-db/src/store/community_auth/postgres_tests.rs @@ -558,3 +558,307 @@ async fn host_policy_tracks_primary_requirement_and_serving_lifecycle() { .unwrap() .is_none()); } + +fn intent(tuple: &UpgradeRequest, desired: bool, generation: i64, required: bool) -> PolicyRequest { + let mut tuple = tuple.clone(); + tuple.operation_id = Uuid::new_v4(); + if !desired { + tuple.expected_issuer.clear(); + } + PolicyRequest { + tuple, + requires_nip_fi: desired, + expected_policy_generation: generation, + expected_requires_nip_fi: required, + } +} + +async fn apply(db: &Db, request: &PolicyRequest) -> PolicyRecord { + let PolicyOutcome::Accepted(record) = db + .set_community_nip_fi_policy(request, Ok(())) + .await + .unwrap() + else { + panic!("policy refused") + }; + record +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn policy_enable_disable_reenable_history_and_identity() { + let (db, tuple, _) = fixture().await; + let enable = intent(&tuple, true, 0, false); + let first = apply(&db, &enable).await; + assert_eq!(first.acceptance.policy_generation, 1); + assert_eq!(first.acceptance.state, "pending"); + let disable = intent(&tuple, false, 1, true); + let second = apply(&db, &disable).await; + assert_eq!(second.acceptance.policy_generation, 2); + assert_eq!(second.acceptance.state, "applied"); + let old = db + .community_auth_policy_status(enable.tuple.operation_id, &tuple.operator_pubkey) + .await + .unwrap() + .unwrap(); + assert_eq!(old.acceptance.state, "superseded"); + assert_eq!(old.current_policy_generation, Some(2)); + assert_eq!(old.current_requires_nip_fi, Some(false)); + // A committed retry is history even with the mutation gate disabled. + assert_eq!( + db.set_community_nip_fi_policy(&enable, Err("upgrades_disabled")) + .await + .unwrap(), + PolicyOutcome::Accepted(old) + ); + let reenable = intent(&tuple, true, 2, false); + let third = apply(&db, &reenable).await; + assert_eq!(third.acceptance.policy_generation, 3); + assert_eq!(third.current_requires_nip_fi, Some(true)); + for request in [&enable, &disable, &reenable] { + let record = db + .community_auth_policy_status(request.tuple.operation_id, &tuple.operator_pubkey) + .await + .unwrap() + .unwrap(); + assert_eq!(record.current_policy_generation, Some(3)); + assert_eq!( + record.acceptance.state, + if request == &reenable { + "pending" + } else { + "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(); + match change { + 0 => changed.requires_nip_fi = false, + 1 => changed.expected_policy_generation = 2, + 2 => changed.expected_requires_nip_fi = true, + 3 => changed.tuple.operator_pubkey = "f".repeat(64), + _ => changed.tuple.expected_issuer = "https://different.example".into(), + } + assert_eq!( + db.set_community_nip_fi_policy(&changed, Ok(())) + .await + .unwrap(), + PolicyOutcome::Refused("idempotency_conflict") + ); + } + assert_eq!(policy(&db, &tuple).await.policy_generation, 3); +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn policy_same_state_barrier_refuses_stale_cas_and_legacy_retry() { + let (db, tuple, _) = fixture().await; + let stale = intent(&tuple, true, 0, false); + let barrier = intent(&tuple, false, 0, false); + let record = apply(&db, &barrier).await; + assert_eq!(record.acceptance.policy_generation, 1); + assert!(!record.acceptance.requires_nip_fi); + assert_eq!( + db.set_community_nip_fi_policy(&stale, Ok(())) + .await + .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(())) + .await + .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); + 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(())) + .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" + ); +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn policy_disable_guards_and_issuer_removal_then_reenable() { + let (db, tuple, _) = fixture().await; + accepted(&db, &tuple).await; + let disable = intent(&tuple, false, 1, true); + for kind in 0..4 { + let mut changed = disable.clone(); + let code = match kind { + 0 => { + changed.tuple.actor_pubkey = "a".repeat(64); + "owner_conflict" + } + 1 => { + changed.tuple.expected_host = "wrong.example".into(); + "host_conflict" + } + 2 => { + changed.tuple.expected_owner_pubkey = "b".repeat(64); + "owner_conflict" + } + _ => { + changed.tuple.community_id = Uuid::new_v4(); + "community_not_found" + } + }; + assert_eq!( + db.set_community_nip_fi_policy(&changed, Ok(())) + .await + .unwrap(), + PolicyOutcome::Refused(code) + ); + } + assert_eq!( + db.set_community_nip_fi_policy(&disable, Err("upgrades_disabled")) + .await + .unwrap(), + PolicyOutcome::Refused("upgrades_disabled") + ); + apply(&db, &disable).await; + let community = CommunityId::from_uuid(tuple.community_id); + assert_eq!( + db.unbind_nip_fi_issuer(community, ISSUER, true) + .await + .unwrap(), + crate::NipFiUnbind::Removed + ); + let enable = intent(&tuple, true, 2, false); + assert_eq!( + db.set_community_nip_fi_policy(&enable, Ok(())) + .await + .unwrap(), + PolicyOutcome::Refused("issuer_conflict") + ); + // Disable works without a trusted/bound issuer and still advances its barrier. + let next = apply(&db, &intent(&tuple, false, 2, false)).await; + assert_eq!(next.acceptance.policy_generation, 3); + sqlx::query("UPDATE communities SET archived_at = now() WHERE id = $1") + .bind(tuple.community_id) + .execute(&db.pool) + .await + .unwrap(); + assert_eq!( + db.set_community_nip_fi_policy(&intent(&tuple, false, 3, false), Ok(())) + .await + .unwrap(), + PolicyOutcome::Refused("lifecycle_conflict") + ); +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn policy_disable_serializes_behind_enable_drain() { + use std::str::FromStr; + let (db, tuple, _) = fixture().await; + let application = format!("policy-drain-{}", Uuid::new_v4().simple()); + let options = sqlx::postgres::PgConnectOptions::from_str(&crate::test_support::database_url()) + .unwrap() + .application_name(&application); + let writer = Db::from_pool( + sqlx::postgres::PgPoolOptions::new() + .max_connections(1) + .connect_with(options) + .await + .unwrap(), + ); + let enable = intent(&tuple, true, 0, false); + let task = tokio::spawn(async move { apply(&writer, &enable).await }); + // The lease-lock statement followed by idle-in-transaction identifies the + // production drain while the writer still owns its community row lock. + tokio::time::timeout(std::time::Duration::from_secs(5), async { + loop { + assert!(!task.is_finished(), "missed the production lease drain"); + let draining: bool = sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM pg_stat_activity WHERE application_name = $1 AND state = 'idle in transaction' AND query LIKE 'SELECT pg_advisory_xact_lock($1,%')") + .bind(&application).fetch_one(&db.pool).await.unwrap(); + if draining { break; } + tokio::task::yield_now().await; + } + }).await.unwrap(); + let disable = intent(&tuple, false, 0, false); + assert_eq!( + db.set_community_nip_fi_policy(&disable, Ok(())) + .await + .unwrap(), + PolicyOutcome::Refused("policy_conflict") + ); + assert_eq!(task.await.unwrap().acceptance.policy_generation, 1); + assert!(policy(&db, &tuple).await.requires_nip_fi); + assert_eq!( + apply(&db, &intent(&tuple, false, 1, true)) + .await + .acceptance + .policy_generation, + 2 + ); +} diff --git a/crates/buzz-relay/src/api/operator.rs b/crates/buzz-relay/src/api/operator.rs index 755b41f2402..8672aed591e 100644 --- a/crates/buzz-relay/src/api/operator.rs +++ b/crates/buzz-relay/src/api/operator.rs @@ -4,6 +4,9 @@ //! request signing and replay protection, but they do not run through event //! ingest, relay membership, channel scoping, storage, or fan-out. +mod community_policy; +pub use community_policy::{community_nip_fi_policy_status, set_community_nip_fi_policy}; + use crate::access::{AccessSlot, AuthMethod, AuthResult}; use std::sync::Arc; @@ -1374,7 +1377,10 @@ 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 upgrade = matches!( + request.uri().path(), + "/operator/communities/nip-fi-upgrade" | "/operator/communities/nip-fi-policy" + ); let mut response = next.run(request).await; if upgrade { response.headers_mut().insert( @@ -4981,4 +4987,5 @@ mod postgres_tests { let body = read_json(response).await; assert_eq!(body["error"], "internal server error"); } + mod policy_tests; } diff --git a/crates/buzz-relay/src/api/operator/community_policy.rs b/crates/buzz-relay/src/api/operator/community_policy.rs new file mode 100644 index 00000000000..df55a0c1d4e --- /dev/null +++ b/crates/buzz-relay/src/api/operator/community_policy.rs @@ -0,0 +1,216 @@ +//! General operator policy intents and truthful current/historical readback. + +use super::*; +use buzz_db::community_auth::{PolicyOutcome, PolicyRecord, PolicyRequest, UpgradeRequest}; + +const PATH: &str = "/operator/communities/nip-fi-policy"; + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct Request { + operation_id: Uuid, + community_id: Uuid, + expected_host: String, + actor_pubkey: String, + expected_owner_pubkey: String, + #[serde(default)] + expected_issuer: Option, + requires_nip_fi: bool, + expected_policy_generation: i64, + expected_requires_nip_fi: bool, +} + +/// Exactly one of an operation receipt or a community/host current-state read. +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +pub struct PolicyQuery { + operation_id: Option, + community_id: Option, + expected_host: Option, +} + +type Error = (StatusCode, Json); + +fn invalid() -> Error { + coded_api_error( + StatusCode::BAD_REQUEST, + "invalid_request", + "invalid policy request", + ) +} + +fn unavailable(error: buzz_db::DbError) -> Error { + tracing::warn!(%error, "community auth policy unavailable"); + coded_api_error( + StatusCode::SERVICE_UNAVAILABLE, + "authorization_unavailable", + "authorization unavailable", + ) +} + +fn mode_fields(state: &AppState, required: Option) -> Value { + use buzz_auth::NipFiMode; + let mode = state.config.nip_fi.mode; + let name = match mode { + NipFiMode::Off => "off", + NipFiMode::Shadow => "shadow", + NipFiMode::Enforce => "enforce", + NipFiMode::DenyProtected => "deny-protected", + }; + serde_json::json!({ + "mode": name, + "effective_required": required.map(|r| mode.with_requirement(r).restricts()), + }) +} + +fn response(state: &AppState, mut record: PolicyRecord) -> Json { + // Supersession is history, not a trust failure. Disable needs no issuer. + if record.acceptance.state == "pending" + && upgrade_configured_trust( + state, + &record.acceptance.expected_host, + &record.acceptance.expected_issuer, + ) + .is_err() + { + record.acceptance.state = "unavailable".into(); + } + let fields = mode_fields(state, record.current_requires_nip_fi); + Json(serde_json::json!({ + "receipt": record, + "mode": fields["mode"], + "effective_required": fields["effective_required"], + })) +} + +/// Atomically accept an owner-authorized stored requirement with CAS and replay. +pub async fn set_community_nip_fi_policy( + State(state): State>, + slot: AccessSlot, + headers: HeaderMap, + body: axum::body::Bytes, +) -> Result, Error> { + let operator = + authorize_operator_request(&state, &slot, &headers, "POST", PATH, None, Some(&body)) + .await?; + let request: Request = serde_json::from_slice(&body).map_err(|_| invalid())?; + let host = normalize_candidate_host(&request.expected_host).map_err(|_| invalid())?; + if request.operation_id.is_nil() + || request.community_id.is_nil() + || request.expected_policy_generation < 0 + || host != request.expected_host + || (request.requires_nip_fi + && request + .expected_issuer + .as_ref() + .is_none_or(|s| s.is_empty())) + || (!request.requires_nip_fi && request.expected_issuer.is_some()) + { + return Err(invalid()); + } + let tuple = UpgradeRequest { + operation_id: request.operation_id, + community_id: request.community_id, + expected_host: host, + actor_pubkey: validate_pubkey_hex(&request.actor_pubkey).ok_or_else(invalid)?, + expected_owner_pubkey: validate_pubkey_hex(&request.expected_owner_pubkey) + .ok_or_else(invalid)?, + expected_issuer: request.expected_issuer.unwrap_or_default(), + operator_pubkey: operator.to_hex(), + }; + let preflight = if !state.config.nip_fi_upgrades_enabled { + Err("upgrades_disabled") + } else if request.requires_nip_fi { + upgrade_configured_trust(&state, &tuple.expected_host, &tuple.expected_issuer) + } else { + Ok(()) + }; + let intent = PolicyRequest { + tuple, + requires_nip_fi: request.requires_nip_fi, + expected_policy_generation: request.expected_policy_generation, + expected_requires_nip_fi: request.expected_requires_nip_fi, + }; + match state + .db + .set_community_nip_fi_policy(&intent, preflight) + .await + .map_err(unavailable)? + { + PolicyOutcome::Accepted(record) => { + state.invalidate_community_host(&TenantContext::resolved( + buzz_core::CommunityId::from_uuid(record.acceptance.community_id), + &record.acceptance.expected_host, + )); + Ok(response(&state, record)) + } + PolicyOutcome::Refused(code) => Err(coded_api_error( + match code { + "community_not_found" => StatusCode::NOT_FOUND, + "upgrades_disabled" | "authorization_unavailable" => { + StatusCode::SERVICE_UNAVAILABLE + } + _ => StatusCode::CONFLICT, + }, + code, + code, + )), + } +} + +/// Read exact current preconditions or the originating operator's operation history. +pub async fn community_nip_fi_policy_status( + State(state): State>, + slot: AccessSlot, + headers: HeaderMap, + RawQuery(raw): RawQuery, + Query(query): Query, +) -> Result, Error> { + let operator = + authorize_operator_request(&state, &slot, &headers, "GET", PATH, raw.as_deref(), None) + .await?; + match (query.operation_id, query.community_id, query.expected_host) { + (Some(id), None, None) if !id.is_nil() => { + let record = state + .db + .community_auth_policy_status(id, &operator.to_hex()) + .await + .map_err(unavailable)? + .ok_or_else(|| { + coded_api_error( + StatusCode::NOT_FOUND, + "operation_not_found", + "operation not found", + ) + })?; + Ok(response(&state, record)) + } + (None, Some(id), Some(host)) if !id.is_nil() => { + if normalize_candidate_host(&host).map_err(|_| invalid())? != host { + return Err(invalid()); + } + let policy = state + .db + .community_auth_policy_for_host(&host) + .await + .map_err(unavailable)? + .filter(|p| p.community_id == id) + .ok_or_else(|| { + coded_api_error( + StatusCode::NOT_FOUND, + "community_not_found", + "community not found", + ) + })?; + let fields = mode_fields(&state, Some(policy.requires_nip_fi)); + Ok(Json(serde_json::json!({ + "community_id": id, "expected_host": host, + "requires_nip_fi": policy.requires_nip_fi, "policy_generation": policy.policy_generation, + "expected_policy_generation": policy.policy_generation, + "expected_requires_nip_fi": policy.requires_nip_fi, + "mode": fields["mode"], "effective_required": fields["effective_required"], + }))) + } + _ => Err(invalid()), + } +} diff --git a/crates/buzz-relay/src/api/operator/postgres_tests/policy_tests.rs b/crates/buzz-relay/src/api/operator/postgres_tests/policy_tests.rs new file mode 100644 index 00000000000..163ad67ca4b --- /dev/null +++ b/crates/buzz-relay/src/api/operator/postgres_tests/policy_tests.rs @@ -0,0 +1,212 @@ +mod postgres_tests { + use super::super::*; + // Included in operator::postgres_tests to reuse the real signed HTTP fixture. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn policy_operator_http_transition_history_guards_and_modes() { + let operator = Keys::generate(); + let owner = Keys::generate(); + let (binding, _, host) = reserved_host_policy(); + let mut state = operator_test_state_with_policy(&[operator.clone()], Some(binding)) + .await + .unwrap(); + 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".into()], + 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(); + let config = Arc::make_mut(&mut Arc::get_mut(&mut state).unwrap().config); + config.nip_fi.registry.insert(issuer_policy); + config.nip_fi.mode = buzz_auth::NipFiMode::Shadow; + config.nip_fi_upgrades_enabled = false; + 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 path = "/operator/communities/nip-fi-policy"; + let current_path = format!("{path}?community_id={}&expected_host={host}", community.id); + let current = + fresh_upgrade_request(state.clone(), &operator, "GET", ¤t_path, None).await; + assert_eq!(current.headers()[header::CACHE_CONTROL], "no-store"); + let current = read_json(current).await; + assert_eq!(current["expected_policy_generation"], 0); + assert_eq!(current["expected_requires_nip_fi"], false); + let mut body = serde_json::json!({ + "operation_id": Uuid::new_v4(), "community_id": community.id.to_string(), + "expected_host": host, "actor_pubkey": owner.public_key().to_hex(), + "expected_owner_pubkey": owner.public_key().to_hex(), "expected_issuer": NIP_FI_ISSUER, + "requires_nip_fi": true, "expected_policy_generation": 0, "expected_requires_nip_fi": false, + }); + let disabled = fresh_upgrade_request( + state.clone(), + &operator, + "POST", + path, + Some(body.to_string()), + ) + .await; + assert_eq!(disabled.status(), StatusCode::SERVICE_UNAVAILABLE); + assert_eq!(read_json(disabled).await["code"], "upgrades_disabled"); + Arc::make_mut(&mut Arc::get_mut(&mut state).unwrap().config).nip_fi_upgrades_enabled = true; + let mut history = Vec::new(); + for (generation, desired) in [(1, true), (2, false), (3, true)] { + body["operation_id"] = Uuid::new_v4().to_string().into(); + body["requires_nip_fi"] = desired.into(); + body["expected_policy_generation"] = (generation - 1).into(); + body["expected_requires_nip_fi"] = (!desired).into(); + if desired { + body["expected_issuer"] = NIP_FI_ISSUER.into(); + } else { + body.as_object_mut().unwrap().remove("expected_issuer"); + } + let response = fresh_upgrade_request( + state.clone(), + &operator, + "POST", + path, + Some(body.to_string()), + ) + .await; + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(response.headers()[header::CACHE_CONTROL], "no-store"); + let accepted = read_json(response).await; + assert_eq!(accepted["receipt"]["policy_generation"], generation); + assert_eq!(accepted["receipt"]["requires_nip_fi"], desired); + assert_eq!(accepted["effective_required"], desired); + assert_eq!( + accepted["receipt"]["state"], + if desired { "pending" } else { "applied" } + ); + history.push(body.clone()); + for old in &history { + let id = old["operation_id"].as_str().unwrap(); + let historical = fresh_upgrade_request( + state.clone(), + &operator, + "GET", + &format!("{path}?operation_id={id}"), + None, + ) + .await; + assert_eq!(historical.status(), StatusCode::OK); + let historical = read_json(historical).await; + assert_eq!( + historical["receipt"]["current_policy_generation"], + generation + ); + assert_eq!( + historical["receipt"]["state"], + if old == &body { + if desired { + "pending" + } else { + "applied" + } + } else { + "superseded" + } + ); + let legacy = fresh_upgrade_request( + state.clone(), + &operator, + "GET", + &format!("/operator/communities/nip-fi-upgrade?operation_id={id}"), + None, + ) + .await; + assert_eq!(legacy.status(), StatusCode::NOT_FOUND); + let replay = fresh_upgrade_request( + state.clone(), + &operator, + "POST", + path, + Some(old.to_string()), + ) + .await; + assert_eq!(read_json(replay).await, historical); + } + let current = read_json( + fresh_upgrade_request(state.clone(), &operator, "GET", ¤t_path, None).await, + ) + .await; + assert_eq!(current["expected_policy_generation"], generation); + assert_eq!(current["expected_requires_nip_fi"], desired); + } + let mut stale = history[0].clone(); + stale["operation_id"] = Uuid::new_v4().to_string().into(); + let response = fresh_upgrade_request( + state.clone(), + &operator, + "POST", + path, + Some(stale.to_string()), + ) + .await; + assert_eq!(response.status(), StatusCode::CONFLICT); + assert_eq!(read_json(response).await["code"], "policy_conflict"); + let mut conflict = history[0].clone(); + conflict["requires_nip_fi"] = false.into(); + conflict.as_object_mut().unwrap().remove("expected_issuer"); + let response = fresh_upgrade_request( + state.clone(), + &operator, + "POST", + path, + Some(conflict.to_string()), + ) + .await; + assert_eq!(read_json(response).await["code"], "idempotency_conflict"); + Arc::make_mut(&mut Arc::get_mut(&mut state).unwrap().config).nip_fi_upgrades_enabled = + false; + let response = fresh_upgrade_request( + state.clone(), + &operator, + "POST", + path, + Some(history[0].to_string()), + ) + .await; + assert_eq!(response.status(), StatusCode::OK); + for mode in [buzz_auth::NipFiMode::Off, buzz_auth::NipFiMode::Enforce] { + Arc::make_mut(&mut Arc::get_mut(&mut state).unwrap().config) + .nip_fi + .mode = mode; + let current = read_json( + fresh_upgrade_request(state.clone(), &operator, "GET", ¤t_path, None).await, + ) + .await; + assert_eq!(current["effective_required"], mode.restricts()); + } + let invalid = fresh_upgrade_request( + state.clone(), + &operator, + "GET", + &format!("{current_path}&operation_id={}", Uuid::new_v4()), + None, + ) + .await; + assert_eq!(invalid.status(), StatusCode::BAD_REQUEST); + // The control-plane signature and allowlist remain mandatory. + let outsider = Keys::generate(); + let response = fresh_upgrade_request(state, &outsider, "GET", ¤t_path, None).await; + assert_eq!(response.status(), StatusCode::FORBIDDEN); + } +} diff --git a/crates/buzz-relay/src/router.rs b/crates/buzz-relay/src/router.rs index dcd58c5e919..172895d0caa 100644 --- a/crates/buzz-relay/src/router.rs +++ b/crates/buzz-relay/src/router.rs @@ -513,6 +513,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-policy", + 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) diff --git a/docs/community-auth-upgrades.md b/docs/community-auth-upgrades.md index ed5205b1afa..0d246da120e 100644 --- a/docs/community-auth-upgrades.md +++ b/docs/community-auth-upgrades.md @@ -1,434 +1,138 @@ -# Community NIP-FI upgrade operator contract +# Community NIP-FI policy operator contract -The acceptance layer records durable policy. Admission enforces the effective -requirement, and the periodic community revalidator eventually closes existing -legacy root and audio WebSockets. Deploy and verify these behaviors on every -serving relay before enabling upgrade calls. The receipt proves acceptance; it -never reports `active` or certifies that all old work has finished. +The operator plane manages a stored community requirement independently of the +global FI mode. See [operator usage](operator-nip-fi-upgrade.md). Receipts prove +durable acceptance, never fleet-wide socket closure or current authorization. -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`. +## Operator boundary -`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 admission and eventual socket closure -are verified on every serving relay; caller release discipline alone cannot enable acceptance. +POST/GET `/operator/communities/nip-fi-policy` require an allowlisted operator's +NIP-98 signature bound to `RELAY_OPERATOR_API_ORIGIN`, method and URL (including +the GET query), and POST body hash. Each attempt needs a fresh signature. These +routes extend provisioning outside tenant event ingest; ordinary membership +credentials cannot invoke them. All responses use `Cache-Control: no-store`. -## 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; receipt -matching still requires 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 operator mediates an authenticated actor's registered key. The relay verifies +the operator and stored owner; `actor_pubkey` is the mediator's claimed actor, +not an independent user credential. Never accept a caller-selected actor as identity. -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. +## Generation storage -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. +`communities.requires_nip_fi` defaults false; `policy_generation` defaults zero. +Every new general operation advances the generation, even a same-state intent. +Replay never increments. Generation exhaustion is refused. The historical +`community_auth_upgrades` table stores acceptance receipts for both directions. +Receipts survive target deletion and are not tenant serving data. + +## Enable and disable + +POST contains `operation_id`, `community_id`, canonical `expected_host`, +`actor_pubkey`, `expected_owner_pubkey`, desired `requires_nip_fi`, and mandatory +`expected_policy_generation` plus `expected_requires_nip_fi`. UUIDs must be non-nil, +keys lowercase hex, generations nonnegative; unknown fields are rejected. +Enable requires `expected_issuer`; disable must omit it. + +Read current preconditions with GET `?community_id=&expected_host=`. +Both generation and stored state must still match under the writer row lock. +A mismatch returns 409 `policy_conflict` without mutation or receipt. Re-read +and use a new UUID/request for revised intent. On a lost response, preserve the +original complete file/UUID and recover or retry it with a fresh signature. + +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 +meaning under a committed UUID returns 409 `idempotency_conflict`. Identical +committed replay recovers history before mutable preflight and never writes policy. + +Enable requires a single deployment-selected authorized issuer in +`BUZZ_NIP_FI_PROVISION_BINDING`, an applicable registry policy and exact existing +community binding. It locks the binding against unbind and adds no trust. +Disable does not depend on issuer availability and does not alter bindings. +Every optional→required transition, including re-enable, excludes new host leases +and drains prior optional leases for 1.1 seconds before committing. ## 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": ["connection_closure_unverified"] -} -``` - -`requires_nip_fi: true` records durable acceptance. `state` reports availability -and connection closure 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`. +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. -`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. -`connection_closure_unverified` means that this version does not certify socket -closure. It remains present after successful sweeps: neither a timer nor local -socket counts prove fleet-wide completion. Clients must treat `pending` as -accepted policy whose connection closure is unverified, rather than wait for an -activation ACK that this version does not produce. - -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 | +| Receipt state | Meaning | CLI exit | |---|---|---| -| 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. +| `applied` | Current stored disable | 0 | +| `pending` | Current accepted enable; closure uncertified | 10 | +| `superseded` | Later generation or state differs | 11 | +| `unavailable` | Target/lifecycle/host or required issuer trust unavailable | 12 | + +`pending_reasons` includes `connection_closure_unverified` for enable. Neither +time, local counters nor a successful sweep certify fleet completion. No response +reports `active`. History is not a current authorization credential; ownership +changes do not destroy the original operator's recovery authority. + +Invalid requests return 400; operator auth uses existing 401/403 errors. Missing +community/operation returns 404. Host, owner, lifecycle, issuer and policy conflicts +return 409 with corresponding `*_conflict` codes; generation exhaustion returns +409 `policy_generation_exhausted`. New mutations behind a closed gate return +503 `upgrades_disabled`; unavailable authoritative DB/trust returns +503 `authorization_unavailable`. Request/transport/response errors exit 5. ## 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. This migration -changes no data-plane gate. Relay mode determines whether stored requirements -are enforced, as described below. -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. Repair mode denies protected traffic first. In -`off`, no community requires FI, including a stored-required community; FI lookup -or dependency failures must not deny ordinary admission. In `shadow`, enforce -stored requirements and observe optional communities. In `enforce`, require FI -for every community. Evaluating policy lookups fail closed on errors. Mode -changes do not clear the stored requirement, generation, issuer trust or receipts. - -## 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. In Shadow, 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. A timeout or mistaken call does not -authorize clearing the requirement or discarding operation evidence. - -An explicit operator change to `off` intentionally suspends FI enforcement for -all communities while preserving durable requirements and receipts. This is a -relay-wide mode choice, not an undo of acceptance or a binary rollback. Readback -must distinguish stored policy from effective enforcement and must not report -active enforcement in Off. Returning to `shadow` resumes enforcement for stored -required communities; `enforce` requires FI everywhere. Off suspends FI for -every community; it is not per-community containment. Choose relay-wide Off -suspension or archive/lifecycle containment explicitly; neither a fleet-wide -drain nor a 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. - -## Request enforcement and discovery (BUZZ-511) - -Relay mode controls the effective requirement without changing durable storage: -`off` disables FI for all communities, including stored `requires_nip_fi=true`; -`shadow` enforces FI only for stored required communities and observes optional -communities; `enforce` requires FI everywhere. `deny_protected` remains an -unconditional repair refusal. Off ignores missing FI policy, verifier, deny map -and issuer dependencies; ordinary tenant routing and Nostr authorization remain. -Outside Off, protected requests resolve their normalized Host's active policy -through a bounded primary-read lease cache, with a 500 ms budget on misses. -Existing Nostr proof, key pairing, deny map, membership and resource checks remain. -Discovery, pairing, enrollment and operator exceptions keep their own authentication. - -The router shares one policy snapshot with HTTP handlers for that request. Git -also scopes its extractor to one policy and tenant snapshot, retaining its -existing header, signed-URL and denial ordering. Routing and policy share one -host cache capped at 10,000 entries. A serving entry carries immutable tenant ID, -requirement, generation and original read time from the same row. It is usable -for strictly less than one second from before the read's connection checkout; -neither a slow read nor a cache hit renews its lifetime. Concurrent fills for a -host share a successful read. Misses, errors and timeouts are never cached. A late result -after invalidation is usable once by its in-flight request but never published, -and an older generation cannot replace a newer -observed generation for the same community. Off retains ordinary routing-only -cache population and does no FI-policy fills. - -Cache fills take a shared transaction-scoped advisory lock keyed by normalized -host in a dedicated namespace. Lock acquisition and the serving-row SELECT are -separate statements in an explicit READ COMMITTED transaction. A contended fill -refuses immediately; a statement has a 400 ms server-side timeout inside the -500 ms overall budget. Waiters on the per-host fill share its successful result; -when no result was cached, each waiter makes at most one lease read of its own -inside that same budget, with no retry loop. Unmapped hosts remain 404. -Each cold policy fill uses six database statements: BEGIN, isolation setup, -statement-timeout setup, try-lock, row SELECT and COMMIT. Ordinary routing keeps -its single plain serving-row query and a routing-only snapshot in the same -bounded host cache. That snapshot cannot supply FI policy. A routing refresh -preserves a fresh policy for the same tenant without extending its lease. - -Acceptance takes the shared deletion lock and locks the target community row -`FOR NO KEY UPDATE`. It checks the row's host against `expected_host`, then takes -the exclusive advisory lock derived from that locked host. An optional row waits -1.1 seconds before the remaining owner/issuer checks and commit. The row stays -locked throughout, preventing single or double host remaps during the drain. -This also expires leases issued under any former host before row-lock acquisition. -An initially wrong host precondition is refused before waiting. - -This drains every optional one-second lease across all pods BEFORE commit, -without relying on Redis delivery or synchronized wall clocks. The extra 100 ms -is a clock-rate margin. Lease duration and acceptance wait are compile-time fleet -protocol constants; increasing the lease requires a coordinated writer rollout -first. Existing receipt recovery skips this wait. Already-required targets need -no lease drain. Fills take only a shared advisory host lock and never lock the -community row, preserving the deletion-shared → row → host-lock ordering. - -Policy resolutions that start after acceptance commits therefore cannot use an -optional lease for that community, even on a pod that missed invalidation. -Admissions that resolved policy before commit can still be in flight, exactly -as with primary reads; existing sockets are caught by a later community sweep, and already-running -requests may finish. Cache expiry does not certify socket closure. -The acceptance drain blocks new policy fills and other acceptances for the -host. Its community row lock also blocks archive/unarchive, deletion admission -and fences, ownership transfer/convergence and issuer-binding writers for that -community during the 1.1-second wait. These are operator actions; ordinary -serving reads and writes do not take a conflicting row lock. Foreign-key -`FOR KEY SHARE` locks do not conflict with `FOR NO KEY UPDATE`, and the shared -deletion lock remains compatible with ordinary tenant writers. Cold policy -requests during the transition may return 503; acceptance is rare and this -avoids allowing stale optional leases after commit. - -Relay acceptance, provisioning and archive/unarchive writes drop the local host -snapshot and publish a best-effort Redis invalidation to other pods. An epoch -fences fills that began before a local or received drop. Direct administrative -database writes and missed notifications retain the one-second routing bound. -Host remap, archive and deletion do not drain leases: cached tenant binding can -lag those changes by up to one second, as ordinary host routing already does. -Policy and tenant data always use the same cached row, including NIP-11 -descriptors and decorations. Issuer-binding and JWKS freshness keep their own -existing bounds and checks; cached status never substitutes for those checks. - -`buzz_nip_fi_policy_cache_hits_total` and `buzz_nip_fi_policy_cache_misses_total` -measure cache use; `buzz_nip_fi_policy_reads_total` counts fill attempts and -`buzz_nip_fi_policy_read_seconds` measures completed source reads. A warm ordinary -protected request or discovery request performs no policy writer read. Root and -audio upgrades intentionally keep a final uncached authoritative tenant/policy -binding after the early evidence gate: one read on a cache hit, two on a cold -successful upgrade. Its optional-to-required recheck preserves the unarchive and -host-remap race fence, as well as early refusal and deadline ordering. - -Issuer-binding/JWKS caches keep their existing bounds; unavailable dependencies -refuse required admission, rather than treating it as optional. - -WebSocket upgrades also bind tenant identity and its policy from the same primary -row. An earlier missing or optional policy result cannot authorize a socket for a -required row that becomes serving during lookup (for example, on unarchive). -Before upgrade, enforce that bound row's requirement even when the early check -ran in Shadow. Off never promotes a stored requirement. This changes no discovery fields. - -Database-sourced deployments load the FI binding snapshot and command components -only in evaluating modes. Off does not depend on that snapshot and cannot report -required-admission readiness. Configure finite -`BUZZ_NIP_FI_MAX_CONNECTION_LIFETIME_SECS`, issuer JWKS and command configuration -before enforcing any row in Shadow or Enforce. Missing required components return -503 only when FI is enabled. An optional row -still follows global off/shadow behavior, including signed and unsigned legacy -proofs where the endpoint permits them. - -Both `GET /info` and root NIP-11 negotiation return `Cache-Control: no-store`. -An unknown or non-serving Host returns generic 404. Outside Off, policy lookup -failure returns 503. Off uses ordinary tenant routing and returns generic 404 on -a tenant lookup failure, without consulting FI policy. The top-level `requires_nip_fi` is a host-specific boolean. Existing -`limitation.federated_identity` and `federated_identity` descriptor fields retain -capability semantics: advertise when global mode evaluates OR the host requires -FI. Shadow optional hosts still advertise capability. Off hosts advertise neither -FI capability nor an effective requirement, even when stored policy is required. No issuer, audience or identity -provider address is added to discovery. Existing clients can keep reading their -current fields; new clients must use the requirement field to distinguish optional -capability from mandatory evidence. +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_UPGRADES_ENABLED=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. + +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. + +## Request enforcement and discovery + +`off` ignores stored requirements; `shadow` enforces stored true; `enforce` is a +global requirement floor; `deny-protected` denies protected traffic. Report +`effective_required` alongside stored state so disable under enforce and enable +under off cannot be mistaken for effective changes. Evaluating policy lookups +fail closed on errors; off does not require an FI policy read. + +Host policy comes from the writer, bound to community identity and lifecycle. +Optional cache leases last at most one second; enable excludes refills and drains +them. Fail-closed cache ordering may retain old required state for the remaining +lease after disable. It cannot renew that stale lease. `/info` advertises effective +`requires_nip_fi` without exposing operator receipts. ### Existing connections -The existing community revalidator also reads durable authentication policy from -the writer by immutable community ID for communities with local sockets. Each -policy read has a 500 ms budget. A failed read retains that community's sockets, -continues the sweep, and retries on a later pass. No Redis notification or client -request is needed. An upgrade callback that registers after a scan is caught on -the next pass. +The periodic revalidator reads stored policy without row-locking transactions. +After disable it stops requesting required-policy closes; a concurrent sweep may +request one last non-FI close from its earlier snapshot. That client reconnects +under optional policy. FI-admitted sockets retain their finite lifetime and then +reconnect under current policy. Disable does not reopen or force-logout sessions. +Re-enable repeats the optional-lease drain; non-FI root/audio sockets close through +existing revalidation. Valid FI-admitted sockets retain existing rules. -When effective mode requires FI, legacy and optional-shadow sockets receive -Close 1008 (`community authentication upgraded`) through the existing transport -cleanup paths. Only the enforced FI admission marker preserves a socket; later -NIP-42 AUTH does not turn a legacy socket into an FI admission. Valid FI sessions -retain their normal expiry and revocation rules. Other communities stay open. -Off and repair mode retain their existing behavior; Off suspends FI enforcement. - -`BUZZ_COMMUNITY_REVALIDATE_INTERVAL_SECS` defaults to 30 seconds, clamped to 1–300 seconds. -This interval is not a deadline through database failure, sweep duration, -overload or a paused relay. Closure requires a successful sweep on each running -compatible relay. Already-running HTTP requests and descendant work may finish. -There is no boot inventory, drain ACK, or durable fleet-completion certification. -The operator receipt remains `pending` even after successful local closure. - -For each serving relay, observe -`buzz_community_auth_policy_checks_total{outcome="required"}` advancing after a -required policy change, with no sustained `error` or `timeout` outcomes. -Other bounded outcomes are `optional` and `inactive`; no community labels are -emitted. `buzz_community_auth_policy_disconnects_total` counts legacy socket -cancellation requests. Verify actual Close 1008 on idle root/audio probes and -successful FI reconnects on that relay; the counters alone do not certify -transport retirement or fleet completion. A relay without local sockets has no -policy checks to report. Account for that when probing each serving relay. - -There is no new feature flag. The first successful sweep after deployment can -close existing legacy sockets in already-required communities. Count required rows and assess concurrent -reconnect load before rollout; this change does not spread those reconnects. -Keep new upgrades disabled throughout mixed-version deployment. A binary rollback -needs no migration and restores the old receipt reason; clients must tolerate -unknown pending reasons. An older binary cannot provide this eventual-closure -behavior, so retain a compatible binary or contain affected communities instead -of assuming rollback undoes accepted policy. - -### Staging and containment - -Outside Off, this changes every serving host, including free hosts: protected -routes and discovery require a valid primary policy lease. Off retains ordinary routing. Unmapped discovery previously could -return a generic document; it now returns 404. Policy failures return 503. -Database-source startup loads its FI binding snapshot only in evaluating modes; -Off does not load it. A failed load still fails startup in Shadow or Enforce. The local dependency gauge -`buzz_nip_fi_required_admission_ready` must be 1 before accepting any upgrade; -it covers verifier, deny map and finite lifetime, not verification of socket closure. -Binding and JWKS readiness must also be verified through a signed request. - -Keep `BUZZ_NIP_FI_UPGRADES_ENABLED=false` throughout a mixed-version rollout -until EVERY acceptance path implements the host-lock lease-drain protocol. -Uncached serving pods can coexist with caching pods, but an old acceptance -writer cannot safely coexist with cached optional leases. Before rolling an -acceptance writer back or removing this protocol, disable new acceptance on -every writer and verify that disablement; keep the lease-aware writers until -in-flight acceptance has finished. Disabling new calls does not undo existing -requirements. Existing required-community admission, eventual socket closure -and revocation prerequisites still apply. Never enable an older acceptance path -while any caching pod serves optional communities. -Older pods may log an unknown `CommunityHost` cache-invalidation message during -the mixed-version rollout. They keep serving through their existing uncached -policy reads; missing that best-effort notification does not weaken the lease -protocol. The acceptance-writer gate above still applies. - -Keep `BUZZ_NIP_FI_UPGRADES_ENABLED=false` during the initial staging rollout. -Start with zero required rows and record a 30-minute baseline using representative -free signed/unsigned HTTP, root/audio WS and discovery traffic. Compare identical -traffic before and after this change. Record request rate, policy-read p95/p99, -writer pool acquisition p99 (`buzz_db_pool_acquire_duration_seconds`, -`pool_role=writer`, `operation=authorization`), policy timeout/error count, HTTP -503 rate and startup/restart success. A local PostgreSQL 16 probe measured 100 -serial reads in 13.36 ms; that is not a production capacity result. - -Initial staging abort criteria (validate before increasing traffic): any policy -timeout, policy-induced 503, unexpected free-admission denial or startup failure; -policy-read p99 above 50 ms; writer acquisition p99 above 20 ms or more than twice -its pre-change baseline; total 503 rate more than 0.1 percentage point above -baseline over five minutes. These are conservative rollout limits, not measured -production guarantees. Do not expand until the full 30-minute run stays within -them and the operator has reviewed measured results. Verify unknown-host 404 and -dependency-failure 503 with no-store explicitly. - -Then test a disposable required community with complete FI dependencies, in both -Shadow and Enforce: unsigned/missing evidence denies, signed valid FI succeeds, -root/audio upgrades agree, discovery agrees, and a pending deletion request does -not weaken the requirement. Before production acceptance, verify idle legacy root/audio closure after a -durable policy change, FI reconnects and isolation on every serving relay. No -production verification is established by local tests. - -Before any required row exists, an operator may stop the rollout and restore the -previous binary after verifying the primary contains zero required rows. Once a -requirement has been accepted, a pre-511 binary in Shadow is unsafe: it ignores -that row. Off explicitly suspends FI; global Enforce still requires all hosts. -Stop new upgrade calls, retain policy and receipt records, and keep an enforcing -binary while repairing dependencies. If admission cannot be restored, contain the -affected serving hosts with an operator-approved repair-mode/traffic action; -Off explicitly disables FI; use it only as an operator mode decision and never -clear accepted policy. Returning to Shadow restores stored requirements. No automatic fleet -drain or deployment is prescribed here. Existing receipt recovery remains usable -while new acceptances are disabled. +## Mistaken acceptance and containment -Required sessions admitted under global Shadow are real enforced FI sessions. -The existing global-Shadow admin disconnect path still only observes would-close; -A separate revocation change must close these enforced sessions independently of global rollout mode, -while preserving the separate optional-shadow registry. Before enabling upgrade -acceptance for any serving community in Shadow, deploy and verify that local -and cross-pod disconnect behavior, including real Redis propagation. Keep upgrade -acceptance disabled until that prerequisite is met; admission tests alone do not -establish it. This socket sweep does not change that independent revocation prerequisite. +Stop an uncertain initiating caller and preserve its operation evidence. Read +current policy before deciding whether to issue a new explicit CAS disable. +A timeout is not permission to discard the original request or edit SQL. +Disable is a supported operator action; acceptance is no longer monotonic. +Last-issuer removal is allowed after disable, and re-enable must recheck trust +and the exact binding. Removing trust never implicitly clears stored policy. + +Close the mutation gate to stop new changes while keeping recovery available. +Use established archive/lifecycle containment or repair FI trust as appropriate; +never broaden trust to make a probe pass. Global off suspends FI everywhere while +preserving stored policy and receipts; it is not per-community disable. Binary +rollback does not undo acceptance and must not restore unfenced writers. No live +mutation or deployment is implied by this procedure. diff --git a/docs/operator-nip-fi-bindings.md b/docs/operator-nip-fi-bindings.md index 4bc0b500c0f..2fc21a1b501 100644 --- a/docs/operator-nip-fi-bindings.md +++ b/docs/operator-nip-fi-bindings.md @@ -6,7 +6,7 @@ each community in `community_nip_fi_issuers`. The relay writes these rows when it creates a community; `buzz-admin nip-fi` backfills and edits them. To make an existing community require federated sign-in, use the separate -[community upgrade command and manual test](operator-nip-fi-upgrade.md). +[community policy management command](operator-nip-fi-upgrade.md). An issuer binding alone does not change that community's required policy. A host may hold a binding only when it is exactly one DNS label under the diff --git a/docs/operator-nip-fi-upgrade.md b/docs/operator-nip-fi-upgrade.md index 40fb517e75d..db3b0d16038 100644 --- a/docs/operator-nip-fi-upgrade.md +++ b/docs/operator-nip-fi-upgrade.md @@ -1,213 +1,75 @@ -# Require NIP-FI on an existing community +# Manage a community's NIP-FI requirement -`buzz-admin nip-fi-upgrade` makes one signed HTTP call to the existing relay -operator API. It needs no database access, application startup, sponsorship or -billing workflow. `enable` accepts the saved upgrade tuple; `status` reads its -durable receipt. Both require an authorized deployment operator key. -The [upgrade API contract](community-auth-upgrades.md) defines the receipt and -policy semantics used below. +Use `buzz-admin nip-fi-policy` to enable, disable or inspect a community's +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. -This command is a trusted operator surface. The operator must independently -authenticate the community owner and verify the owner's registered public key -before putting it in `actor_pubkey`. A caller-supplied public key is not proof -of the owner's identity. The relay still checks current ownership, community -identity, issuer trust and the upgrade admission gate. This command does not -replace an application's owner authentication or admission policy. +Before changing policy, apply the schema and roll compatible management writers +across the whole fleet with `BUZZ_NIP_FI_UPGRADES_ENABLED=false`. 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 +actor mediated by the operator must be that owner. See the +[policy contract](community-auth-upgrades.md) and [binding guide](operator-nip-fi-bindings.md). -## Before the test +Read current preconditions first: -Use a disposable staging community whose policy is not yet required. Verify its -UUID, canonical lowercase host, current owner and active issuer binding against -authoritative records. Use the current owner's key for both public-key fields. -The issuer must match the relay's provisioning configuration and active trust. - -Verify `BUZZ_NIP_FI_MODE=shadow` on **every serving replica** before connecting -the test client. Shadow observes optional communities and enforces their stored -required policy after an upgrade. `off` ignores stored requirements; `enforce` -already requires sign-in everywhere. Either mode invalidates this transition -test. Keep shadow mode unchanged throughout the test and record it in evidence. - -All serving relay replicas must support persisted required policy and closing -existing sessions after an upgrade. Apply their required migrations and finish -the rollout before the test. The deployment must already have authorized -`BUZZ_NIP_FI_UPGRADES_ENABLED=true`; this command does not change that gate. -Only the deployment's authorized operator may enable that gate, after verifying -the fleet prerequisites in the API contract. Record that operator's approval. -Use a client that supports required-policy discovery and federated sign-in. - -Identify the configured `RELAY_OPERATOR_API_ORIGIN` and a key in -`RELAY_OPERATOR_PUBKEYS`. The origin is the operator API origin, not the target -community URL. Use HTTPS outside a trusted internal mesh. Provision the key as -a protected file/Secret through the deployment's existing secret process; never -paste it into arguments, logs or a committed manifest. Do not borrow a client -or managed agent key. - -## Save the operation, then leave the client connected +```sh +buzz-admin nip-fi-policy --origin https://operator.example.org --key-file operator.key \ + status --community-id c9acf9a6-37d5-4af1-8df9-92a165637b01 --host team.communities.example.org +``` -Save `request.json` with one newly generated operation UUID and the verified -values. Keep this file unchanged for retries. The command refuses noncanonical -hosts or public keys before sending a POST. These are example values only: +Save a new request file with a fresh operation UUID and the exact +`expected_policy_generation` and `expected_requires_nip_fi` from that response: ```json { - "operation_id": "deecfc7a-9ec2-4ec5-b15b-9e567ad59740", - "community_id": "00fb4822-7930-40fc-9eed-315926f83ca7", - "expected_host": "test.communities.example.org", - "actor_pubkey": "", - "expected_owner_pubkey": "", - "expected_issuer": "https://issuer.example.org" + "operation_id": "c2ffcc20-9505-48fd-b6c7-446aa833bcd1", + "community_id": "c9acf9a6-37d5-4af1-8df9-92a165637b01", + "expected_host": "team.communities.example.org", + "actor_pubkey": "", + "expected_owner_pubkey": "", + "expected_issuer": "https://identity.example.org", + "requires_nip_fi": true, + "expected_policy_generation": 0, + "expected_requires_nip_fi": false } ``` -Open the test community in an existing client and confirm it can read and send -a message without federated sign-in. Keep that session connected. Record the -client version, relay image digests and time, plus the community's `/info` -response showing `requires_nip_fi: false`. Do not sign out, restart the client, -or change relay configuration during the acceptance test. - -## Run one pod - -Use a relay image containing this command, pinned to its verified digest. The -normal relay image includes `/usr/local/bin/buzz-admin`. Check the command's -`--help` before the test if using a previously built image. - -Select the staging context and namespace explicitly. Create the input ConfigMap -from the saved request, and use an existing Secret with a `key` entry: - ```sh -kubectl --context "$STAGING_CONTEXT" -n "$NAMESPACE" create configmap nip-fi-upgrade-input \ - --from-file=request.json +buzz-admin nip-fi-policy --origin https://operator.example.org --key-file operator.key \ + enable --request policy.json +buzz-admin nip-fi-policy --origin https://operator.example.org --key-file operator.key \ + status --operation-id c2ffcc20-9505-48fd-b6c7-446aa833bcd1 ``` -Replace the image, origin and Secret name below with reviewed deployment -values. Save as `upgrade-pod.yaml`. This is a single Pod, with no Job controller, -retry policy or application/service labels. It mounts only the input and key. -Use the deployment's approved network/mesh settings if its operator API needs -them; confirm the pod can reach that API without adding it to serving workloads. -If a mesh sidecar is injected, use the platform's approved one-shot completion -mechanism (for example, native sidecars). An ordinary sidecar can keep this Pod -running after the command exits; retain the upgrade container's exit status and -logs, and arrange sidecar shutdown without weakening required network policy. - -```yaml -apiVersion: v1 -kind: Pod -metadata: - name: nip-fi-upgrade-enable -spec: - restartPolicy: Never - activeDeadlineSeconds: 60 - automountServiceAccountToken: false - securityContext: - runAsNonRoot: true - runAsUser: 1000 - runAsGroup: 1000 - fsGroup: 1000 - containers: - - name: upgrade - image: ghcr.io/block/buzz@sha256: - command: [/usr/local/bin/buzz-admin] - args: - - nip-fi-upgrade - - --origin - - https://operator.example.org - - --key-file - - /operator/key - - enable - - --request - - /input/request.json - volumeMounts: - - {name: operator, mountPath: /operator, readOnly: true} - - {name: input, mountPath: /input, readOnly: true} - volumes: - - name: operator - secret: - secretName: reviewed-upgrade-operator - defaultMode: 0440 - - name: input - configMap: - name: nip-fi-upgrade-input -``` - -```sh -kubectl --context "$STAGING_CONTEXT" -n "$NAMESPACE" create -f upgrade-pod.yaml -kubectl --context "$STAGING_CONTEXT" -n "$NAMESPACE" logs -f pod/nip-fi-upgrade-enable -c upgrade -kubectl --context "$STAGING_CONTEXT" -n "$NAMESPACE" get pod nip-fi-upgrade-enable -o json -``` - -The last command records the pod UID, observed image ID and container exit code. -Do not use Pod phase alone: the expected accepted outcome in this relay version -is exit **10**, which Kubernetes labels Failed. This is acceptance, not a command -failure or a certified fleet-wide connection drain. - -| Exit | Meaning | -| --- | --- | -| 10 | Expected success: valid matching receipt, `requires_nip_fi: true`, state `pending` | -| 12 | Valid acceptance receipt, target/trust currently `unavailable` | -| 5 | Request error or unconfirmed result; acceptance may already have happened | - -This relay version never reports `active`. The command reserves exit 0 for a -future valid `active` receipt with no pending reasons; do not wait for it here. -`connection_closure_unverified` is expected even after successful socket sweeps: -the receipt does not certify fleet-wide closure. Continue with the client test. -Retain and investigate other/unknown pending reasons; they remain unverified. - -Each HTTP call has a 30-second timeout, no automatic retry and no redirects. -The command verifies the returned operation, operator, required policy and -positive generation. For enable it also verifies every saved tuple field. - -## Confirm policy and recover the same operation - -Create another pod from the same manifest with a new pod name and replace the -`enable --request /input/request.json` arguments with: - -```yaml - - status - - --operation-id - - deecfc7a-9ec2-4ec5-b15b-9e567ad59740 -``` - -Use the actual saved operation UUID, same API origin and same operator key. -Compare the receipt's complete tuple and generation with the saved evidence. -A successful receipt must report `requires_nip_fi: true`. Also read the target -community's `/info` and confirm it advertises required policy. - -After timeout, response loss or pod termination, status may recover an accepted -receipt. If it cannot confirm acceptance, repeat enable with the **same saved -request** and a new pod name. Never create a new operation UUID to escape an -uncertain result. `pending` and `unavailable` do not undo required policy; retain -their state and reasons. A pending receipt with `connection_closure_unverified` -does not prevent the manual client test from passing on observed evidence. -An unavailable target or unknown obligations require investigation. -Turning off admission, deleting the pod or rolling back the command cannot undo -an accepted policy change. - -## Observe the existing client - -Record `BUZZ_COMMUNITY_REVALIDATE_INTERVAL_SECS` on every serving replica (default -30 seconds, clamped to 1–300). Before the test, choose and record a finite client -reconnect/sign-in budget from that client's configured reconnect behavior. Wait -at least the largest replica interval plus that client budget after acceptance. -Record the resulting observation deadline. If sign-in has not appeared by then, -record the test as failed or blocked with logs; do not wait indefinitely. - -1. After the upgrade, verify the already-connected client demands federated - sign-in before continuing to read or send in the community. Let the client - handle the disconnect/reconnect. Do not manually sign out or restart it. -2. Complete sign-in with the test user's allowed issuer, then verify reading and - sending work again in that same community. -3. Record a failure if access continues without sign-in, sign-in never appears, - or access does not resume. A changed `/info` response alone is not a pass. - -Record the exact invocation/manifest, saved request, operator public key, -community UUID/host, pod UID/observed image ID, all serving relay versions and -shadow modes, revalidator intervals, client reconnect budget/deadline, -client version, pre/post `/info`, receipt/generation/state, timestamps and observed -client behavior. Keep secret key bytes and authorization headers out of evidence. -Mark unexecuted steps as **not run**, and keep policy confirmation separate from -the client result. Export logs and pod metadata before deleting the temporary -pods and input ConfigMap. Use a fresh staging community for another first-upgrade -test; do not clear persisted policy to reset this one. -For a mistaken acceptance, stop the caller and follow the API contract's -containment procedure. Archive or isolate the affected community through the -deployment's authorized operator process; there is no policy rollback command. +To disable, save a **new** file/UUID with `requires_nip_fi:false`, omit +`expected_issuer`, and copy freshly read preconditions. Run `disable --request` +with that file. The subcommand checks the saved action before reading the key +or sending anything. A same-state operation also advances the generation, +creating a barrier against earlier uncommitted requests. + +After `policy_conflict`, re-read current policy and use a new UUID for the revised +intent. After a timeout or lost response, keep the complete original file and UUID: +read its status or retry that exact file. A committed replay never reapplies policy. + +| Exit | Result | +|---|---| +| 0 | Current-state read, or applied stored disable | +| 10 | Enable accepted; connection closure remains unverified (`pending`) | +| 11 | Historical acceptance superseded by a later generation | +| 12 | Receipt target or required trust unavailable | +| 5 | Invalid input, API refusal or uncertain transport/response | + +Inspect `mode` and `effective_required` separately from the stored flag. +`enforce` keeps FI required after disable; `off` suspends FI after enable. +`shadow` follows the stored flag. An enable receipt remains pending even after +local sweeps, since it cannot certify fleet-wide connection closure. Required +host caches can over-restrict for their remaining one-second lease after disable. +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 legacy `nip-fi-upgrade enable/status` command remains for old saved requests. +Use the general command for new operations; legacy GET hides general CAS receipts. diff --git a/migrations/0084_community_auth_policy.sql b/migrations/0084_community_auth_policy.sql new file mode 100644 index 00000000000..d92d8681a51 --- /dev/null +++ b/migrations/0084_community_auth_policy.sql @@ -0,0 +1,8 @@ +SET LOCAL lock_timeout = '5s'; +SET LOCAL statement_timeout = '30s'; + +-- Retain old receipt intent and INSERT compatibility without a data rewrite. +ALTER TABLE community_auth_upgrades + ADD COLUMN requested_requires_nip_fi BOOLEAN NOT NULL DEFAULT true, + ADD COLUMN expected_policy_generation BIGINT, + ADD COLUMN expected_requires_nip_fi BOOLEAN; diff --git a/schema/tables/public/community_auth_upgrades.sql b/schema/tables/public/community_auth_upgrades.sql index cd4785adcce..89e2604bfbb 100644 --- a/schema/tables/public/community_auth_upgrades.sql +++ b/schema/tables/public/community_auth_upgrades.sql @@ -10,5 +10,8 @@ CREATE TABLE community_auth_upgrades ( expected_issuer TEXT NOT NULL, operator_pubkey TEXT NOT NULL, policy_generation BIGINT NOT NULL CHECK (policy_generation > 0), + requested_requires_nip_fi BOOLEAN NOT NULL DEFAULT true, + expected_policy_generation BIGINT, + expected_requires_nip_fi BOOLEAN, accepted_at TIMESTAMPTZ NOT NULL DEFAULT now() ); From 87c9491404bb596d31aebb09311fd5ef3ab3c3e2 Mon Sep 17 00:00:00 2001 From: Brad Seiler Date: Fri, 6 Nov 2026 11:09:32 -0500 Subject: [PATCH 03/16] Reuse the configured NIP-FI mode label in policy status Co-authored-by: Honor Signed-off-by: Brad Seiler --- .../buzz-relay/src/api/operator/community_policy.rs | 8 +------- crates/buzz-relay/src/nip_fi_config.rs | 13 +++++++++---- docs/community-auth-upgrades.md | 2 +- 3 files changed, 11 insertions(+), 12 deletions(-) diff --git a/crates/buzz-relay/src/api/operator/community_policy.rs b/crates/buzz-relay/src/api/operator/community_policy.rs index df55a0c1d4e..ecf86985fb2 100644 --- a/crates/buzz-relay/src/api/operator/community_policy.rs +++ b/crates/buzz-relay/src/api/operator/community_policy.rs @@ -49,14 +49,8 @@ fn unavailable(error: buzz_db::DbError) -> Error { } fn mode_fields(state: &AppState, required: Option) -> Value { - use buzz_auth::NipFiMode; let mode = state.config.nip_fi.mode; - let name = match mode { - NipFiMode::Off => "off", - NipFiMode::Shadow => "shadow", - NipFiMode::Enforce => "enforce", - NipFiMode::DenyProtected => "deny-protected", - }; + let name = crate::nip_fi_config::mode_name(mode); serde_json::json!({ "mode": name, "effective_required": required.map(|r| mode.with_requirement(r).restricts()), diff --git a/crates/buzz-relay/src/nip_fi_config.rs b/crates/buzz-relay/src/nip_fi_config.rs index fa1b54f4d7a..a6ed9c3280c 100644 --- a/crates/buzz-relay/src/nip_fi_config.rs +++ b/crates/buzz-relay/src/nip_fi_config.rs @@ -620,14 +620,19 @@ fn parse_mode() -> Result { } } -/// A missing-setting startup error naming the configured mode. -fn mode_requires(mode: NipFiMode, missing: &str) -> ConfigError { - let value = match mode { +/// Stable configured mode name for operator diagnostics and status. +pub(crate) fn mode_name(mode: NipFiMode) -> &'static str { + match mode { NipFiMode::Enforce => "enforce", NipFiMode::Shadow => "shadow", NipFiMode::Off => "off", NipFiMode::DenyProtected => "deny_protected", - }; + } +} + +/// A missing-setting startup error naming the configured mode. +fn mode_requires(mode: NipFiMode, missing: &str) -> ConfigError { + let value = mode_name(mode); ConfigError::InvalidValue(format!("BUZZ_NIP_FI_MODE={value} but {missing}")) } diff --git a/docs/community-auth-upgrades.md b/docs/community-auth-upgrades.md index 0d246da120e..1692b5ee521 100644 --- a/docs/community-auth-upgrades.md +++ b/docs/community-auth-upgrades.md @@ -100,7 +100,7 @@ General status can read legacy receipts, with null CAS preconditions. ## Request enforcement and discovery `off` ignores stored requirements; `shadow` enforces stored true; `enforce` is a -global requirement floor; `deny-protected` denies protected traffic. Report +global requirement floor; `deny_protected` denies protected traffic. Report `effective_required` alongside stored state so disable under enforce and enable under off cannot be mistaken for effective changes. Evaluating policy lookups fail closed on errors; off does not require an FI policy read. From 829c899eaae498a7ba0af1a307d60260758bafa2 Mon Sep 17 00:00:00 2001 From: Brad Seiler Date: Fri, 6 Nov 2026 11:10:34 -0500 Subject: [PATCH 04/16] Cover required-state barriers and effective disable behavior Co-authored-by: Honor Signed-off-by: Brad Seiler --- crates/buzz-db/src/runtime/migration.rs | 12 +++++------ crates/buzz-db/src/store/community_auth.rs | 7 ++++--- .../store/community_auth/postgres_tests.rs | 11 ++++++++++ .../operator/postgres_tests/policy_tests.rs | 21 +++++++++++++++++++ scripts/run-tests.sh | 6 ++++++ 5 files changed, 48 insertions(+), 9 deletions(-) diff --git a/crates/buzz-db/src/runtime/migration.rs b/crates/buzz-db/src/runtime/migration.rs index 2f0154ba9cc..b3d7ccebda4 100644 --- a/crates/buzz-db/src/runtime/migration.rs +++ b/crates/buzz-db/src/runtime/migration.rs @@ -3234,12 +3234,6 @@ mod postgres_tests { .expect("second post-apply reconciliation"); } - /// Migrations 0076 and 0078 refuse a populated table whose index was not - /// prebuilt concurrently. A relay that auto-migrated through earlier - /// releases and holds artifact data must still boot through every migration. When a - /// bounded inline build cannot take its lock it must fail closed without - /// recording that migration, keeping earlier migrations and each index - /// already built in its own transaction. #[tokio::test] #[ignore = "requires Postgres"] async fn policy_receipt_expand_preserves_populated_rows_and_legacy_inserts() { @@ -3279,6 +3273,12 @@ mod postgres_tests { } } + /// Migrations 0076 and 0078 refuse a populated table whose index was not + /// prebuilt concurrently. A relay that auto-migrated through earlier + /// releases and holds artifact data must still boot through every migration. When a + /// bounded inline build cannot take its lock it must fail closed without + /// recording that migration, keeping earlier migrations and each index + /// already built in its own transaction. #[tokio::test] #[ignore = "requires Postgres"] async fn populated_artifact_tables_auto_migrate_through_every_migration() { diff --git a/crates/buzz-db/src/store/community_auth.rs b/crates/buzz-db/src/store/community_auth.rs index 64ef7c2364d..da65559eccf 100644 --- a/crates/buzz-db/src/store/community_auth.rs +++ b/crates/buzz-db/src/store/community_auth.rs @@ -102,15 +102,16 @@ pub struct UpgradeRecord { pub actor_pubkey: String, /// Owner precondition at acceptance. pub expected_owner_pubkey: String, - /// Issuer precondition at acceptance. + /// Issuer precondition at acceptance; omitted for disable. + #[serde(skip_serializing_if = "String::is_empty")] pub expected_issuer: String, /// Operator authorized to recover this receipt. pub operator_pubkey: String, - /// Accepted generation; concurrent enables converge on this value. + /// Immutable generation accepted for this operation. 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. + /// Current availability, with no certification of fleet-wide socket closure. pub state: String, /// Extensible activation obligations; unknown reasons must remain pending. pub pending_reasons: Vec, 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 8f38806a1e0..ee6354becd0 100644 --- a/crates/buzz-db/src/store/community_auth/postgres_tests.rs +++ b/crates/buzz-db/src/store/community_auth/postgres_tests.rs @@ -659,6 +659,17 @@ async fn policy_enable_disable_reenable_history_and_identity() { ); } assert_eq!(policy(&db, &tuple).await.policy_generation, 3); + // Reasserting required policy fences an earlier uncommitted disable too. + let stale_disable = intent(&tuple, false, 3, true); + let reassert = intent(&tuple, true, 3, true); + assert_eq!(apply(&db, &reassert).await.acceptance.policy_generation, 4); + assert_eq!( + db.set_community_nip_fi_policy(&stale_disable, Ok(())) + .await + .unwrap(), + PolicyOutcome::Refused("policy_conflict") + ); + assert!(policy(&db, &tuple).await.requires_nip_fi); } #[tokio::test] diff --git a/crates/buzz-relay/src/api/operator/postgres_tests/policy_tests.rs b/crates/buzz-relay/src/api/operator/postgres_tests/policy_tests.rs index 163ad67ca4b..b3f0163ec68 100644 --- a/crates/buzz-relay/src/api/operator/postgres_tests/policy_tests.rs +++ b/crates/buzz-relay/src/api/operator/postgres_tests/policy_tests.rs @@ -195,6 +195,27 @@ mod postgres_tests { .await; assert_eq!(current["effective_required"], mode.restricts()); } + // Enforce remains a global floor even after a stored disable. + Arc::make_mut(&mut Arc::get_mut(&mut state).unwrap().config).nip_fi_upgrades_enabled = true; + body["operation_id"] = Uuid::new_v4().to_string().into(); + body["requires_nip_fi"] = false.into(); + body["expected_policy_generation"] = 3.into(); + body["expected_requires_nip_fi"] = true.into(); + body.as_object_mut().unwrap().remove("expected_issuer"); + let response = read_json( + fresh_upgrade_request( + state.clone(), + &operator, + "POST", + path, + Some(body.to_string()), + ) + .await, + ) + .await; + assert_eq!(response["receipt"]["state"], "applied"); + assert_eq!(response["receipt"]["current_requires_nip_fi"], false); + assert_eq!(response["effective_required"], true); let invalid = fresh_upgrade_request( state.clone(), &operator, diff --git a/scripts/run-tests.sh b/scripts/run-tests.sh index b37790861ec..384b0d2a5d8 100755 --- a/scripts/run-tests.sh +++ b/scripts/run-tests.sh @@ -165,6 +165,12 @@ run_unit_tests() { run_test_step "buzz-admin NIP-FI binding command tests" \ cargo test -p buzz-admin nip_fi::tests -- --nocapture + run_test_step "buzz-admin NIP-FI upgrade command tests" \ + cargo test -p buzz-admin nip_fi_upgrade::tests -- --nocapture + + run_test_step "buzz-admin NIP-FI policy command tests" \ + cargo test -p buzz-admin nip_fi_policy::tests -- --nocapture + run_test_step "buzz-admin user-deletion intake command tests" \ cargo test -p buzz-admin user_deletions::tests -- --nocapture From 3c586a384eb443f956831d783a4e92b856ddacb8 Mon Sep 17 00:00:00 2001 From: Brad Seiler Date: Fri, 6 Nov 2026 11:14:25 -0500 Subject: [PATCH 05/16] Allow later migrations in listed-name upgrade tests Co-authored-by: Honor Signed-off-by: Brad Seiler --- .../src/runtime/tests/listed_channel_name_postgres_tests.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/buzz-db/src/runtime/tests/listed_channel_name_postgres_tests.rs b/crates/buzz-db/src/runtime/tests/listed_channel_name_postgres_tests.rs index 8ebd4a49680..e34c8523544 100644 --- a/crates/buzz-db/src/runtime/tests/listed_channel_name_postgres_tests.rs +++ b/crates/buzz-db/src/runtime/tests/listed_channel_name_postgres_tests.rs @@ -556,7 +556,7 @@ async fn migration_schema_listed_name_index_needs_the_renames_then_builds_inline migration::run_migrations(&pool) .await .expect("migrate a populated table after the renames"); - assert_eq!(ledger_version(&pool).await, BEFORE_INDEX + 1); + assert!(ledger_version(&pool).await > BEFORE_INDEX); let mut expected = vec![ (kept, "square-ios".to_owned()), renamed(newer, "Square-iOS"), @@ -633,6 +633,6 @@ async fn migration_schema_listed_name_index_validates_a_prebuild() { .await .expect("accept the prebuild without waiting for channel writers"); writer.rollback().await.unwrap(); - assert_eq!(ledger_version(&pool).await, BEFORE_INDEX + 1); + assert!(ledger_version(&pool).await > BEFORE_INDEX); assert_eq!(index_oid(&pool).await, oid, "the prebuild is not replaced"); } From 7e734cae26888957f49785ee9dcea492173b03ab Mon Sep 17 00:00:00 2001 From: Brad Seiler Date: Fri, 6 Nov 2026 11:15:50 -0500 Subject: [PATCH 06/16] Keep policy outcomes compact and satisfy workspace Clippy Co-authored-by: Honor Signed-off-by: Brad Seiler --- crates/buzz-db/src/store/community_auth.rs | 10 +++++----- .../buzz-db/src/store/community_auth/postgres_tests.rs | 4 ++-- crates/buzz-relay/src/api/operator/community_policy.rs | 2 +- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/crates/buzz-db/src/store/community_auth.rs b/crates/buzz-db/src/store/community_auth.rs index da65559eccf..6f08b8ef335 100644 --- a/crates/buzz-db/src/store/community_auth.rs +++ b/crates/buzz-db/src/store/community_auth.rs @@ -83,7 +83,7 @@ impl PolicyRecord { #[derive(Debug, Clone, PartialEq, Eq)] pub enum PolicyOutcome { /// Durable acceptance or identical read-only replay. - Accepted(PolicyRecord), + Accepted(Box), /// No mutation or receipt was committed. Refused(&'static str), } @@ -353,7 +353,7 @@ impl Db { .write_community_nip_fi_policy(request, None, configured_trust) .await? { - PolicyOutcome::Accepted(record) => UpgradeOutcome::Accepted(record.legacy()), + PolicyOutcome::Accepted(record) => UpgradeOutcome::Accepted((*record).legacy()), PolicyOutcome::Refused(code) => UpgradeOutcome::Refused(code), }, ) @@ -365,7 +365,7 @@ impl Db { intent: Option<(bool, i64, bool)>, configured_trust: std::result::Result<(), &'static str>, ) -> Result { - let desired = intent.map_or(true, |i| i.0); + 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 connection = crate::observability::acquire_writer( @@ -394,7 +394,7 @@ impl Db { && record.expected_policy_generation == expected_generation && record.expected_requires_nip_fi == expected_state { - PolicyOutcome::Accepted(record) + PolicyOutcome::Accepted(Box::new(record)) } else { PolicyOutcome::Refused("idempotency_conflict") }, @@ -522,7 +522,7 @@ impl Db { .fetch_one(&mut *tx) .await?; tx.commit().await?; - Ok(PolicyOutcome::Accepted(record)) + Ok(PolicyOutcome::Accepted(Box::new(record))) } } 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 ee6354becd0..e4aff87ecb8 100644 --- a/crates/buzz-db/src/store/community_auth/postgres_tests.rs +++ b/crates/buzz-db/src/store/community_auth/postgres_tests.rs @@ -581,7 +581,7 @@ async fn apply(db: &Db, request: &PolicyRequest) -> PolicyRecord { else { panic!("policy refused") }; - record + *record } #[tokio::test] @@ -609,7 +609,7 @@ async fn policy_enable_disable_reenable_history_and_identity() { db.set_community_nip_fi_policy(&enable, Err("upgrades_disabled")) .await .unwrap(), - PolicyOutcome::Accepted(old) + PolicyOutcome::Accepted(Box::new(old)) ); let reenable = intent(&tuple, true, 2, false); let third = apply(&db, &reenable).await; diff --git a/crates/buzz-relay/src/api/operator/community_policy.rs b/crates/buzz-relay/src/api/operator/community_policy.rs index ecf86985fb2..7e6c61d5be4 100644 --- a/crates/buzz-relay/src/api/operator/community_policy.rs +++ b/crates/buzz-relay/src/api/operator/community_policy.rs @@ -136,7 +136,7 @@ pub async fn set_community_nip_fi_policy( buzz_core::CommunityId::from_uuid(record.acceptance.community_id), &record.acceptance.expected_host, )); - Ok(response(&state, record)) + Ok(response(&state, *record)) } PolicyOutcome::Refused(code) => Err(coded_api_error( match code { From 9b072f5a0c62173b24b3751b30fd3cfac2cda382 Mon Sep 17 00:00:00 2001 From: Brad Seiler Date: Fri, 6 Nov 2026 11:19:29 -0500 Subject: [PATCH 07/16] Use a borrowed operator slice in the policy HTTP fixture Co-authored-by: Honor Signed-off-by: Brad Seiler --- .../src/api/operator/postgres_tests/policy_tests.rs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/crates/buzz-relay/src/api/operator/postgres_tests/policy_tests.rs b/crates/buzz-relay/src/api/operator/postgres_tests/policy_tests.rs index b3f0163ec68..5c06953a003 100644 --- a/crates/buzz-relay/src/api/operator/postgres_tests/policy_tests.rs +++ b/crates/buzz-relay/src/api/operator/postgres_tests/policy_tests.rs @@ -7,9 +7,10 @@ mod postgres_tests { let operator = Keys::generate(); let owner = Keys::generate(); let (binding, _, host) = reserved_host_policy(); - let mut state = operator_test_state_with_policy(&[operator.clone()], Some(binding)) - .await - .unwrap(); + let mut state = + operator_test_state_with_policy(std::slice::from_ref(&operator), Some(binding)) + .await + .unwrap(); Arc::get_mut(&mut state).unwrap().nip98_replay = Arc::new(SeenOnceReplayGuard(Mutex::new(HashSet::new()))); let issuer_policy = buzz_auth::IssuerPolicy::new( From d3bf876202f98e9a5382e3dd3bd0e34dbc3d7f84 Mon Sep 17 00:00:00 2001 From: Brad Seiler Date: Fri, 6 Nov 2026 12:28:00 -0500 Subject: [PATCH 08/16] Clarify community FI policy operator safeguards Co-authored-by: Honor Signed-off-by: Brad Seiler --- .env.example | 4 ++-- crates/buzz-relay/src/config.rs | 2 +- docs/community-auth-upgrades.md | 6 ++++-- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/.env.example b/.env.example index 9d821ab559e..ba4c9cf2301 100644 --- a/.env.example +++ b/.env.example @@ -470,8 +470,8 @@ RUST_LOG=buzz_relay=debug,buzz_datastore=info,buzz_db=debug,buzz_auth=debug,buzz # 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. +# Permit community FI policy enables and disables only after the whole fleet +# supports policy management. A closed gate preserves receipt recovery/status. BUZZ_NIP_FI_UPGRADES_ENABLED=false # NIP-CL compact membership discovery. Deploy fleet-wide dark before enabling. diff --git a/crates/buzz-relay/src/config.rs b/crates/buzz-relay/src/config.rs index 215c1472fca..fc0ea1e62cc 100644 --- a/crates/buzz-relay/src/config.rs +++ b/crates/buzz-relay/src/config.rs @@ -408,7 +408,7 @@ 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 + /// Permit new community FI policy enables and disables. Default off; receipt /// recovery/status remain available (`BUZZ_NIP_FI_UPGRADES_ENABLED`). pub nip_fi_upgrades_enabled: bool, diff --git a/docs/community-auth-upgrades.md b/docs/community-auth-upgrades.md index 1692b5ee521..751fc536da4 100644 --- a/docs/community-auth-upgrades.md +++ b/docs/community-auth-upgrades.md @@ -127,8 +127,10 @@ Stop an uncertain initiating caller and preserve its operation evidence. Read current policy before deciding whether to issue a new explicit CAS disable. A timeout is not permission to discard the original request or edit SQL. Disable is a supported operator action; acceptance is no longer monotonic. -Last-issuer removal is allowed after disable, and re-enable must recheck trust -and the exact binding. Removing trust never implicitly clears stored policy. +Disable removes the required-policy block on last-issuer removal; the separate +last-binding guard still applies. The operator unbind command keeps the last +issuer for bindable hosts. Re-enable must recheck trust and the exact binding. +Removing trust never implicitly clears stored policy. Close the mutation gate to stop new changes while keeping recovery available. Use established archive/lifecycle containment or repair FI trust as appropriate; From a024ea8b69a4b17bdaf1236be7cd77cea540ed64 Mon Sep 17 00:00:00 2001 From: Brad Seiler Date: Fri, 6 Nov 2026 12:28:18 -0500 Subject: [PATCH 09/16] Describe the legacy FI endpoint command accurately Co-authored-by: Honor Signed-off-by: Brad Seiler --- docs/operator-nip-fi-upgrade.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/operator-nip-fi-upgrade.md b/docs/operator-nip-fi-upgrade.md index db3b0d16038..a13edd7bd11 100644 --- a/docs/operator-nip-fi-upgrade.md +++ b/docs/operator-nip-fi-upgrade.md @@ -71,5 +71,5 @@ 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 legacy `nip-fi-upgrade enable/status` command remains for old saved requests. +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. From 1e930a3f9bbc1d29d8c9b229799bbfb0e681ddfa Mon Sep 17 00:00:00 2001 From: Brad Seiler Date: Fri, 6 Nov 2026 12:34:19 -0500 Subject: [PATCH 10/16] Rename NIP-FI policy mutation gate Co-authored-by: Honor Signed-off-by: Brad Seiler --- .env.example | 2 +- crates/buzz-relay/src/api/operator.rs | 14 ++++---- .../src/api/operator/community_policy.rs | 2 +- .../operator/postgres_tests/policy_tests.rs | 9 +++-- crates/buzz-relay/src/config.rs | 36 ++++++++----------- .../tests/policy_sweep_postgres_tests.rs | 2 +- docs/community-auth-upgrades.md | 2 +- docs/operator-nip-fi-upgrade.md | 2 +- 8 files changed, 30 insertions(+), 39 deletions(-) diff --git a/.env.example b/.env.example index ba4c9cf2301..98916ec2793 100644 --- a/.env.example +++ b/.env.example @@ -472,7 +472,7 @@ RUST_LOG=buzz_relay=debug,buzz_datastore=info,buzz_db=debug,buzz_auth=debug,buzz # Permit community FI policy enables and disables only after the whole fleet # supports policy management. A closed gate preserves receipt recovery/status. -BUZZ_NIP_FI_UPGRADES_ENABLED=false +BUZZ_NIP_FI_CHANGES=false # NIP-CL compact membership discovery. Deploy fleet-wide dark before enabling. BUZZ_MEMBERSHIP_SNAPSHOT_ENABLED=false diff --git a/crates/buzz-relay/src/api/operator.rs b/crates/buzz-relay/src/api/operator.rs index 8672aed591e..9af82c6d922 100644 --- a/crates/buzz-relay/src/api/operator.rs +++ b/crates/buzz-relay/src/api/operator.rs @@ -1284,7 +1284,7 @@ pub async fn enable_community_nip_fi( 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 { + let trust = if state.config.nip_fi_changes { upgrade_configured_trust(&state, &host, &request.expected_issuer) } else { Err("upgrades_disabled") @@ -3164,9 +3164,8 @@ mod postgres_tests { .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); + 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) @@ -3237,7 +3236,7 @@ mod postgres_tests { .await .unwrap() .is_none()); - Arc::make_mut(&mut Arc::get_mut(&mut state).unwrap().config).nip_fi_upgrades_enabled = true; + 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); @@ -3313,8 +3312,7 @@ mod postgres_tests { .policy_generation, 1 ); - Arc::make_mut(&mut Arc::get_mut(&mut state).unwrap().config).nip_fi_upgrades_enabled = - false; + 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); @@ -3358,7 +3356,7 @@ mod postgres_tests { .await .unwrap() .is_none()); - Arc::make_mut(&mut Arc::get_mut(&mut state).unwrap().config).nip_fi_upgrades_enabled = true; + 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. diff --git a/crates/buzz-relay/src/api/operator/community_policy.rs b/crates/buzz-relay/src/api/operator/community_policy.rs index 7e6c61d5be4..622aec2b3e2 100644 --- a/crates/buzz-relay/src/api/operator/community_policy.rs +++ b/crates/buzz-relay/src/api/operator/community_policy.rs @@ -112,7 +112,7 @@ pub async fn set_community_nip_fi_policy( expected_issuer: request.expected_issuer.unwrap_or_default(), operator_pubkey: operator.to_hex(), }; - let preflight = if !state.config.nip_fi_upgrades_enabled { + let preflight = if !state.config.nip_fi_changes { Err("upgrades_disabled") } else if request.requires_nip_fi { upgrade_configured_trust(&state, &tuple.expected_host, &tuple.expected_issuer) diff --git a/crates/buzz-relay/src/api/operator/postgres_tests/policy_tests.rs b/crates/buzz-relay/src/api/operator/postgres_tests/policy_tests.rs index 5c06953a003..19b1e0823d4 100644 --- a/crates/buzz-relay/src/api/operator/postgres_tests/policy_tests.rs +++ b/crates/buzz-relay/src/api/operator/postgres_tests/policy_tests.rs @@ -29,7 +29,7 @@ mod postgres_tests { let config = Arc::make_mut(&mut Arc::get_mut(&mut state).unwrap().config); config.nip_fi.registry.insert(issuer_policy); config.nip_fi.mode = buzz_auth::NipFiMode::Shadow; - config.nip_fi_upgrades_enabled = false; + config.nip_fi_changes = false; assert_eq!( provision_community(state.clone(), &operator, &host, &owner) .await @@ -66,7 +66,7 @@ mod postgres_tests { .await; assert_eq!(disabled.status(), StatusCode::SERVICE_UNAVAILABLE); assert_eq!(read_json(disabled).await["code"], "upgrades_disabled"); - Arc::make_mut(&mut Arc::get_mut(&mut state).unwrap().config).nip_fi_upgrades_enabled = true; + Arc::make_mut(&mut Arc::get_mut(&mut state).unwrap().config).nip_fi_changes = true; let mut history = Vec::new(); for (generation, desired) in [(1, true), (2, false), (3, true)] { body["operation_id"] = Uuid::new_v4().to_string().into(); @@ -175,8 +175,7 @@ mod postgres_tests { ) .await; assert_eq!(read_json(response).await["code"], "idempotency_conflict"); - Arc::make_mut(&mut Arc::get_mut(&mut state).unwrap().config).nip_fi_upgrades_enabled = - false; + Arc::make_mut(&mut Arc::get_mut(&mut state).unwrap().config).nip_fi_changes = false; let response = fresh_upgrade_request( state.clone(), &operator, @@ -197,7 +196,7 @@ mod postgres_tests { assert_eq!(current["effective_required"], mode.restricts()); } // Enforce remains a global floor even after a stored disable. - Arc::make_mut(&mut Arc::get_mut(&mut state).unwrap().config).nip_fi_upgrades_enabled = true; + Arc::make_mut(&mut Arc::get_mut(&mut state).unwrap().config).nip_fi_changes = true; body["operation_id"] = Uuid::new_v4().to_string().into(); body["requires_nip_fi"] = false.into(); body["expected_policy_generation"] = 3.into(); diff --git a/crates/buzz-relay/src/config.rs b/crates/buzz-relay/src/config.rs index fc0ea1e62cc..3c2deba25f6 100644 --- a/crates/buzz-relay/src/config.rs +++ b/crates/buzz-relay/src/config.rs @@ -409,8 +409,8 @@ pub struct Config { pub nip_fi_provision_binding: Option, /// Permit new community FI policy enables and disables. Default off; receipt - /// recovery/status remain available (`BUZZ_NIP_FI_UPGRADES_ENABLED`). - pub nip_fi_upgrades_enabled: bool, + /// recovery/status remain available (`BUZZ_NIP_FI_CHANGES`). + pub nip_fi_changes: bool, /// Database resolver bounds (`BUZZ_NIP_FI_RESOLVER_*`, /// `BUZZ_NIP_FI_SNAPSHOT_*`). Validated in every mode, so a bad value @@ -1508,7 +1508,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_changes: parse_bool("BUZZ_NIP_FI_CHANGES", false)?, nip_fi_resolver_bounds, nip_fi, }) @@ -1582,33 +1582,27 @@ mod tests { } #[test] - fn nip_fi_upgrade_acceptance_is_explicitly_default_off() { + fn nip_fi_changes_are_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); + let before = std::env::var("BUZZ_NIP_FI_CHANGES").ok(); + std::env::remove_var("BUZZ_NIP_FI_CHANGES"); + assert!(!Config::from_env().unwrap().nip_fi_changes); 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}" - ); + std::env::set_var("BUZZ_NIP_FI_CHANGES", value); + assert!(Config::from_env().unwrap().nip_fi_changes, "{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_CHANGES", value); + assert!(!Config::from_env().unwrap().nip_fi_changes, "{value}"); } - std::env::set_var("BUZZ_NIP_FI_UPGRADES_ENABLED", "treu"); + std::env::set_var("BUZZ_NIP_FI_CHANGES", "treu"); assert!( matches!(Config::from_env(), Err(ConfigError::InvalidValue(message)) - if message.contains("BUZZ_NIP_FI_UPGRADES_ENABLED must be true or false")) + if message.contains("BUZZ_NIP_FI_CHANGES 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"), + Some(value) => std::env::set_var("BUZZ_NIP_FI_CHANGES", value), + None => std::env::remove_var("BUZZ_NIP_FI_CHANGES"), } } 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 433c1f8164c..5c3426d376f 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 @@ -121,7 +121,7 @@ mod postgres_tests { let mut configured = (*base).clone(); let config = Arc::make_mut(&mut configured.config); config.nip_fi.mode = buzz_auth::NipFiMode::Shadow; - config.nip_fi_upgrades_enabled = false; + config.nip_fi_changes = false; config.nip_fi.max_connection_lifetime_secs = 3600; config.nip_fi.communities = crate::nip_fi_config::NipFiCommunities::for_test_any_host( TEST_COMMUNITY_URI, diff --git a/docs/community-auth-upgrades.md b/docs/community-auth-upgrades.md index 751fc536da4..602d389b681 100644 --- a/docs/community-auth-upgrades.md +++ b/docs/community-auth-upgrades.md @@ -86,7 +86,7 @@ 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_UPGRADES_ENABLED=false`, then explicitly enable mutations. +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. diff --git a/docs/operator-nip-fi-upgrade.md b/docs/operator-nip-fi-upgrade.md index a13edd7bd11..1ef4d1bad28 100644 --- a/docs/operator-nip-fi-upgrade.md +++ b/docs/operator-nip-fi-upgrade.md @@ -6,7 +6,7 @@ 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_UPGRADES_ENABLED=false`. Then explicitly +across the whole fleet with `BUZZ_NIP_FI_CHANGES=false`. 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 From 636986f1ec88cf752e8ff570beed5d825a5b73fe Mon Sep 17 00:00:00 2001 From: Brad Seiler Date: Fri, 6 Nov 2026 13:16:14 -0500 Subject: [PATCH 11/16] Remove the legacy FI operator command Consolidate signing, bounded transport and input validation in the policy command; retain their functional tests and selectors. Co-authored-by: Honor Signed-off-by: Brad Seiler --- Justfile | 2 +- crates/buzz-admin/src/main.rs | 6 - crates/buzz-admin/src/nip_fi_policy.rs | 267 +++++++++++- crates/buzz-admin/src/nip_fi_upgrade.rs | 534 ------------------------ scripts/run-tests.sh | 3 - 5 files changed, 254 insertions(+), 558 deletions(-) delete mode 100644 crates/buzz-admin/src/nip_fi_upgrade.rs diff --git a/Justfile b/Justfile index f96c8477854..96be962855b 100644 --- a/Justfile +++ b/Justfile @@ -468,7 +468,7 @@ test-unit: # parser, validation, and propagation-evidence tests, and the partition # audit outcome contract, NIP-FI policy/backfill, and user-deletion intake. cargo nextest run -p buzz-admin \ - -E 'test(storage_snapshot) + test(/^communities::tests::/) + test(/^tests::communities_/) + test(=tests::partition_audit_outcomes_keep_their_json_names_and_exit_codes) + test(/^nip_fi::tests::/) + test(/^nip_fi_upgrade::tests::/) + test(/^nip_fi_policy::tests::/) + test(/^user_deletions::tests::/)' + -E 'test(storage_snapshot) + test(/^communities::tests::/) + test(/^tests::communities_/) + test(=tests::partition_audit_outcomes_keep_their_json_names_and_exit_codes) + test(/^nip_fi::tests::/) + test(/^nip_fi_policy::tests::/) + test(/^user_deletions::tests::/)' # Multi-tenant conformance gate (buzz-conformance): the independent # replay checker + golden fixtures. No infra — pure in-process trace # replay — so it belongs in the unit job. Run all targets (lib + the diff --git a/crates/buzz-admin/src/main.rs b/crates/buzz-admin/src/main.rs index 52499781300..86551425fa0 100644 --- a/crates/buzz-admin/src/main.rs +++ b/crates/buzz-admin/src/main.rs @@ -23,7 +23,6 @@ mod communities; mod deletions; mod nip_fi; mod nip_fi_policy; -mod nip_fi_upgrade; mod user_deletions; use std::future::Future; @@ -126,8 +125,6 @@ enum Command { #[command(subcommand)] command: nip_fi::NipFiCommand, }, - /// Require NIP-FI through the relay's existing signed operator API. - NipFiUpgrade(nip_fi_upgrade::Args), /// Change or inspect a community stored NIP-FI requirement. NipFiPolicy(nip_fi_policy::Args), /// Durable CLI-only whole-community deletion control plane. @@ -224,9 +221,6 @@ async fn run(cli: Cli) -> Result { Command::NipFiPolicy(args) => nip_fi_policy::run(args) .await .map_err(|e| anyhow::anyhow!("{e:#}")), - Command::NipFiUpgrade(args) => nip_fi_upgrade::run(args) - .await - .map_err(|error| anyhow::anyhow!("{error:#}")), Command::ReconcileChannels { channel, relay_key } => { reconcile_channels(channel, relay_key).await?; Ok(0) diff --git a/crates/buzz-admin/src/nip_fi_policy.rs b/crates/buzz-admin/src/nip_fi_policy.rs index 21131c6209e..66b18d630db 100644 --- a/crates/buzz-admin/src/nip_fi_policy.rs +++ b/crates/buzz-admin/src/nip_fi_policy.rs @@ -1,14 +1,23 @@ //! General stored community policy management with immutable saved intent. -use std::path::PathBuf; +use std::{ + io::Read, + path::{Path, PathBuf}, + time::Duration, +}; use anyhow::{ensure, Context, Result}; +use base64::{engine::general_purpose::STANDARD, Engine}; use clap::{Args as ClapArgs, Subcommand}; -use nostr::Keys; +use nostr::{EventBuilder, JsonUtil, Keys, Kind, Tag}; +use reqwest::{redirect::Policy, Method}; use serde::{Deserialize, Serialize}; use serde_json::Value; +use sha2::{Digest, Sha256}; +use url::Url; use uuid::Uuid; -use crate::nip_fi_upgrade::{endpoint, read_bounded, signed_call, UpgradeRequest}; +const PATH: &str = "/operator/communities/nip-fi-policy"; +const MAX_BYTES: usize = 64 * 1024; /// Explicit operator destination and signing key for community policy management. #[derive(Debug, ClapArgs)] @@ -77,16 +86,136 @@ impl Request { }, "enable requires expected_issuer; disable must omit it" ); - UpgradeRequest { - operation_id: self.operation_id, - community_id: self.community_id, - expected_host: self.expected_host.clone(), - actor_pubkey: self.actor_pubkey.clone(), - expected_owner_pubkey: self.expected_owner_pubkey.clone(), - expected_issuer: self.expected_issuer.clone().unwrap_or_default(), + ensure!( + !self.operation_id.is_nil() && !self.community_id.is_nil(), + "UUIDs must not be nil" + ); + let host = crate::communities::normalize_host_authority(&self.expected_host) + .map_err(anyhow::Error::msg)?; + ensure!( + host == self.expected_host && !host.contains(':'), + "expected_host must be canonical lowercase without a port or trailing dot" + ); + for key in [&self.actor_pubkey, &self.expected_owner_pubkey] { + ensure!( + key.len() == 64 + && key + .bytes() + .all(|c| c.is_ascii_digit() || (b'a'..=b'f').contains(&c)), + "owner public keys must be 64 lowercase hex characters" + ); } - .validate() + Ok(()) + } +} + +fn read_bounded(path: &Path, limit: usize) -> Result> { + let mut bytes = Vec::new(); + std::fs::File::open(path) + .with_context(|| format!("cannot open {}", path.display()))? + .take((limit + 1) as u64) + .read_to_end(&mut bytes) + .with_context(|| format!("cannot read {}", path.display()))?; + ensure!(bytes.len() <= limit, "input file exceeds size limit"); + Ok(bytes) +} + +fn endpoint(origin: &str) -> Result { + let base = Url::parse(origin).context("invalid operator origin")?; + ensure!( + matches!(base.scheme(), "http" | "https") + && base.host_str().is_some() + && base.username().is_empty() + && base.password().is_none() + && base.path() == "/" + && base.query().is_none() + && base.fragment().is_none(), + "operator origin must be an HTTP(S) origin without credentials, path, query or fragment" + ); + Ok(base.join(PATH)?) +} + +fn authorization(keys: &Keys, method: &Method, url: &Url, body: Option<&[u8]>) -> Result { + let mut tags = vec![ + Tag::parse(["u", url.as_str()])?, + Tag::parse(["method", method.as_str()])?, + Tag::parse(["nonce", &Uuid::new_v4().to_string()])?, + ]; + if let Some(body) = body { + tags.push(Tag::parse(["payload", &hex::encode(Sha256::digest(body))])?); } + let event = EventBuilder::new(Kind::HttpAuth, "") + .tags(tags) + .sign_with_keys(keys)?; + Ok(format!("Nostr {}", STANDARD.encode(event.as_json()))) +} + +/// Bounded, signed single call shared by the operator policy commands. +async fn signed_call(keys: &Keys, url: Url, body: Option>) -> Result { + let method = if body.is_some() { + Method::POST + } else { + Method::GET + }; + let auth = authorization(keys, &method, &url, body.as_deref())?; + let client = reqwest::Client::builder() + .redirect(Policy::none()) + .retry(reqwest::retry::never()) + .timeout(Duration::from_secs(30)) + .build()?; + let mut call = client.request(method, url).header("Authorization", auth); + if let Some(body) = body { + call = call.header("Content-Type", "application/json").body(body); + } + let mut response = call + .send() + .await + .context("unconfirmed: request failed; recover the same operation_id")?; + let status = response.status(); + let mut bytes = Vec::new(); + while let Some(chunk) = response + .chunk() + .await + .context("unconfirmed: response interrupted; recover the same operation_id")? + { + ensure!( + bytes.len() + chunk.len() <= MAX_BYTES, + "unconfirmed: response exceeds size limit" + ); + bytes.extend_from_slice(&chunk); + } + if !status.is_success() { + // Preserve known machine-readable codes, without echoing arbitrary response bodies. + let error = serde_json::from_slice::(&bytes).ok(); + let code = error.as_ref().and_then(|value| value["code"].as_str()); + let detail = match code { + Some( + code @ ("invalid_request" + | "community_not_found" + | "operation_not_found" + | "idempotency_conflict" + | "policy_conflict" + | "policy_generation_exhausted" + | "host_conflict" + | "owner_conflict" + | "lifecycle_conflict" + | "issuer_conflict" + | "upgrades_disabled" + | "authorization_unavailable"), + ) => code, + _ if error.is_none() => "non-JSON error response", + _ => "unrecognized error code", + }; + anyhow::bail!("operator API returned HTTP {} ({detail}); on policy_conflict re-read policy and use a new UUID; after response loss recover the same saved request and UUID", status.as_u16()); + } + // Print only structured relay output, never transport headers or signing material. + let receipt: Value = serde_json::from_slice(&bytes).with_context(|| { + format!( + "unconfirmed: invalid JSON response (HTTP {})", + status.as_u16() + ) + })?; + Ok(receipt) } fn receipt_exit_code( @@ -136,8 +265,7 @@ pub async fn run(args: Args) -> Result { } Command::Status { .. } => None, }; - let mut url = endpoint(&args.origin, None)?; - url.set_path("/operator/communities/nip-fi-policy"); + let mut url = endpoint(&args.origin)?; if let Command::Status { operation_id, community_id, @@ -209,7 +337,9 @@ pub async fn run(args: Args) -> Result { }; let stored = value .get("receipt") - .map_or(&value["requires_nip_fi"], |r| &r["current_requires_nip_fi"]); + .map_or(&value["expected_requires_nip_fi"], |r| { + &r["current_requires_nip_fi"] + }); if stored.is_boolean() && value["effective_required"].is_boolean() && *stored != value["effective_required"] @@ -223,6 +353,7 @@ pub async fn run(args: Args) -> Result { #[cfg(test)] mod tests { use super::*; + use axum::{routing::any, Json, Router}; fn saved(desired: bool) -> Request { Request { @@ -365,4 +496,112 @@ mod tests { } } } + #[tokio::test] + async fn refuses_noncanonical_inputs_before_reading_key_or_sending_post() { + for (field, value) in [ + ("expected_host", "TEST.communities.example.org"), + ("expected_host", "test.communities.example.org."), + ("expected_host", "test.communities.example.org:443"), + ("expected_host", "https://test.communities.example.org"), + ("actor_pubkey", &"A".repeat(64)), + ("expected_owner_pubkey", &"A".repeat(64)), + ("actor_pubkey", &"a".repeat(63)), + ("expected_owner_pubkey", &"g".repeat(64)), + ] { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("request.json"); + let mut input = serde_json::to_value(saved(true)).unwrap(); + input[field] = value.into(); + std::fs::write(&path, serde_json::to_vec(&input).unwrap()).unwrap(); + let error = run(Args { + origin: "http://127.0.0.1:1".into(), + key_file: dir.path().join("absent-key"), + command: Command::Enable { request: path }, + }) + .await + .unwrap_err(); + let message = format!("{error:#}"); + assert!( + message.contains("host") || message.contains("public keys"), + "{message}" + ); + assert!( + !message.contains("absent-key"), + "key read preceded validation: {message}" + ); + } + } + + #[tokio::test] + async fn error_responses_preserve_status_and_known_codes_without_echoing_bodies() { + use axum::http::StatusCode; + + for (status, body, expected) in [ + ( + 409, + r#"{"code":"owner_conflict","error":"private detail"}"#, + "owner_conflict", + ), + (409, r#"{"code":"issuer_conflict"}"#, "issuer_conflict"), + (409, r#"{"code":"host_conflict"}"#, "host_conflict"), + ( + 409, + r#"{"code":"idempotency_conflict"}"#, + "idempotency_conflict", + ), + (503, r#"{"code":"upgrades_disabled"}"#, "upgrades_disabled"), + ( + 502, + "private detail", + "non-JSON error response", + ), + ( + 409, + r#"{"code":"private detail"}"#, + "unrecognized error code", + ), + ] { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let origin = format!("http://{}", listener.local_addr().unwrap()); + let app = Router::new().route( + PATH, + any(move || async move { (StatusCode::from_u16(status).unwrap(), body) }), + ); + let server = tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); + let dir = tempfile::tempdir().unwrap(); + let key_file = dir.path().join("key"); + let request_file = dir.path().join("request.json"); + let keys = Keys::generate(); + std::fs::write(&key_file, keys.secret_key().display_secret().to_string()).unwrap(); + std::fs::write(&request_file, serde_json::to_vec(&saved(true)).unwrap()).unwrap(); + let error = run(Args { + origin, + key_file, + command: Command::Enable { + request: request_file, + }, + }) + .await + .unwrap_err(); + let message = format!("{error:#}"); + assert!(message.contains(&format!("HTTP {status}")), "{message}"); + assert!(message.contains(expected), "{message}"); + assert!(!message.contains("private detail"), "{message}"); + assert!(!message.contains(&keys.secret_key().display_secret().to_string())); + server.abort(); + } + } + + #[test] + fn origin_cannot_select_a_different_path_or_embed_credentials() { + for origin in [ + "https://user:password@example.org", + "https://example.org/wrong", + "https://example.org/?q=x", + "https://example.org/#fragment", + "file:///tmp/key", + ] { + assert!(endpoint(origin).is_err(), "accepted {origin}"); + } + } } diff --git a/crates/buzz-admin/src/nip_fi_upgrade.rs b/crates/buzz-admin/src/nip_fi_upgrade.rs deleted file mode 100644 index 842e3fcc083..00000000000 --- a/crates/buzz-admin/src/nip_fi_upgrade.rs +++ /dev/null @@ -1,534 +0,0 @@ -//! One-shot calls to the existing operator upgrade API; no database or worker startup. - -use std::{io::Read, path::Path, path::PathBuf, time::Duration}; - -use anyhow::{ensure, Context, Result}; -use base64::{engine::general_purpose::STANDARD, Engine}; -use clap::{Args as ClapArgs, Subcommand}; -use nostr::{EventBuilder, JsonUtil, Keys, Kind, Tag}; -use reqwest::{redirect::Policy, Method}; -use serde::{Deserialize, Serialize}; -use serde_json::Value; -use sha2::{Digest, Sha256}; -use url::Url; -use uuid::Uuid; - -const PATH: &str = "/operator/communities/nip-fi-upgrade"; -const MAX_BYTES: usize = 64 * 1024; - -/// Explicit operator destination and protected signing material. -#[derive(Debug, ClapArgs)] -pub struct Args { - /// Must match the relay's RELAY_OPERATOR_API_ORIGIN (HTTP for a trusted mesh, or HTTPS). - #[arg(long)] - origin: String, - /// File containing the authorized deployment operator's hex secret key or nsec. - #[arg(long)] - key_file: PathBuf, - #[command(subcommand)] - command: Command, -} - -#[derive(Debug, Subcommand)] -enum Command { - /// Accept or recover an upgrade using the exact saved request tuple. - Enable { - /// JSON with operation_id, community_id, expected_host, actor_pubkey, - /// expected_owner_pubkey and expected_issuer. Reuse after response loss. - #[arg(long)] - request: PathBuf, - }, - /// Read the original operator's durable receipt without sending a mutation. - Status { - #[arg(long)] - operation_id: Uuid, - }, -} - -#[derive(Debug, Deserialize, Serialize)] -#[serde(deny_unknown_fields)] -pub(super) struct UpgradeRequest { - pub(super) operation_id: Uuid, - pub(super) community_id: Uuid, - pub(super) expected_host: String, - pub(super) actor_pubkey: String, - pub(super) expected_owner_pubkey: String, - pub(super) expected_issuer: String, -} - -impl UpgradeRequest { - pub(super) fn validate(&self) -> Result<()> { - ensure!( - !self.operation_id.is_nil() && !self.community_id.is_nil(), - "UUIDs must not be nil" - ); - let host = crate::communities::normalize_host_authority(&self.expected_host) - .map_err(anyhow::Error::msg)?; - ensure!( - host == self.expected_host && !host.contains(':'), - "expected_host must be canonical lowercase without a port or trailing dot" - ); - for key in [&self.actor_pubkey, &self.expected_owner_pubkey] { - ensure!( - key.len() == 64 - && key - .bytes() - .all(|c| c.is_ascii_digit() || (b'a'..=b'f').contains(&c)), - "owner public keys must be 64 lowercase hex characters" - ); - } - Ok(()) - } -} - -pub(super) fn read_bounded(path: &Path, limit: usize) -> Result> { - let mut bytes = Vec::new(); - std::fs::File::open(path) - .with_context(|| format!("cannot open {}", path.display()))? - .take((limit + 1) as u64) - .read_to_end(&mut bytes) - .with_context(|| format!("cannot read {}", path.display()))?; - ensure!(bytes.len() <= limit, "input file exceeds size limit"); - Ok(bytes) -} - -pub(super) fn endpoint(origin: &str, operation: Option) -> Result { - let base = Url::parse(origin).context("invalid operator origin")?; - ensure!( - matches!(base.scheme(), "http" | "https") - && base.host_str().is_some() - && base.username().is_empty() - && base.password().is_none() - && base.path() == "/" - && base.query().is_none() - && base.fragment().is_none(), - "operator origin must be an HTTP(S) origin without credentials, path, query or fragment" - ); - let mut url = base.join(PATH)?; - if let Some(id) = operation { - ensure!(!id.is_nil(), "operation_id must not be nil"); - url.query_pairs_mut() - .append_pair("operation_id", &id.to_string()); - } - Ok(url) -} - -fn authorization(keys: &Keys, method: &Method, url: &Url, body: Option<&[u8]>) -> Result { - let mut tags = vec![ - Tag::parse(["u", url.as_str()])?, - Tag::parse(["method", method.as_str()])?, - Tag::parse(["nonce", &Uuid::new_v4().to_string()])?, - ]; - if let Some(body) = body { - tags.push(Tag::parse(["payload", &hex::encode(Sha256::digest(body))])?); - } - let event = EventBuilder::new(Kind::HttpAuth, "") - .tags(tags) - .sign_with_keys(keys)?; - Ok(format!("Nostr {}", STANDARD.encode(event.as_json()))) -} - -fn receipt_exit_code( - receipt: &Value, - operation_id: Uuid, - operator: &str, - request: Option<&UpgradeRequest>, -) -> Result { - ensure!( - receipt["operation_id"].as_str() == Some(operation_id.to_string().as_str()) - && receipt["operator_pubkey"].as_str() == Some(operator) - && receipt["requires_nip_fi"].as_bool() == Some(true) - && receipt["policy_generation"].as_i64().is_some_and(|g| g > 0), - "unconfirmed: response is not the expected durable acceptance receipt" - ); - if let Some(request) = request { - let expected = serde_json::to_value(request)?; - ensure!( - expected - .as_object() - .context("request must be an object")? - .iter() - .all(|(k, v)| receipt[k] == *v), - "unconfirmed: receipt tuple does not match the saved request" - ); - } - let reasons = receipt["pending_reasons"] - .as_array() - .context("unconfirmed: missing pending reasons")?; - ensure!( - reasons.iter().all(Value::is_string), - "unconfirmed: invalid pending reasons" - ); - match receipt["state"].as_str() { - Some("active") if reasons.is_empty() => Ok(0), - Some("pending") => Ok(10), - Some("unavailable") => Ok(12), - _ => anyhow::bail!("unconfirmed: invalid upgrade state"), - } -} - -/// Send exactly one signed request. A timeout never means the upgrade was rejected. -pub async fn run(args: Args) -> Result { - let request = match &args.command { - Command::Enable { request } => { - let request: UpgradeRequest = - serde_json::from_slice(&read_bounded(request, MAX_BYTES)?)?; - request.validate()?; - Some(request) - } - Command::Status { .. } => None, - }; - let operation_id = match &args.command { - Command::Enable { .. } => request.as_ref().context("missing request")?.operation_id, - Command::Status { operation_id } => *operation_id, - }; - let url = endpoint(&args.origin, request.is_none().then_some(operation_id))?; - // Do not include the secret or its parse error in diagnostics. - let secret = read_bounded(&args.key_file, 1024)?; - let keys = std::str::from_utf8(&secret) - .ok() - .and_then(|s| Keys::parse(s.trim()).ok()) - .context("invalid operator key file")?; - let body = request.as_ref().map(serde_json::to_vec).transpose()?; - let receipt = signed_call(&keys, url, body).await?; - let code = receipt_exit_code( - &receipt, - operation_id, - &keys.public_key().to_hex(), - request.as_ref(), - )?; - println!("{}", serde_json::to_string(&receipt)?); - Ok(code) -} - -/// Bounded, signed single call shared by the operator policy commands. -pub(super) async fn signed_call(keys: &Keys, url: Url, body: Option>) -> Result { - let method = if body.is_some() { - Method::POST - } else { - Method::GET - }; - let auth = authorization(keys, &method, &url, body.as_deref())?; - let client = reqwest::Client::builder() - .redirect(Policy::none()) - .retry(reqwest::retry::never()) - .timeout(Duration::from_secs(30)) - .build()?; - let mut call = client.request(method, url).header("Authorization", auth); - if let Some(body) = body { - call = call.header("Content-Type", "application/json").body(body); - } - let mut response = call - .send() - .await - .context("unconfirmed: request failed; recover the same operation_id")?; - let status = response.status(); - let mut bytes = Vec::new(); - while let Some(chunk) = response - .chunk() - .await - .context("unconfirmed: response interrupted; recover the same operation_id")? - { - ensure!( - bytes.len() + chunk.len() <= MAX_BYTES, - "unconfirmed: response exceeds size limit" - ); - bytes.extend_from_slice(&chunk); - } - if !status.is_success() { - // Preserve known machine-readable codes, without echoing arbitrary response bodies. - let error = serde_json::from_slice::(&bytes).ok(); - let code = error.as_ref().and_then(|value| value["code"].as_str()); - let detail = match code { - Some( - code @ ("invalid_request" - | "community_not_found" - | "operation_not_found" - | "idempotency_conflict" - | "policy_conflict" - | "policy_generation_exhausted" - | "host_conflict" - | "owner_conflict" - | "lifecycle_conflict" - | "issuer_conflict" - | "upgrades_disabled" - | "authorization_unavailable"), - ) => code, - _ if error.is_none() => "non-JSON error response", - _ => "unrecognized error code", - }; - anyhow::bail!("operator API returned HTTP {} ({detail}); on policy_conflict re-read policy and use a new UUID; after response loss recover the same saved request and UUID", status.as_u16()); - } - // Print only structured relay output, never transport headers or signing material. - let receipt: Value = serde_json::from_slice(&bytes).with_context(|| { - format!( - "unconfirmed: invalid JSON response (HTTP {})", - status.as_u16() - ) - })?; - Ok(receipt) -} - -#[cfg(test)] -mod tests { - use super::*; - use axum::{body::Bytes, http::HeaderMap, routing::any, Json, Router}; - use tokio::sync::mpsc; - - fn tuple() -> UpgradeRequest { - let owner = Keys::generate().public_key().to_hex(); - UpgradeRequest { - operation_id: Uuid::new_v4(), - community_id: Uuid::new_v4(), - expected_host: "test.communities.example.org".into(), - actor_pubkey: owner.clone(), - expected_owner_pubkey: owner, - expected_issuer: "https://issuer.example.org".into(), - } - } - - #[tokio::test] - async fn refuses_noncanonical_inputs_before_reading_key_or_sending_post() { - for (field, value) in [ - ("expected_host", "TEST.communities.example.org"), - ("expected_host", "test.communities.example.org."), - ("expected_host", "test.communities.example.org:443"), - ("expected_host", "https://test.communities.example.org"), - ("actor_pubkey", &"A".repeat(64)), - ("expected_owner_pubkey", &"A".repeat(64)), - ("actor_pubkey", &"a".repeat(63)), - ("expected_owner_pubkey", &"g".repeat(64)), - ] { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("request.json"); - let mut input = serde_json::to_value(tuple()).unwrap(); - input[field] = value.into(); - std::fs::write(&path, serde_json::to_vec(&input).unwrap()).unwrap(); - let error = run(Args { - origin: "http://127.0.0.1:1".into(), - key_file: dir.path().join("absent-key"), - command: Command::Enable { request: path }, - }) - .await - .unwrap_err(); - let message = format!("{error:#}"); - assert!( - message.contains("host") || message.contains("public keys"), - "{message}" - ); - assert!( - !message.contains("absent-key"), - "key read preceded validation: {message}" - ); - } - } - - #[tokio::test] - async fn error_responses_preserve_status_and_known_codes_without_echoing_bodies() { - use axum::http::StatusCode; - - for (status, body, expected) in [ - ( - 409, - r#"{"code":"owner_conflict","error":"private detail"}"#, - "owner_conflict", - ), - (409, r#"{"code":"issuer_conflict"}"#, "issuer_conflict"), - (409, r#"{"code":"host_conflict"}"#, "host_conflict"), - ( - 409, - r#"{"code":"idempotency_conflict"}"#, - "idempotency_conflict", - ), - (503, r#"{"code":"upgrades_disabled"}"#, "upgrades_disabled"), - ( - 502, - "private detail", - "non-JSON error response", - ), - ( - 409, - r#"{"code":"private detail"}"#, - "unrecognized error code", - ), - ] { - let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); - let origin = format!("http://{}", listener.local_addr().unwrap()); - let app = Router::new().route( - PATH, - any(move || async move { (StatusCode::from_u16(status).unwrap(), body) }), - ); - let server = tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); - let dir = tempfile::tempdir().unwrap(); - let key_file = dir.path().join("key"); - let request_file = dir.path().join("request.json"); - let keys = Keys::generate(); - std::fs::write(&key_file, keys.secret_key().display_secret().to_string()).unwrap(); - std::fs::write(&request_file, serde_json::to_vec(&tuple()).unwrap()).unwrap(); - let error = run(Args { - origin, - key_file, - command: Command::Enable { - request: request_file, - }, - }) - .await - .unwrap_err(); - let message = format!("{error:#}"); - assert!(message.contains(&format!("HTTP {status}")), "{message}"); - assert!(message.contains(expected), "{message}"); - assert!(!message.contains("private detail"), "{message}"); - assert!(!message.contains(&keys.secret_key().display_secret().to_string())); - server.abort(); - } - } - - fn receipt(request: &UpgradeRequest, keys: &Keys, state: &str) -> Value { - let mut value = serde_json::to_value(request).unwrap(); - value["operator_pubkey"] = keys.public_key().to_hex().into(); - value["policy_generation"] = 1.into(); - value["requires_nip_fi"] = true.into(); - value["state"] = state.into(); - value["pending_reasons"] = serde_json::json!([]); - value - } - - #[tokio::test] - async fn command_signs_exact_post_body_and_get_query_and_preserves_operation() { - let keys = Keys::generate(); - let tuple = tuple(); - let response = receipt(&tuple, &keys, "pending"); - let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); - let origin = format!("http://{}", listener.local_addr().unwrap()); - let (sender, mut requests) = mpsc::channel(4); - let app = Router::new().route( - PATH, - any( - move |method: Method, uri: axum::http::Uri, headers: HeaderMap, body: Bytes| { - let response = response.clone(); - let sender = sender.clone(); - async move { - sender.send((method, uri, headers, body)).await.unwrap(); - Json(response) - } - }, - ), - ); - let server = tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); - let dir = tempfile::tempdir().unwrap(); - let key_file = dir.path().join("key"); - let request_file = dir.path().join("request.json"); - std::fs::write(&key_file, keys.secret_key().display_secret().to_string()).unwrap(); - std::fs::write(&request_file, serde_json::to_vec(&tuple).unwrap()).unwrap(); - - for command in [ - Command::Enable { - request: request_file, - }, - Command::Status { - operation_id: tuple.operation_id, - }, - ] { - assert_eq!( - run(Args { - origin: origin.clone(), - key_file: key_file.clone(), - command - }) - .await - .unwrap(), - 10 - ); - let (method, uri, headers, body) = requests.recv().await.unwrap(); - let encoded = headers["Authorization"] - .to_str() - .unwrap() - .strip_prefix("Nostr ") - .unwrap(); - let event = String::from_utf8(STANDARD.decode(encoded).unwrap()).unwrap(); - let url = format!("{origin}{uri}"); - let pubkey = buzz_auth::verify_nip98_event( - &event, - &url, - method.as_str(), - (method == Method::POST).then_some(body.as_ref()), - ) - .unwrap(); - assert_eq!(pubkey, keys.public_key()); - if method == Method::POST { - assert_eq!( - serde_json::from_slice::(&body).unwrap(), - serde_json::to_value(&tuple).unwrap() - ); - assert!( - buzz_auth::verify_nip98_event(&event, &url, "POST", Some(b"changed")).is_err() - ); - } else { - assert_eq!( - uri.query(), - Some(format!("operation_id={}", tuple.operation_id).as_str()) - ); - assert!(buzz_auth::verify_nip98_event( - &event, - &format!("{origin}{PATH}"), - "GET", - None - ) - .is_err()); - } - } - assert!(requests.try_recv().is_err()); - server.abort(); - } - - #[test] - fn only_matching_required_receipts_can_report_success() { - let keys = Keys::generate(); - let request = tuple(); - let operator = keys.public_key().to_hex(); - for (state, code) in [("active", 0), ("pending", 10), ("unavailable", 12)] { - assert_eq!( - receipt_exit_code( - &receipt(&request, &keys, state), - request.operation_id, - &operator, - Some(&request) - ) - .unwrap(), - code - ); - } - for (field, bad) in [ - ("operation_id", Value::from(Uuid::new_v4().to_string())), - ( - "operator_pubkey", - Value::from(Keys::generate().public_key().to_hex()), - ), - ("community_id", Value::from(Uuid::new_v4().to_string())), - ("requires_nip_fi", Value::from(false)), - ("policy_generation", Value::from(0)), - ("state", Value::from("unknown")), - ("pending_reasons", serde_json::json!(["not_drained"])), - ] { - let mut value = receipt(&request, &keys, "active"); - value[field] = bad; - assert!( - receipt_exit_code(&value, request.operation_id, &operator, Some(&request)).is_err(), - "accepted invalid {field}" - ); - } - } - - #[test] - fn origin_cannot_select_a_different_path_or_embed_credentials() { - for origin in [ - "https://user:password@example.org", - "https://example.org/wrong", - "https://example.org/?q=x", - "https://example.org/#fragment", - "file:///tmp/key", - ] { - assert!(endpoint(origin, None).is_err(), "accepted {origin}"); - } - } -} diff --git a/scripts/run-tests.sh b/scripts/run-tests.sh index 384b0d2a5d8..c8173aa68fa 100755 --- a/scripts/run-tests.sh +++ b/scripts/run-tests.sh @@ -165,9 +165,6 @@ run_unit_tests() { run_test_step "buzz-admin NIP-FI binding command tests" \ cargo test -p buzz-admin nip_fi::tests -- --nocapture - run_test_step "buzz-admin NIP-FI upgrade command tests" \ - cargo test -p buzz-admin nip_fi_upgrade::tests -- --nocapture - run_test_step "buzz-admin NIP-FI policy command tests" \ cargo test -p buzz-admin nip_fi_policy::tests -- --nocapture From 7b495d126f4b9d9441bef5b602ce5636cd003dcf Mon Sep 17 00:00:00 2001 From: Brad Seiler Date: Fri, 6 Nov 2026 13:26:35 -0500 Subject: [PATCH 12/16] 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 | 248 ++++---- 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 | 37 +- docs/operator-nip-fi-policy-client.md | 46 ++ docs/operator-nip-fi-upgrade.md | 13 +- 10 files changed, 288 insertions(+), 811 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 6f08b8ef335..21cee02ad2d 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 e4aff87ecb8..19ccb3e6ac8 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) - ); - assert_eq!(a, b); - assert_eq!(a.policy_generation, c.policy_generation); + 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(())) + ); + 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); - 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; + 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.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 66754b45d4f..c326fe81dce 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 9af82c6d922..41a28de2b75 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 6fc5d9b4cdb..a26063b4a51 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 172895d0caa..45cd825133b 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 5c3426d376f..32121467c7c 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 602d389b681..ff889edc751 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. - -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. +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. + +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 00000000000..0faadc3f1ec --- /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 1ef4d1bad28..95652d65fab 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. From b0d006618e0f04db254b9ccdd27dff72b1f93733 Mon Sep 17 00:00:00 2001 From: Brad Seiler Date: Fri, 6 Nov 2026 13:27:03 -0500 Subject: [PATCH 13/16] Expose one set of current policy preconditions Exercise retained historical receipts and deleted targets through the supported status API; refuse UUID reuse without mutating policy. Co-authored-by: Honor Signed-off-by: Brad Seiler --- .../src/api/operator/community_policy.rs | 1 - .../operator/postgres_tests/policy_tests.rs | 214 +++++++++++++++++- 2 files changed, 205 insertions(+), 10 deletions(-) diff --git a/crates/buzz-relay/src/api/operator/community_policy.rs b/crates/buzz-relay/src/api/operator/community_policy.rs index 622aec2b3e2..ed9d660b935 100644 --- a/crates/buzz-relay/src/api/operator/community_policy.rs +++ b/crates/buzz-relay/src/api/operator/community_policy.rs @@ -199,7 +199,6 @@ pub async fn community_nip_fi_policy_status( let fields = mode_fields(&state, Some(policy.requires_nip_fi)); Ok(Json(serde_json::json!({ "community_id": id, "expected_host": host, - "requires_nip_fi": policy.requires_nip_fi, "policy_generation": policy.policy_generation, "expected_policy_generation": policy.policy_generation, "expected_requires_nip_fi": policy.requires_nip_fi, "mode": fields["mode"], "effective_required": fields["effective_required"], diff --git a/crates/buzz-relay/src/api/operator/postgres_tests/policy_tests.rs b/crates/buzz-relay/src/api/operator/postgres_tests/policy_tests.rs index 19b1e0823d4..db0bbddbede 100644 --- a/crates/buzz-relay/src/api/operator/postgres_tests/policy_tests.rs +++ b/crates/buzz-relay/src/api/operator/postgres_tests/policy_tests.rs @@ -66,7 +66,64 @@ mod postgres_tests { .await; assert_eq!(disabled.status(), StatusCode::SERVICE_UNAVAILABLE); assert_eq!(read_json(disabled).await["code"], "upgrades_disabled"); + for (field, value) in [ + ("operation_id", serde_json::json!(Uuid::nil())), + ("community_id", serde_json::json!(Uuid::nil())), + ("expected_host", serde_json::json!(host.to_uppercase())), + ("actor_pubkey", serde_json::json!("not-a-pubkey")), + ("expected_policy_generation", serde_json::json!(-1)), + ("expected_issuer", serde_json::json!("")), + ("unknown", serde_json::json!(true)), + ] { + let mut invalid = body.clone(); + invalid[field] = value; + let response = fresh_upgrade_request( + state.clone(), + &operator, + "POST", + path, + Some(invalid.to_string()), + ) + .await; + assert_eq!(response.status(), StatusCode::BAD_REQUEST, "{field}"); + assert_eq!(read_json(response).await["code"], "invalid_request"); + } Arc::make_mut(&mut Arc::get_mut(&mut state).unwrap().config).nip_fi_changes = true; + for (field, value, code) in [ + ( + "expected_issuer", + "https://unapproved.example".to_owned(), + "issuer_conflict", + ), + ( + "expected_host", + "outside.example".to_owned(), + "issuer_conflict", + ), + ( + "actor_pubkey", + Keys::generate().public_key().to_hex(), + "owner_conflict", + ), + ( + "expected_owner_pubkey", + Keys::generate().public_key().to_hex(), + "owner_conflict", + ), + ] { + let mut refused = body.clone(); + refused[field] = value.into(); + let response = fresh_upgrade_request( + state.clone(), + &operator, + "POST", + path, + Some(refused.to_string()), + ) + .await; + assert_eq!(response.status(), StatusCode::CONFLICT); + assert_eq!(read_json(response).await["code"], code); + } let mut history = Vec::new(); for (generation, desired) in [(1, true), (2, false), (3, true)] { body["operation_id"] = Uuid::new_v4().to_string().into(); @@ -96,6 +153,73 @@ mod postgres_tests { accepted["receipt"]["state"], if desired { "pending" } else { "applied" } ); + if generation == 1 { + let binding = Arc::make_mut(&mut Arc::get_mut(&mut state).unwrap().config) + .nip_fi_provision_binding + .take(); + let mut fresh = body.clone(); + let refused_id = Uuid::new_v4(); + fresh["operation_id"] = refused_id.to_string().into(); + fresh["expected_policy_generation"] = 1.into(); + fresh["expected_requires_nip_fi"] = true.into(); + let refused = fresh_upgrade_request( + state.clone(), + &operator, + "POST", + path, + Some(fresh.to_string()), + ) + .await; + assert_eq!(refused.status(), StatusCode::SERVICE_UNAVAILABLE); + assert_eq!( + read_json(refused).await["code"], + "authorization_unavailable" + ); + assert!(state + .db + .community_auth_policy_status(refused_id, &operator.public_key().to_hex()) + .await + .unwrap() + .is_none()); + let historical = read_json( + fresh_upgrade_request( + state.clone(), + &operator, + "POST", + path, + Some(body.to_string()), + ) + .await, + ) + .await; + assert_eq!(historical["receipt"]["state"], "unavailable"); + assert_eq!(historical["receipt"]["policy_generation"], 1); + Arc::make_mut(&mut Arc::get_mut(&mut state).unwrap().config) + .nip_fi_provision_binding = binding; + let text = body.to_string(); + let auth = fresh_upgrade_auth( + &operator, + &format!("http://{INGRESS_HOST}{path}"), + "POST", + Some(text.as_bytes()), + ); + let first = operator_request_with_auth( + state.clone(), + "POST", + path, + Some(text.clone()), + auth.clone(), + ) + .await; + assert_eq!(first.status(), StatusCode::OK); + let replayed_signature = + operator_request_with_auth(state.clone(), "POST", path, Some(text), auth).await; + assert_eq!(replayed_signature.status(), StatusCode::UNAUTHORIZED); + assert_eq!( + replayed_signature.headers()[header::CACHE_CONTROL], + "no-store" + ); + } history.push(body.clone()); for old in &history { let id = old["operation_id"].as_str().unwrap(); @@ -125,15 +249,6 @@ mod postgres_tests { "superseded" } ); - let legacy = fresh_upgrade_request( - state.clone(), - &operator, - "GET", - &format!("/operator/communities/nip-fi-upgrade?operation_id={id}"), - None, - ) - .await; - assert_eq!(legacy.status(), StatusCode::NOT_FOUND); let replay = fresh_upgrade_request( state.clone(), &operator, @@ -225,6 +340,87 @@ mod postgres_tests { ) .await; assert_eq!(invalid.status(), StatusCode::BAD_REQUEST); + // Old acceptance rows remain recoverable through the sole supported + // endpoint, including after their target has been deleted. Their null + // preconditions cannot match a new fenced request with the same UUID. + for target in [*community.id.as_uuid(), Uuid::new_v4()] { + let 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,1)") + .bind(id).bind(target).bind(&host).bind(owner.public_key().to_hex()) + .bind(NIP_FI_ISSUER).bind(operator.public_key().to_hex()) + .execute(state.db.pool()).await.unwrap(); + let receipt = read_json( + fresh_upgrade_request( + state.clone(), + &operator, + "GET", + &format!("{path}?operation_id={id}"), + None, + ) + .await, + ) + .await; + assert!(receipt["receipt"]["expected_policy_generation"].is_null()); + assert!(receipt["receipt"]["expected_requires_nip_fi"].is_null()); + assert_eq!(receipt["receipt"]["requires_nip_fi"], true); + assert_eq!( + receipt["receipt"]["state"], + if target == *community.id.as_uuid() { + "superseded" + } else { + "unavailable" + } + ); + if target != *community.id.as_uuid() { + assert!(receipt["receipt"]["current_policy_generation"].is_null()); + assert!(receipt["receipt"]["current_requires_nip_fi"].is_null()); + } + let mut reuse = history[0].clone(); + reuse["operation_id"] = id.to_string().into(); + reuse["community_id"] = target.to_string().into(); + reuse["expected_policy_generation"] = 4.into(); + let conflict = fresh_upgrade_request( + state.clone(), + &operator, + "POST", + path, + Some(reuse.to_string()), + ) + .await; + assert_eq!(conflict.status(), StatusCode::CONFLICT); + assert_eq!(read_json(conflict).await["code"], "idempotency_conflict"); + let hidden = fresh_upgrade_request( + state.clone(), + &Keys::generate(), + "GET", + &format!("{path}?operation_id={id}"), + None, + ) + .await; + assert_eq!(hidden.status(), StatusCode::FORBIDDEN); + } + let current = read_json( + fresh_upgrade_request(state.clone(), &operator, "GET", ¤t_path, None).await, + ) + .await; + assert!(current.get("requires_nip_fi").is_none()); + assert!(current.get("policy_generation").is_none()); + assert_eq!(current["expected_policy_generation"], 4); + assert_eq!(current["expected_requires_nip_fi"], false); + // The removed route has no operator-auth handler even for signed calls. + for method in ["GET", "POST"] { + let gone = fresh_upgrade_request( + state.clone(), + &operator, + method, + "/operator/communities/nip-fi-upgrade", + (method == "POST").then(|| "{}".into()), + ) + .await; + assert_eq!(gone.status(), StatusCode::NOT_FOUND); + } // The control-plane signature and allowlist remain mandatory. let outsider = Keys::generate(); let response = fresh_upgrade_request(state, &outsider, "GET", ¤t_path, None).await; From 698132b34c94da29c197424e2aa3e8bdf13e8387 Mon Sep 17 00:00:00 2001 From: Brad Seiler Date: Fri, 6 Nov 2026 13:27:07 -0500 Subject: [PATCH 14/16] Use a named policy command test handler Verify signed bodies and query-bound reads through the functional command journey. Co-authored-by: Honor Signed-off-by: Brad Seiler --- crates/buzz-admin/src/nip_fi_policy.rs | 103 +++++++++++++++++++------ 1 file changed, 81 insertions(+), 22 deletions(-) diff --git a/crates/buzz-admin/src/nip_fi_policy.rs b/crates/buzz-admin/src/nip_fi_policy.rs index 66b18d630db..6b7e18276bf 100644 --- a/crates/buzz-admin/src/nip_fi_policy.rs +++ b/crates/buzz-admin/src/nip_fi_policy.rs @@ -399,15 +399,78 @@ mod tests { } } + #[derive(Clone)] + struct TestPolicy { + origin: String, + response: Value, + expected_body: Value, + host: String, + community_id: Uuid, + operation_id: Uuid, + desired: bool, + } + + async fn policy_test_handler( + axum::extract::State(policy): axum::extract::State, + method: Method, + uri: axum::http::Uri, + headers: axum::http::HeaderMap, + body: axum::body::Bytes, + ) -> Json { + let encoded = headers["authorization"] + .to_str() + .unwrap() + .strip_prefix("Nostr ") + .unwrap(); + let event = String::from_utf8(STANDARD.decode(encoded).unwrap()).unwrap(); + let url = format!("{}{uri}", policy.origin); + buzz_auth::verify_nip98_event( + &event, + &url, + method.as_str(), + (method == Method::POST).then_some(body.as_ref()), + ) + .unwrap(); + if method == Method::POST { + assert_eq!( + serde_json::from_slice::(&body).unwrap(), + policy.expected_body + ); + assert!(buzz_auth::verify_nip98_event(&event, &url, "POST", Some(b"changed")).is_err()); + } else if uri.query().unwrap().starts_with("community_id=") { + assert_eq!( + uri.query(), + Some( + format!( + "community_id={}&expected_host={}", + policy.community_id, policy.host + ) + .as_str() + ) + ); + return Json(serde_json::json!({ + "community_id": policy.community_id, "expected_host": policy.host, + "expected_policy_generation": 1, "expected_requires_nip_fi": policy.desired, + "mode": "shadow", "effective_required": policy.desired, + })); + } else { + assert_eq!( + uri.query(), + Some(format!("operation_id={}", policy.operation_id).as_str()) + ); + assert!(buzz_auth::verify_nip98_event( + &event, + &format!("{}{PATH}", policy.origin), + "GET", + None + ) + .is_err()); + } + Json(policy.response) + } + #[tokio::test] async fn policy_commands_send_saved_action_and_recover_current_and_history() { - use axum::{ - body::Bytes, - extract::RawQuery, - http::{HeaderMap, Method}, - routing::any, - Json, Router, - }; let keys = Keys::generate(); let dir = tempfile::tempdir().unwrap(); let key_file = dir.path().join("key"); @@ -428,21 +491,17 @@ mod tests { let expected_body = serde_json::to_value(&saved).unwrap(); let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); let origin = format!("http://{}", listener.local_addr().unwrap()); - let app = Router::new().route("/operator/communities/nip-fi-policy", any( - move |method: Method, RawQuery(query): RawQuery, headers: HeaderMap, body: Bytes| { - let response = response.clone(); let expected_body = expected_body.clone(); let host = host.clone(); - async move { - assert!(headers["authorization"].to_str().unwrap().starts_with("Nostr ")); - if method == Method::POST { - assert_eq!(serde_json::from_slice::(&body).unwrap(), expected_body); - } else if query.as_ref().unwrap().starts_with("community_id=") { - return Json(serde_json::json!({"community_id":community_id,"expected_host":host, - "expected_policy_generation":1,"expected_requires_nip_fi":desired, - "requires_nip_fi":desired,"mode":"shadow","effective_required":desired})); - } else { assert_eq!(query, Some(format!("operation_id={id}"))); } - Json(response) - } - })); + let app = Router::new() + .route(PATH, any(policy_test_handler)) + .with_state(TestPolicy { + origin: origin.clone(), + response, + expected_body, + host, + community_id, + operation_id: id, + desired, + }); let server = tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); let command = if desired { Command::Enable { From d31793ccbcdfd748361bcd559358915862b5ed57 Mon Sep 17 00:00:00 2001 From: Brad Seiler Date: Fri, 6 Nov 2026 15:32:57 -0500 Subject: [PATCH 15/16] Fix initial-policy test and docs after gate rename Co-authored-by: Honor Signed-off-by: Brad Seiler --- .../operator/postgres_tests/initial_policy_postgres_tests.rs | 2 +- docs/operator-nip-fi-bindings.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/buzz-relay/src/api/operator/postgres_tests/initial_policy_postgres_tests.rs b/crates/buzz-relay/src/api/operator/postgres_tests/initial_policy_postgres_tests.rs index d0dc9ff1ba1..e371de9186d 100644 --- a/crates/buzz-relay/src/api/operator/postgres_tests/initial_policy_postgres_tests.rs +++ b/crates/buzz-relay/src/api/operator/postgres_tests/initial_policy_postgres_tests.rs @@ -34,7 +34,7 @@ mod postgres_tests { .unwrap(); let owners: Vec<_> = (0..4).map(|_| Keys::generate()).collect(); let owner = &owners[3]; - assert!(!state.config.nip_fi_upgrades_enabled); + assert!(!state.config.nip_fi_changes); for (index, policy) in [ None, Some(Value::Null), diff --git a/docs/operator-nip-fi-bindings.md b/docs/operator-nip-fi-bindings.md index 744154c56c6..037bb727d6f 100644 --- a/docs/operator-nip-fi-bindings.md +++ b/docs/operator-nip-fi-bindings.md @@ -87,7 +87,7 @@ an operator disable or a change in issuer configuration. A different owner retains the existing conflict. Legacy ensure and deployment bootstrap remain optional and do not accept an explicit initial policy. -This policy is independent of `BUZZ_NIP_FI_UPGRADES_ENABLED`. Mode `off` stores +This policy is independent of `BUZZ_NIP_FI_CHANGES`. Mode `off` stores it but bypasses enforcement. The production creation paths are operator create-only, legacy operator ensure, and deployment bootstrap; only create-only accepts this choice. From 8eaa2614ac71dd4aa37baeb1395a63d93ec2c34a Mon Sep 17 00:00:00 2001 From: Brad Seiler Date: Fri, 6 Nov 2026 16:35:52 -0500 Subject: [PATCH 16/16] fix(relay): scope git policy guards to their route Unknown paths on relays without a web fallback now return 404 instead of inheriting the internal Git endpoint localhost rejection. Keep the localhost guard outermost and retain the 1 MB policy request limit. The signed removed-operator-route 404 assertion remains intact. Co-authored-by: Honor Signed-off-by: Brad Seiler --- crates/buzz-relay/src/api/git/mod.rs | 6 +- crates/buzz-relay/src/router.rs | 86 ++++++++++++++++++++++++++++ 2 files changed, 90 insertions(+), 2 deletions(-) diff --git a/crates/buzz-relay/src/api/git/mod.rs b/crates/buzz-relay/src/api/git/mod.rs index 1db75ce39c5..b012fdf65c5 100644 --- a/crates/buzz-relay/src/api/git/mod.rs +++ b/crates/buzz-relay/src/api/git/mod.rs @@ -62,7 +62,9 @@ async fn require_localhost(req: Request, next: Next) -> Response { pub fn git_policy_router(state: Arc) -> Router { Router::new() .route("/internal/git/policy", post(policy::hook_policy_check)) - .layer(RequestBodyLimitLayer::new(1024 * 1024)) // 1 MB - .layer(middleware::from_fn(require_localhost)) + // Scope both guards to this path; Router::layer also wraps the default + // fallback, which would reject unrelated unknown URLs after merging. + .route_layer(RequestBodyLimitLayer::new(1024 * 1024)) // 1 MB + .route_layer(middleware::from_fn(require_localhost)) .with_state(state) } diff --git a/crates/buzz-relay/src/router.rs b/crates/buzz-relay/src/router.rs index 45cd825133b..9d42fd5ac6a 100644 --- a/crates/buzz-relay/src/router.rs +++ b/crates/buzz-relay/src/router.rs @@ -2393,6 +2393,92 @@ mod tests { nip_fi_state(buzz_auth::NipFiMode::Off).await } + #[tokio::test] + async fn git_policy_guards_preserve_unmatched_routes() { + use axum::{body::to_bytes, extract::ConnectInfo, http::Request}; + use std::net::SocketAddr; + use tower::ServiceExt; + + let app = build_router(nip_fi_off_state().await); + let localhost: SocketAddr = "127.0.0.1:12345".parse().unwrap(); + let remote: SocketAddr = "192.0.2.1:12345".parse().unwrap(); + // Exercise the merged production router, including its default fallback. + for peer in [None, Some(remote), Some(localhost)] { + for method in ["GET", "POST"] { + for path in ["/operator/communities/nip-fi-upgrade", "/unknown-route"] { + let mut req = Request::builder() + .method(method) + .uri(path) + .header("content-type", "application/json") + .header("content-length", 1024 * 1024 + 1) + .body(Body::from(vec![b' '; 1024 * 1024 + 1])) + .unwrap(); + if let Some(peer) = peer { + req.extensions_mut().insert(ConnectInfo(peer)); + } + let res = app.clone().oneshot(req).await.unwrap(); + let status = res.status(); + let body = to_bytes(res.into_body(), usize::MAX).await.unwrap(); + assert_eq!( + status, + StatusCode::NOT_FOUND, + "{method} {path}, peer={peer:?}, body={body:?}" + ); + } + } + } + for (peer, size, expected) in [ + (None, 2, StatusCode::FORBIDDEN), + (Some(remote), 1024 * 1024 + 1, StatusCode::FORBIDDEN), + ( + Some(localhost), + 1024 * 1024 + 1, + StatusCode::PAYLOAD_TOO_LARGE, + ), + // A small JSON object reaches the real handler's JSON extractor. + (Some(localhost), 2, StatusCode::UNPROCESSABLE_ENTITY), + ] { + let mut payload = b"{}".to_vec(); + payload.resize(size, b' '); + let mut req = Request::builder() + .method("POST") + .uri("/internal/git/policy") + .header("content-type", "application/json") + .header("content-length", size) + .body(Body::from(payload)) + .unwrap(); + if let Some(peer) = peer { + req.extensions_mut().insert(ConnectInfo(peer)); + } + let res = app.clone().oneshot(req).await.unwrap(); + let status = res.status(); + let body = to_bytes(res.into_body(), usize::MAX).await.unwrap(); + assert_eq!( + status, expected, + "peer={peer:?}, size={size}, body={body:?}" + ); + if expected == StatusCode::FORBIDDEN { + assert_eq!(&body[..], b"internal endpoint: localhost only"); + } + } + // The path's method fallback remains protected, too. + for (peer, expected) in [ + (None, StatusCode::FORBIDDEN), + (Some(remote), StatusCode::FORBIDDEN), + (Some(localhost), StatusCode::METHOD_NOT_ALLOWED), + ] { + let mut req = Request::builder() + .uri("/internal/git/policy") + .body(Body::empty()) + .unwrap(); + if let Some(peer) = peer { + req.extensions_mut().insert(ConnectInfo(peer)); + } + let res = app.clone().oneshot(req).await.unwrap(); + assert_eq!(res.status(), expected, "GET policy, peer={peer:?}"); + } + } + async fn nip_fi_state(mode: buzz_auth::NipFiMode) -> Arc { use crate::nip_fi_config::NipFiRelayConfig; use buzz_auth::IssuerRegistry;