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