mirror of
https://github.com/block/buzz.git
synced 2026-10-28 21:11:48 +00:00
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:
@@ -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": [{
|
||||
|
||||
114
crates/buzz-relay/src/api/bridge/channel_delete_authority.rs
Normal file
114
crates/buzz-relay/src/api/bridge/channel_delete_authority.rs
Normal 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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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(),
|
||||
|
||||
70
docs/channel-delete-authority.md
Normal file
70
docs/channel-delete-authority.md
Normal 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
|
||||
```
|
||||
Reference in New Issue
Block a user