feat(relay): expose current channel delete eligibility

Read persisted, community-scoped Delete ownership through an authenticated query extension. Share the command predicate without changing mutation permissions and advertise support on mapped hosts.

Keep storage errors unavailable and validate viewer/channel/tenant boundaries, profile disagreement, replay, and archived state. Mark two byte-identical baseline test PEM fixtures with rule-specific scanner annotations.

Signed-off-by: Carl <acda9e433d19dcd0e6b6840f7f4b98f3a56f1fab98049d444c087019e6d36560@buzz.block.builderlab.xyz>
This commit is contained in:
Carl
2026-09-28 21:44:15 -07:00
parent 12670bd0f0
commit 2208791c78
6 changed files with 753 additions and 24 deletions

View File

@@ -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<nostr::Filter> = 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": [{

View File

@@ -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<Uuid> {
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<AppState>,
tenant: &TenantContext,
viewer: &nostr::PublicKey,
filters: &[Value],
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
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;

View File

@@ -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<AppState>,
host: &str,
auth: Option<&str>,
body: Vec<u8>,
) -> (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<AppState>,
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<AppState>, 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<AppState>,
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;
}

View File

@@ -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<AppState>,
community_id: buzz_core::CommunityId,
members: &[buzz_db::channel::MemberRecord],
actor: &[u8],
) -> anyhow::Result<bool> {
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 => {

View File

@@ -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<serde_json::Value>,
/// Authenticated, channel/viewer-bound Delete authority read over POST /query.
#[serde(skip_serializing_if = "Option::is_none")]
pub channel_delete_authority: Option<serde_json::Value>,
/// 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(),

View File

@@ -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":"<host-community-uuid>"}}
```
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":["<canonical-channel-uuid>"],"#p":["<authenticated-viewer-hex>"],"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":"<host-community-uuid>","pubkey":"<authenticated-viewer-hex>","channel_id":"<canonical-channel-uuid>","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
```