Manage community NIP-FI policy with fenced enable and disable

Co-authored-by: Honor <e0bf479eeebe47dde1e35705212a4df70616a7bc834d907ddfb18cf0405f8b1b@seiler-test.communities.public.builderlabstaging.xyz>
Signed-off-by: Brad Seiler <seiler@squareup.com>
This commit is contained in:
Brad Seiler
2026-11-06 11:03:39 -05:00
parent c97912c404
commit 019ec5510e
16 changed files with 1547 additions and 665 deletions

View File

@@ -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

View File

@@ -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<i32> {
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:#}")),

View File

@@ -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<Uuid>,
#[arg(long, requires = "host")]
community_id: Option<Uuid>,
#[arg(long, requires = "community_id")]
host: Option<String>,
},
}
#[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<String>,
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<i32> {
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<i32> {
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::<Value>(&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
);
}
}
}
}

View File

@@ -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<Vec<u8>> {
pub(super) fn read_bounded(path: &Path, limit: usize) -> Result<Vec<u8>> {
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<Vec<u8>> {
Ok(bytes)
}
fn endpoint(origin: &str, operation: Option<Uuid>) -> Result<Url> {
pub(super) fn endpoint(origin: &str, operation: Option<Uuid>) -> Result<Url> {
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<i32> {
.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<Vec<u8>>) -> Result<Value> {
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<i32> {
| "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<i32> {
_ 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<i32> {
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)]

View File

@@ -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<i64>, Option<bool>, 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() {

View File

@@ -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<i64>,
/// Null only for legacy, unfenced requests.
pub expected_requires_nip_fi: Option<bool>,
/// Current target generation, absent after target deletion.
pub current_policy_generation: Option<i64>,
/// Current stored flag, absent after target deletion.
pub current_requires_nip_fi: Option<bool>,
}
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<Option<UpgradeRecord>> {
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<Option<PolicyRecord>> {
let mut connection = crate::observability::acquire_writer(
&self.pool,
crate::observability::WriterOperation::Authorization,
)
.await?;
let record: Option<UpgradeRecord> = sqlx::query_as(RECEIPT)
let record: Option<PolicyRecord> = 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<PolicyOutcome> {
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<UpgradeOutcome> {
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<PolicyOutcome> {
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<UpgradeRecord> = sqlx::query_as(RECEIPT)
let existing: Option<PolicyRecord> = 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::<String, _>("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::<bool, _>("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::<bool, _>("active")? || deletion_pending {
return Ok(UpgradeOutcome::Refused("lifecycle_conflict"));
return Ok(PolicyOutcome::Refused("lifecycle_conflict"));
}
let owners: Vec<String> = 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<String> = sqlx::query_scalar(
"SELECT issuer FROM community_nip_fi_issuers
if desired {
let issuer: Option<String> = 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))
}
}

View File

@@ -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
);
}

View File

@@ -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;
}

View File

@@ -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<String>,
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<Uuid>,
community_id: Option<Uuid>,
expected_host: Option<String>,
}
type Error = (StatusCode, Json<Value>);
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<bool>) -> 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<Value> {
// 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<Arc<AppState>>,
slot: AccessSlot,
headers: HeaderMap,
body: axum::body::Bytes,
) -> Result<Json<Value>, 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<Arc<AppState>>,
slot: AccessSlot,
headers: HeaderMap,
RawQuery(raw): RawQuery,
Query(query): Query<PolicyQuery>,
) -> Result<Json<Value>, 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()),
}
}

View File

@@ -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", &current_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", &current_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", &current_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", &current_path, None).await;
assert_eq!(response.status(), StatusCode::FORBIDDEN);
}
}

View File

@@ -513,6 +513,11 @@ pub fn build_router(state: Arc<AppState>) -> 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)

View File

@@ -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`.
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.
## 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.
`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.
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 and disable
## Enable
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.
`POST /operator/communities/nip-fi-upgrade`
Read current preconditions with GET `?community_id=<UUID>&expected_host=<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.
```json
{
"operation_id": "c2ffcc20-9505-48fd-b6c7-446aa833bcd1",
"community_id": "c9acf9a6-37d5-4af1-8df9-92a165637b01",
"expected_host": "team.communities.example",
"actor_pubkey": "<owner's registered 64-character hex public key>",
"expected_owner_pubkey": "<expected current owner's hex public key>",
"expected_issuer": "https://identity.example/api/goose"
}
```
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.
The trusted operator mediates the authenticated actor. Kgoose must resolve that
actor's usable registered key from its session; it must never accept a user-supplied
actor key as identity. The actor must equal the expected and current owner.
`actor_pubkey` records the mediator's claimed authenticated actor for audit and
idempotency; it is not an independently verified end-user credential. The relay
authenticates the operator and checks the stored current owner.
Host and keys normalize before comparison; issuer bytes match exactly. UUIDs
must be non-nil. Unknown JSON fields are rejected.
The deployment selects the expected issuer from the **single** authorized issuer
in `BUZZ_NIP_FI_PROVISION_BINDING`. An absent or ambiguous selection is unavailable.
The target must be bindable under that policy and the issuer must have a configured
`BUZZ_NIP_FI_ISSUERS` policy. The same issuer must already be bound to the community
in `community_nip_fi_issuers`. The call never adds or broadens trust.
A transaction serializes UUID reuse across targets, then community lifecycle,
host and owner checks against transfer/archive/delete. It locks the exact issuer
row against unbind. Only a first optional-to-required transition increments
`policy_generation`; concurrent distinct operations converge on that generation.
Acceptance and its receipt commit together. No timeout or retry clears the policy.
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=<UUID>`
GET `?operation_id=<UUID>` 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.
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": "<owner at acceptance>",
"expected_owner_pubkey": "<expected owner at acceptance>",
"expected_issuer": "https://identity.example/api/goose",
"operator_pubkey": "<authenticated operator>",
"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`.
`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 |
| `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 |
Malformed GET queries use Axum's existing query-extractor rejection. Transport
signature replay uses the existing operator replay error independently of this table.
`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.
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.
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.
Legacy `/nip-fi-upgrade` remains enable-only. Fresh enables on optional/nonzero
generation are refused, so an old uncommitted retry cannot reverse disable.
Already-required legacy no-ops retain their generation. Legacy GET hides general
CAS receipts with 404, and POST cannot reuse their UUIDs. Old binaries cannot hide
those receipts or fence retries; the gate-off whole-fleet rollout is mandatory.
General status can read legacy receipts, with null CAS preconditions.
The active-community primary accessor is
`Db::community_auth_policy(CommunityId) -> Result<Option<CommunityAuthPolicy>>`.
`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.
## Request enforcement and discovery
## Mistaken acceptance and containment
`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.
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.
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.
## Mistaken acceptance and containment
`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.
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.
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.
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.
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.

View File

@@ -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

View File

@@ -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.
```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
```
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:
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": "<verified owner public key: 64 lowercase hex characters>",
"expected_owner_pubkey": "<same verified owner public key>",
"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": "<owner's registered lowercase hex public key>",
"expected_owner_pubkey": "<same current owner's public key>",
"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.
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.
```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:<verified-digest>
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
```
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.
```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
```
| 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 |
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.
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.
| 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.
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.

View File

@@ -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;

View File

@@ -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()
);