diff --git a/crates/buzz-relay/src/api/bridge.rs b/crates/buzz-relay/src/api/bridge.rs index d7805693be..4814835d65 100644 --- a/crates/buzz-relay/src/api/bridge.rs +++ b/crates/buzz-relay/src/api/bridge.rs @@ -3,6 +3,7 @@ //! These endpoints provide HTTP access to the relay's Nostr protocol, //! authenticated via NIP-98 signed events. +mod channel_delete_authority; mod read_state_snapshot; use std::sync::Arc; @@ -1334,6 +1335,9 @@ async fn query_events_authed( { return result; } + if channel_delete_authority::requested(&raw_filters) { + return channel_delete_authority::query(state, tenant, &pubkey, &raw_filters).await; + } let thread_windows = thread_window::parse(&raw_filters)?; let filters: Vec = raw_filters .iter() @@ -5053,11 +5057,8 @@ mod postgres_tests { const HANDLER_TEST_ISSUER: &str = "https://issuer.example"; const HANDLER_TEST_AUDIENCE: &str = "https://relay.example"; const HANDLER_TEST_KID: &str = "test-key-1"; - const HANDLER_TEST_EC_PEM: &str = "-----BEGIN PRIVATE KEY-----\n\ - MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgcnxDM4EiirH9dHUE\n\ - WZc759TX4s5PAn8kO5ovXSnGxCWhRANCAARFb6ZnsfkqOOXyEhj3KBQphGKF4vTa\n\ - zhebbavbZ1ZoklqkF1cGg+jTO7rONAVEzXvXUWtV6CdDV+rybiVmFP2w\n\ - -----END PRIVATE KEY-----\n"; + // Public test-only P-256 fixture for issuer.example; not a credential. + const HANDLER_TEST_EC_PEM: &str = "-----BEGIN PRIVATE KEY-----\nMIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgcnxDM4EiirH9dHUE\nWZc759TX4s5PAn8kO5ovXSnGxCWhRANCAARFb6ZnsfkqOOXyEhj3KBQphGKF4vTa\nzhebbavbZ1ZoklqkF1cGg+jTO7rONAVEzXvXUWtV6CdDV+rybiVmFP2w\n-----END PRIVATE KEY-----\n"; // sadscan:disable kingfisher.privkey.2 np.pem.1 /// Build a NIP-FI Enforce AppState with a real injected P-256 verifier. /// @@ -6932,11 +6933,8 @@ mod postgres_tests { const TEST_KID: &str = "test-key-1"; // PKCS#8 private key matching TEST_JWK_X/Y — same key used by // nip_fi_guard_rejects_crypto_invalid_assertion_before_handler_fires. - const TEST_EC_PKCS8_PEM: &str = "-----BEGIN PRIVATE KEY-----\n\ - MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgcnxDM4EiirH9dHUE\n\ - WZc759TX4s5PAn8kO5ovXSnGxCWhRANCAARFb6ZnsfkqOOXyEhj3KBQphGKF4vTa\n\ - zhebbavbZ1ZoklqkF1cGg+jTO7rONAVEzXvXUWtV6CdDV+rybiVmFP2w\n\ - -----END PRIVATE KEY-----\n"; + // Public test-only P-256 fixture for issuer.example; not a credential. + const TEST_EC_PKCS8_PEM: &str = "-----BEGIN PRIVATE KEY-----\nMIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgcnxDM4EiirH9dHUE\nWZc759TX4s5PAn8kO5ovXSnGxCWhRANCAARFb6ZnsfkqOOXyEhj3KBQphGKF4vTa\nzhebbavbZ1ZoklqkF1cGg+jTO7rONAVEzXvXUWtV6CdDV+rybiVmFP2w\n-----END PRIVATE KEY-----\n"; // sadscan:disable kingfisher.privkey.2 np.pem.1 let jwks: JwkSet = serde_json::from_value(serde_json::json!({ "keys": [{ diff --git a/crates/buzz-relay/src/api/bridge/channel_delete_authority.rs b/crates/buzz-relay/src/api/bridge/channel_delete_authority.rs new file mode 100644 index 0000000000..4b61e861d7 --- /dev/null +++ b/crates/buzz-relay/src/api/bridge/channel_delete_authority.rs @@ -0,0 +1,114 @@ +//! Narrow, authenticated ownership probe. Never an authorization token or a +//! persisted event; unsupported relays' ordinary query arrays cannot satisfy it. + +use std::{sync::Arc, time::Duration}; + +use axum::{http::StatusCode, Json}; +use buzz_core::TenantContext; +use serde_json::{json, Value}; +use uuid::Uuid; + +use crate::{api::api_error, handlers::side_effects::has_delete_authority, state::AppState}; + +pub(super) fn requested(filters: &[Value]) -> bool { + filters + .iter() + .any(|f| f.get("channel_delete_authority").is_some()) +} + +fn channel(filters: &[Value], viewer: &str) -> Option { + let id = filters.first()?.get("#h")?.get(0)?.as_str()?; + let id = Uuid::parse_str(id).ok()?; + let expected = json!({ + "kinds": [buzz_core::kind::KIND_NIP29_DELETE_GROUP], "#h": [id.to_string()], "#p": [viewer], + "channel_delete_authority": 1, + }); + (filters == [expected]).then_some(id) +} + +pub(super) async fn query( + state: &Arc, + tenant: &TenantContext, + viewer: &nostr::PublicKey, + filters: &[Value], +) -> Result, (StatusCode, Json)> { + let id = channel(filters, &viewer.to_hex()).ok_or_else(|| { + api_error( + StatusCode::BAD_REQUEST, + "invalid channel_delete_authority filter", + ) + })?; + let unavailable = || { + api_error( + StatusCode::SERVICE_UNAVAILABLE, + "Delete authority unavailable", + ) + }; + tokio::time::timeout(Duration::from_secs(5), async { + // Both the active roster and persisted ownership mapping use the writer, + // exactly as the Delete validator does, never profile tags or read replicas. + let members = state + .db + .get_members(tenant.community(), id) + .await + .map_err(|_| unavailable())?; + if !members.iter().any(|m| m.pubkey == viewer.to_bytes()) { + // No existence/owner information for a nonmember, including other tenants. + return Err(api_error( + StatusCode::FORBIDDEN, + "channel access unavailable", + )); + } + let channel = state + .db + .get_channel_for_event_write(tenant.community(), id) + .await + .map_err(|_| unavailable())?; + let can_delete = channel.archived_at.is_none() + && has_delete_authority(state, tenant.community(), &members, &viewer.to_bytes()) + .await + .map_err(|_| unavailable())?; + Ok(Json(json!({ + "channel_delete_authority": 1, + "community_id": tenant.community().as_uuid(), + "pubkey": viewer.to_hex(), + "channel_id": id, + "can_delete": can_delete, + }))) + }) + .await + .map_err(|_| unavailable())? +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn exact_single_channel_and_authenticated_viewer_only() { + let viewer = "a".repeat(64); + let id = Uuid::new_v4(); + let filter = + json!({"kinds":[9008], "#h":[id], "#p":[viewer], "channel_delete_authority":1}); + assert_eq!(channel(&[filter.clone()], &viewer), Some(id)); + assert_eq!(channel(&[], &viewer), None); + assert_eq!(channel(&[filter.clone(), filter.clone()], &viewer), None); + for (key, value) in [ + ("#p", json!(["b".repeat(64)])), + ("#h", json!([id, id])), + ("#h", json!(["not-a-channel"])), + ("kinds", json!([9008, 9])), + ("channel_delete_authority", json!(2)), + ("limit", json!(1)), + ] { + let mut invalid = filter.clone(); + invalid[key] = value; + assert!(requested(&[invalid.clone()])); + assert_eq!(channel(&[invalid], &viewer), None, "{key}"); + } + } +} + +#[cfg(test)] +#[path = "channel_delete_authority_tests.rs"] +mod postgres_tests; diff --git a/crates/buzz-relay/src/api/bridge/channel_delete_authority_tests.rs b/crates/buzz-relay/src/api/bridge/channel_delete_authority_tests.rs new file mode 100644 index 0000000000..ef3d164d9a --- /dev/null +++ b/crates/buzz-relay/src/api/bridge/channel_delete_authority_tests.rs @@ -0,0 +1,531 @@ +//! Exercises the actual router and command validator with disposable tenant data. +use super::*; +use axum::{ + body::{to_bytes, Body}, + http::Request, +}; +use base64::Engine; +use buzz_db::channel::{ChannelType, ChannelVisibility, MemberRole}; +use nostr::{EventBuilder, Keys, Kind, Tag, Timestamp}; +use sha2::{Digest, Sha256}; +use tower::ServiceExt; + +fn filter(key: &Keys, id: Uuid) -> Value { + json!([{"kinds":[9008], "#h":[id], "#p":[key.public_key().to_hex()], "channel_delete_authority":1}]) +} +fn proof(key: &Keys, host: &str, body: &[u8]) -> String { + let event = EventBuilder::new(Kind::Custom(27235), "") + .tags([ + Tag::parse(["u", &format!("https://{host}/query")]).unwrap(), + Tag::parse(["method", "POST"]).unwrap(), + Tag::parse(["payload", &hex::encode(Sha256::digest(body))]).unwrap(), + Tag::parse(["nonce", &Uuid::new_v4().to_string()]).unwrap(), + ]) + .sign_with_keys(key) + .unwrap(); + format!( + "Nostr {}", + base64::engine::general_purpose::STANDARD.encode(serde_json::to_vec(&event).unwrap()) + ) +} +async fn post( + state: &Arc, + host: &str, + auth: Option<&str>, + body: Vec, +) -> (StatusCode, Value) { + let mut request = Request::builder() + .method("POST") + .uri("/query") + .header("host", host); + if let Some(auth) = auth { + request = request.header("authorization", auth); + } + let response = crate::router::build_router(state.clone()) + .oneshot(request.body(Body::from(body)).unwrap()) + .await + .unwrap(); + let status = response.status(); + let bytes = to_bytes(response.into_body(), 4096).await.unwrap(); + (status, serde_json::from_slice(&bytes).unwrap()) +} +async fn query_as( + state: &Arc, + host: &str, + key: &Keys, + filter: Value, +) -> (StatusCode, Value) { + let body = serde_json::to_vec(&filter).unwrap(); + post(state, host, Some(&proof(key, host, &body)), body).await +} +async fn setup() -> (Arc, TenantContext, Keys, Uuid) { + let fixture = super::super::postgres_tests::bridge_handler_test_state() + .await + .expect("Postgres and Redis required"); + let mut state = (*fixture).clone(); + let config = Arc::make_mut(&mut state.config); + config.require_auth_token = true; + config.require_relay_membership = true; + state.nip98_replay = Arc::new(buzz_pubsub::RedisNip98ReplayGuard::new( + state.redis_pool.clone(), + )); + let state = Arc::new(state); + let host = format!("delete-authority-{}.local", Uuid::new_v4().simple()); + let community = state + .db + .ensure_configured_community(&host) + .await + .unwrap() + .id; + let owner = Keys::generate(); + let id = state + .db + .create_channel( + community, + "authority-fixture", + ChannelType::Stream, + ChannelVisibility::Private, + None, + &owner.public_key().to_bytes(), + None, + ) + .await + .unwrap() + .id; + admit(&state, community, &owner).await; + (state, TenantContext::resolved(community, host), owner, id) +} +async fn admit(state: &AppState, community: buzz_core::CommunityId, key: &Keys) { + state + .db + .add_relay_member(community, &key.public_key().to_hex(), "member", None) + .await + .unwrap(); +} +async fn agrees( + state: &Arc, + tenant: &TenantContext, + key: &Keys, + id: Uuid, + allowed: bool, +) { + let (status, body) = query_as(state, tenant.host(), key, filter(key, id)).await; + assert_eq!(status, StatusCode::OK, "{body}"); + assert_eq!( + body, + json!({"channel_delete_authority":1, "community_id":tenant.community().as_uuid(), "pubkey":key.public_key().to_hex(), "channel_id":id, "can_delete":allowed}) + ); + // Validate but never publish/execute the destructive command. + let command = EventBuilder::new(Kind::Custom(9008), "") + .tags([Tag::parse(["h", &id.to_string()]).unwrap()]) + .sign_with_keys(key) + .unwrap(); + assert_eq!( + crate::handlers::side_effects::validate_admin_event(tenant, 9008, &command, state) + .await + .is_ok(), + allowed + ); +} + +#[tokio::test] +#[ignore = "requires Postgres and Redis"] +async fn persisted_authority_matches_delete_not_replaced_profile_claims() { + let (state, tenant, owner, id) = setup().await; + let human = Keys::generate(); + let other = Keys::generate(); + let agent = Keys::generate(); + state + .db + .ensure_user(tenant.community(), &agent.public_key().to_bytes()) + .await + .unwrap(); + for key in [&human, &other] { + state + .db + .ensure_user(tenant.community(), &key.public_key().to_bytes()) + .await + .unwrap(); + admit(&state, tenant.community(), key).await; + state + .db + .add_member( + tenant.community(), + id, + &key.public_key().to_bytes(), + MemberRole::Admin, + Some(&owner.public_key().to_bytes()), + ) + .await + .unwrap(); + } + // A coowner, not the creator, is enough; an ordinary admin is not. + state + .db + .add_member( + tenant.community(), + id, + &agent.public_key().to_bytes(), + MemberRole::Owner, + Some(&owner.public_key().to_bytes()), + ) + .await + .unwrap(); + assert!(state + .db + .set_agent_owner( + tenant.community(), + &agent.public_key().to_bytes(), + &human.public_key().to_bytes() + ) + .await + .unwrap()); + assert!(!state + .db + .set_agent_owner( + tenant.community(), + &agent.public_key().to_bytes(), + &other.public_key().to_bytes() + ) + .await + .unwrap()); + agrees(&state, &tenant, &owner, id, true).await; + agrees(&state, &tenant, &human, id, true).await; + agrees(&state, &tenant, &other, id, false).await; + // A valid current NIP-OA claim for a different human cannot rewrite the + // first-write-wins mapping, nor does later removing the tag revoke it. + let digest: [u8; 32] = + Sha256::digest(format!("nostr:agent-auth:{}:", agent.public_key().to_hex())).into(); + let signature = other.sign_schnorr(&nostr::secp256k1::Message::from_digest(digest)); + let tag = Tag::parse([ + "auth", + &other.public_key().to_hex(), + "", + &signature.to_string(), + ]) + .unwrap(); + for (index, tags) in [vec![tag], vec![]].into_iter().enumerate() { + let profile = EventBuilder::new(Kind::Metadata, "{}") + .tags(tags) + .custom_created_at(Timestamp::from(1_790_000_000 + index as u64)) + .sign_with_keys(&agent) + .unwrap(); + state + .db + .replace_addressable_event(tenant.community(), &profile, None) + .await + .unwrap(); + agrees(&state, &tenant, &human, id, true).await; + agrees(&state, &tenant, &other, id, false).await; + } + // Identical channel/agent/viewer keys in another tenant use that tenant's + // persisted mapping, never the mapping above. + let other_host = format!("mapping-{}.local", Uuid::new_v4().simple()); + let other_community = state + .db + .ensure_configured_community(&other_host) + .await + .unwrap() + .id; + state + .db + .create_channel_with_id( + other_community, + id, + "other-mapping", + ChannelType::Stream, + ChannelVisibility::Private, + None, + &agent.public_key().to_bytes(), + None, + ) + .await + .unwrap(); + for key in [&agent, &human, &other] { + state + .db + .ensure_user(other_community, &key.public_key().to_bytes()) + .await + .unwrap(); + admit(&state, other_community, key).await; + } + for key in [&human, &other] { + state + .db + .add_member( + other_community, + id, + &key.public_key().to_bytes(), + MemberRole::Member, + Some(&agent.public_key().to_bytes()), + ) + .await + .unwrap(); + } + state + .db + .set_agent_owner( + other_community, + &agent.public_key().to_bytes(), + &other.public_key().to_bytes(), + ) + .await + .unwrap(); + let other_tenant = TenantContext::resolved(other_community, other_host); + agrees(&state, &other_tenant, &human, id, false).await; + agrees(&state, &other_tenant, &other, id, true).await; + // The existing mutation permits a nonmember owning human; the read remains + // deliberately member-only. Extracting the predicate must not change that. + state + .db + .remove_member( + other_community, + id, + &other.public_key().to_bytes(), + &agent.public_key().to_bytes(), + ) + .await + .unwrap(); + assert_eq!( + query_as(&state, other_tenant.host(), &other, filter(&other, id)) + .await + .0, + StatusCode::FORBIDDEN + ); + let command = EventBuilder::new(Kind::Custom(9008), "") + .tags([Tag::parse(["h", &id.to_string()]).unwrap()]) + .sign_with_keys(&other) + .unwrap(); + crate::handlers::side_effects::validate_admin_event(&other_tenant, 9008, &command, &state) + .await + .unwrap(); + // Membership-role changes, unlike profile replacement, do change authority. + state + .db + .add_member( + tenant.community(), + id, + &agent.public_key().to_bytes(), + MemberRole::Member, + Some(&owner.public_key().to_bytes()), + ) + .await + .unwrap(); + agrees(&state, &tenant, &human, id, false).await; + state + .db + .add_member( + tenant.community(), + id, + &agent.public_key().to_bytes(), + MemberRole::Owner, + Some(&owner.public_key().to_bytes()), + ) + .await + .unwrap(); + agrees(&state, &tenant, &human, id, true).await; + state + .db + .remove_member( + tenant.community(), + id, + &agent.public_key().to_bytes(), + &owner.public_key().to_bytes(), + ) + .await + .unwrap(); + agrees(&state, &tenant, &human, id, false).await; + state + .db + .add_member( + tenant.community(), + id, + &agent.public_key().to_bytes(), + MemberRole::Owner, + Some(&owner.public_key().to_bytes()), + ) + .await + .unwrap(); + state + .db + .archive_channel(tenant.community(), id) + .await + .unwrap(); + agrees(&state, &tenant, &human, id, false).await; + agrees(&state, &tenant, &owner, id, false).await; +} + +#[tokio::test] +#[ignore = "requires Postgres and Redis"] +async fn router_auth_replay_scope_discovery_and_strict_filters() { + let (state, tenant, owner, id) = setup().await; + let body = serde_json::to_vec(&filter(&owner, id)).unwrap(); + assert_eq!( + post(&state, tenant.host(), None, body.clone()).await.0, + StatusCode::UNAUTHORIZED + ); + assert_eq!( + post( + &state, + tenant.host(), + Some(&proof(&owner, "wrong.invalid", &body)), + body.clone() + ) + .await + .0, + StatusCode::UNAUTHORIZED + ); + assert_eq!( + post( + &state, + tenant.host(), + Some(&proof(&owner, tenant.host(), b"[]")), + body.clone() + ) + .await + .0, + StatusCode::UNAUTHORIZED + ); + let auth = proof(&owner, tenant.host(), &body); + assert_eq!( + post(&state, tenant.host(), Some(&auth), body.clone()) + .await + .0, + StatusCode::OK + ); + assert_eq!( + post(&state, tenant.host(), Some(&auth), body).await.0, + StatusCode::UNAUTHORIZED + ); + let stranger = Keys::generate(); + let denied = query_as(&state, tenant.host(), &stranger, filter(&stranger, id)).await; + assert_eq!(denied.0, StatusCode::FORBIDDEN); + assert_eq!(denied.1["error"], "relay_membership_required"); + admit(&state, tenant.community(), &stranger).await; + let denied = query_as(&state, tenant.host(), &stranger, filter(&stranger, id)).await; + assert_eq!(denied.0, StatusCode::FORBIDDEN); + assert!(denied.1.get("can_delete").is_none()); + assert_eq!( + query_as(&state, tenant.host(), &stranger, filter(&owner, id)) + .await + .0, + StatusCode::BAD_REQUEST + ); + assert_eq!( + query_as( + &state, + tenant.host(), + &owner, + filter(&owner, Uuid::new_v4()) + ) + .await + .0, + StatusCode::FORBIDDEN + ); + for (name, value) in [ + ("limit", json!(1)), + ("channel_delete_authority", json!(2)), + ("channel_delete_authority", Value::Null), + ("#h", json!([id, id])), + ] { + let mut bad = filter(&owner, id); + bad[0][name] = value; + assert_eq!( + query_as(&state, tenant.host(), &owner, bad).await.0, + StatusCode::BAD_REQUEST + ); + } + let mut mixed = filter(&owner, id); + mixed.as_array_mut().unwrap().push(json!({"kinds":[0]})); + assert_eq!( + query_as(&state, tenant.host(), &owner, mixed).await.0, + StatusCode::BAD_REQUEST + ); + let info = crate::nip11::nip11_document(&state, tenant.host()).await; + assert_eq!( + info.channel_delete_authority, + Some(json!({"version":1,"community_id":tenant.community().as_uuid()})) + ); + assert!(crate::nip11::nip11_document(&state, "unmapped.invalid") + .await + .channel_delete_authority + .is_none()); + assert_eq!( + query_as(&state, "unmapped.invalid", &owner, filter(&owner, id)) + .await + .0, + StatusCode::NOT_FOUND + ); + let second_host = format!("other-{}.local", Uuid::new_v4().simple()); + let second = state + .db + .ensure_configured_community(&second_host) + .await + .unwrap() + .id; + admit(&state, second, &owner).await; + assert_eq!( + query_as(&state, &second_host, &owner, filter(&owner, id)) + .await + .0, + StatusCode::FORBIDDEN + ); + // Same channel UUID and identities in another tenant: no ownership inheritance. + state + .db + .create_channel_with_id( + second, + id, + "other", + ChannelType::Stream, + ChannelVisibility::Private, + None, + &stranger.public_key().to_bytes(), + None, + ) + .await + .unwrap(); + state + .db + .add_member( + second, + id, + &owner.public_key().to_bytes(), + MemberRole::Member, + Some(&stranger.public_key().to_bytes()), + ) + .await + .unwrap(); + agrees( + &state, + &TenantContext::resolved(second, second_host), + &owner, + id, + false, + ) + .await; +} + +#[tokio::test] +#[ignore = "requires Postgres and Redis"] +async fn database_failure_is_unavailable_not_an_authoritative_denial() { + let (state, tenant, owner, id) = setup().await; + let pool = sqlx::PgPool::connect(&crate::test_support::database_url()) + .await + .unwrap(); + pool.close().await; + let mut failed = (*state).clone(); + failed.db = buzz_db::Db::from_pool(pool); + let filters = filter(&owner, id); + let (status, body) = query( + &Arc::new(failed), + &tenant, + &owner.public_key(), + filters.as_array().unwrap(), + ) + .await + .unwrap_err(); + assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE); + assert!(body.get("can_delete").is_none()); + agrees(&state, &tenant, &owner, id, true).await; +} diff --git a/crates/buzz-relay/src/handlers/side_effects.rs b/crates/buzz-relay/src/handlers/side_effects.rs index a2e260c9fc..e9c339537f 100644 --- a/crates/buzz-relay/src/handlers/side_effects.rs +++ b/crates/buzz-relay/src/handlers/side_effects.rs @@ -447,6 +447,24 @@ async fn actor_owns_any_owner_agent( Ok(false) } +/// Current Delete ownership authority, shared by the command and its read-only probe. +/// The caller supplies an active roster from this community's writer. Other +/// command checks (including archived state) remain with the command validator. +pub(crate) async fn has_delete_authority( + state: &Arc, + community_id: buzz_core::CommunityId, + members: &[buzz_db::channel::MemberRecord], + actor: &[u8], +) -> anyhow::Result { + if members + .iter() + .any(|m| m.pubkey == actor && m.role == "owner") + { + return Ok(true); + } + actor_owns_any_owner_agent(state, community_id, members, actor).await +} + /// Validate an admin kind event BEFORE storage. pub async fn validate_admin_event( tenant: &TenantContext, @@ -799,20 +817,10 @@ pub async fn validate_admin_event( 9008 => { // DELETE_GROUP: owner only, or the owning human of the channel's agent-owner. let members = state.db.get_members(tenant.community(), channel_id).await?; - let actor_member = members.iter().find(|m| m.pubkey == actor_bytes); - match actor_member { - Some(m) if m.role == "owner" => Ok(()), - _ => { - // Allow the owning human of any active owner-role agent in the - // channel, even when the human is not a channel member — - // diverges from kind:9001 intentionally. - if actor_owns_any_owner_agent(state, tenant.community(), &members, &actor_bytes) - .await? - { - return Ok(()); - } - Err(anyhow::anyhow!("only owner can delete group")) - } + if has_delete_authority(state, tenant.community(), &members, &actor_bytes).await? { + Ok(()) + } else { + Err(anyhow::anyhow!("only owner can delete group")) } } 9022 => { diff --git a/crates/buzz-relay/src/nip11.rs b/crates/buzz-relay/src/nip11.rs index 218647f862..e7a9a87245 100644 --- a/crates/buzz-relay/src/nip11.rs +++ b/crates/buzz-relay/src/nip11.rs @@ -34,6 +34,9 @@ pub struct RelayInfo { /// Host-bound atomic read-state snapshot capability; absent on unresolved hosts. #[serde(skip_serializing_if = "Option::is_none")] pub read_state_snapshot: Option, + /// Authenticated, channel/viewer-bound Delete authority read over POST /query. + #[serde(skip_serializing_if = "Option::is_none")] + pub channel_delete_authority: Option, /// NIP-AR artifact query transport and enforced resource limits. pub artifacts: serde_json::Value, /// Relay operator's public key (hex), if published. @@ -209,6 +212,7 @@ impl RelayInfo { description: "Buzz — private team communication relay".to_string(), icon: icon.filter(|s| !s.is_empty()).map(|s| s.to_string()), read_state_snapshot: None, + channel_delete_authority: None, artifacts: serde_json::json!({ "version": 1, "revision_kind": 45010, "removal_kind": 45011, "query": "/query", "count": "/count", @@ -314,6 +318,10 @@ pub(crate) async fn nip11_document(state: &crate::state::AppState, raw_host: &st state.config.klipy.as_ref().map(|_| "klipy"), ); if let Ok(tenant) = crate::tenant::bind_community(&state.db, raw_host).await { + info.channel_delete_authority = Some(serde_json::json!({ + "version": 1, + "community_id": tenant.community().as_uuid(), + })); info.read_state_snapshot = Some(serde_json::json!({ "version": 1, "community_id": tenant.community().as_uuid(), diff --git a/docs/channel-delete-authority.md b/docs/channel-delete-authority.md new file mode 100644 index 0000000000..e17a9305cd --- /dev/null +++ b/docs/channel-delete-authority.md @@ -0,0 +1,70 @@ +# Channel Delete authority read + +`POST /query` accepts an opt-in version-1 `channel_delete_authority` filter. +This is a read-only UI eligibility probe, not a persisted Nostr event, delegated +signing authority, ownership-transfer API or permission token. Kind 9008 remains +the destructive command and performs its existing authorization checks at write +time. A successful read does not protect against subsequent membership changes. + +## Discovery and exact request + +Host-bound NIP-11 advertises: + +```json +{"channel_delete_authority":{"version":1,"community_id":""}} +``` + +Unresolved hosts do not advertise the capability. Clients must require version 1 +and retain the host-derived community UUID for response validation. Older relays +may ignore unknown filters and return ordinary event arrays; those are **not** +eligibility evidence. + +Send exactly one filter through the existing authenticated bridge: + +```json +[{"kinds":[9008],"#h":[""],"#p":[""],"channel_delete_authority":1}] +``` + +The ordinary bridge Host binding, NIP-98 proof/body validation, admission, +replay guard and relay-membership checks run before this extension. The same +configured development-only auth fallback as other bridge reads still applies. +Unknown fields, mixed filters, unsupported versions and mismatched viewers fail +with 400. The viewer must be an active channel member; a nonmember or unknown +channel returns a generic 403 without ownership or existence data. + +## Response and authority + +```json +{"channel_delete_authority":1,"community_id":"","pubkey":"","channel_id":"","can_delete":true} +``` + +The decision uses the writer's active roster and the same owner predicate as +kind 9008: direct owner, or the human in the persisted community-scoped ownership +mapping for any active owner-role agent. Profile claims are not consulted: +replacing or removing a profile auth tag neither transfers nor revokes the +first-write-wins `users.agent_owner_pubkey` mapping. Archived channels return +false, matching the existing mutation validator's archive guard. The probe is +intentionally narrower than the mutation's existing nonmember-owning-human path; +this does not change that mutation permission. + +Database failure or the five-second read deadline returns 503, never a fabricated +false. Clients must bind version/community/viewer/channel, require a boolean, +bound response bytes, and distinguish unavailable authority from denial. They +should retain independently established actions when this optional read fails, +provide retry, and recheck before signing and publishing. The bridge response is +trusted through the configured relay transport; it is not a relay-signed event. + +Deploy the relay capability before expecting clients to support owner-agent +Delete. No schema migration or change to kind 9008's wire format is required. + +## Validation + +`channel_delete_authority_tests.rs` exercises the production router with real +NIP-98 proofs and Postgres/Redis, compares decisions with the actual Delete +validator, and covers profile/mapping disagreement, coowners, demotion/removal, +archived channels, tenant isolation, strict filters, replay and storage failure. +It validates commands without executing deletion. With an isolated test database: + +```sh +cargo test -p buzz-relay --lib api::bridge::channel_delete_authority -- --include-ignored +```