Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 5 additions & 6 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ BUZZ_PUSH_GATEWAY_DELIVERY_URL=https://push.buzz.xyz/v1/deliveries/apns
# 3. relay_operators table — DB-managed Operator/Moderator roster.
# The dashboard requires a NIP-07 browser extension.
# Setting RELAY_OPERATOR_PUBKEYS for the admin console does NOT require
# RELAY_OPERATOR_API_ORIGIN; that origin is only for community provisioning
# RELAY_OPERATOR_API_ORIGIN; that origin is only for operator HTTP APIs
# (see below). When BUZZ_ADMIN_HOST is set, the relay advertises the admin
# origin in its NIP-11 document (`admin_api` field) so clients can auto-discover
# the console without manual URL entry.
Expand All @@ -113,11 +113,10 @@ BUZZ_PUSH_GATEWAY_DELIVERY_URL=https://push.buzz.xyz/v1/deliveries/apns
# Directory holding the built dashboard assets (`pnpm -C admin-web build`).
# BUZZ_ADMIN_WEB_DIR=./admin-web/dist
#
# Canonical origin (http(s)://host[:port], no path) that community-provisioning
# NIP-98 requests are verified against. Required only to USE the provisioning
# endpoints (POST /operator/communities) — not for the admin console. When
# RELAY_OPERATOR_PUBKEYS is set but this is unset, the relay boots with a WARN
# and provisioning requests fail closed until it is set.
# Canonical origin (http(s)://host[:port], no path) for NIP-98 operator requests.
# Required for community provisioning and revocation notifications, not the admin
# console. With operator pubkeys set but no origin, the relay warns at startup
# and these requests fail closed.
# RELAY_OPERATOR_API_ORIGIN=http://127.0.0.1:3000

# Optional relay-owned KLIPY key. When set, NIP-11 advertises GIF search and
Expand Down
1 change: 1 addition & 0 deletions crates/buzz-relay/src/api/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ pub mod media;
pub mod mesh_demo;
pub mod nip05;
pub mod operator;
pub mod operator_revocations;
pub mod workflows;

// Re-export imeta helpers used by ingest pipeline.
Expand Down
2 changes: 1 addition & 1 deletion crates/buzz-relay/src/api/operator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ const OPERATOR_REPLAY_SCOPE: &str = "operator-management";
/// Shared deployment-global operator auth prelude. The canonical management
/// origin and replay namespace are configuration, never tenant registry state
/// or an inbound proxy `Host` header.
async fn authorize_operator_request(
pub(super) async fn authorize_operator_request(
state: &Arc<AppState>,
headers: &HeaderMap,
method: &str,
Expand Down
228 changes: 228 additions & 0 deletions crates/buzz-relay/src/api/operator_revocations.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,228 @@
//! Deployment-operator revocation notification ingress.
//!
//! This surface validates and logs a normalized notification. It deliberately
//! does not revoke identities, disconnect sessions, persist state, publish to
//! Redis, or claim that enforcement occurred.

use std::sync::Arc;

use axum::{
body::Bytes,
extract::State,
http::{header, HeaderMap, StatusCode},
response::Json,
};
use chrono::{DateTime, SecondsFormat, Utc};
use nostr::{secp256k1::XOnlyPublicKey, PublicKey};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use uuid::Uuid;

use crate::state::AppState;

use super::{api_error, operator::authorize_operator_request};

const PATH: &str = "/operator/revocation-notifications";
const NOTIFICATION_VERSION: u32 = 1;
const NOTIFICATION_TYPE: &str = "identity.revoked";
const MAX_OCCURRED_AT_BYTES: usize = 64;

#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct RevocationNotificationRequest {
version: u32,
id: String,
#[serde(rename = "type")]
notification_type: String,
target_pubkey: XOnlyPublicKey,
occurred_at: String,
}

#[derive(Debug)]
struct ValidatedRevocationNotification {
id: String,
target_pubkey: PublicKey,
occurred_at: DateTime<Utc>,
}

#[derive(Debug, Serialize)]
pub(crate) struct RevocationNotificationResponse {
accepted: bool,
status: &'static str,
revocation_applied: bool,
id: String,
}

fn validate_notification(
request: RevocationNotificationRequest,
) -> Result<ValidatedRevocationNotification, &'static str> {
if request.version != NOTIFICATION_VERSION {
return Err("version must be 1");
}
if request.notification_type != NOTIFICATION_TYPE {
return Err("type must be identity.revoked");
}

let id = Uuid::parse_str(&request.id).map_err(|_| "id must be a canonical UUID")?;
if id.hyphenated().to_string() != request.id {
return Err("id must be a canonical UUID");
}

let target_pubkey = PublicKey::from(request.target_pubkey);
let occurred_at = validate_occurred_at(&request.occurred_at)?;

Ok(ValidatedRevocationNotification {
id: request.id,
target_pubkey,
occurred_at,
})
}

fn validate_occurred_at(value: &str) -> Result<DateTime<Utc>, &'static str> {
if value.len() > MAX_OCCURRED_AT_BYTES || !value.ends_with('Z') {
return Err("occurred_at must be an RFC3339 UTC timestamp ending in Z");
}
DateTime::parse_from_rfc3339(value)
.map(|timestamp| timestamp.with_timezone(&Utc))
.map_err(|_| "occurred_at must be an RFC3339 UTC timestamp ending in Z")
}

/// Validate and log an operator-authenticated revocation notification.
///
/// A successful response acknowledges only that the bounded notification was
/// authenticated, validated, and emitted to structured logs. It performs no
/// durable write, deduplication, retry scheduling, session closure, or access
/// revocation.
pub(crate) async fn receive_revocation_notification(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
body: Bytes,
) -> Result<Json<RevocationNotificationResponse>, (StatusCode, Json<Value>)> {
let signer =
authorize_operator_request(&state, &headers, "POST", PATH, None, Some(&body)).await?;

if headers
.get(header::CONTENT_TYPE)
.and_then(|value| value.to_str().ok())
!= Some("application/json")
{
return Err(api_error(
StatusCode::UNSUPPORTED_MEDIA_TYPE,
"content-type must be application/json",
));
}

let request: RevocationNotificationRequest = serde_json::from_slice(&body).map_err(|_| {
api_error(
StatusCode::BAD_REQUEST,
"invalid revocation notification JSON",
)
})?;
let notification = validate_notification(request)
.map_err(|message| api_error(StatusCode::BAD_REQUEST, message))?;
let occurred_at = notification
.occurred_at
.to_rfc3339_opts(SecondsFormat::AutoSi, true);

tracing::info!(
notification_id = %notification.id,
signer = %signer.to_hex(),
target_pubkey = %notification.target_pubkey.to_hex(),
occurred_at = %occurred_at,
status = "logged_stub",
revocation_applied = false,
"operator revocation notification logged; no revocation action applied"
);

Ok(Json(RevocationNotificationResponse {
accepted: true,
status: "logged_stub",
revocation_applied: false,
id: notification.id,
}))
}

#[cfg(test)]
mod tests {
use super::{validate_notification, RevocationNotificationRequest};

const VALID_PUBKEY: &str = "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798";

fn valid_request() -> RevocationNotificationRequest {
RevocationNotificationRequest {
version: 1,
id: "550e8400-e29b-41d4-a716-446655440000".to_string(),
notification_type: "identity.revoked".to_string(),
target_pubkey: VALID_PUBKEY.parse().expect("valid x-only public key"),
occurred_at: "2026-09-21T12:34:56Z".to_string(),
}
}

fn request_json(target_pubkey: &str) -> String {
format!(
r#"{{"version":1,"id":"550e8400-e29b-41d4-a716-446655440000","type":"identity.revoked","target_pubkey":"{target_pubkey}","occurred_at":"2026-09-21T12:34:56Z"}}"#
)
}

#[test]
fn exact_contract_is_valid() {
let notification = validate_notification(valid_request()).expect("valid notification");
assert_eq!(notification.id, "550e8400-e29b-41d4-a716-446655440000");
assert_eq!(notification.target_pubkey.to_hex(), VALID_PUBKEY);
}

#[test]
fn timestamp_allows_fractional_seconds_but_requires_z() {
let mut fractional = valid_request();
fractional.occurred_at = "2026-09-21T12:34:56.123Z".to_string();
assert!(validate_notification(fractional).is_ok());

let mut offset = valid_request();
offset.occurred_at = "2026-09-21T12:34:56+00:00".to_string();
assert_eq!(
validate_notification(offset).expect_err("offset form must fail"),
"occurred_at must be an RFC3339 UTC timestamp ending in Z"
);
}

#[test]
fn semantic_contract_fields_are_strict() {
let mut wrong_version = valid_request();
wrong_version.version = 2;
assert!(validate_notification(wrong_version).is_err());

let mut wrong_type = valid_request();
wrong_type.notification_type = "identity.disabled".to_string();
assert!(validate_notification(wrong_type).is_err());

let mut noncanonical_id = valid_request();
noncanonical_id.id = "550E8400-E29B-41D4-A716-446655440000".to_string();
assert!(validate_notification(noncanonical_id).is_err());
}

#[test]
fn target_pubkey_deserialization_uses_sdk_hex_type() {
let uppercase = VALID_PUBKEY.to_uppercase();
let request =
serde_json::from_str::<RevocationNotificationRequest>(&request_json(&uppercase))
.expect("uppercase hex public key");
let notification = validate_notification(request).expect("valid notification");
assert_eq!(notification.target_pubkey.to_hex(), VALID_PUBKEY);

let invalid_point = "0".repeat(64);
for invalid in ["not-hex", invalid_point.as_str()] {
assert!(
serde_json::from_str::<RevocationNotificationRequest>(&request_json(invalid))
.is_err()
);
}
}

#[test]
fn unknown_json_fields_are_rejected() {
let body = format!(
r#"{{"version":1,"id":"550e8400-e29b-41d4-a716-446655440000","type":"identity.revoked","target_pubkey":"{VALID_PUBKEY}","occurred_at":"2026-09-21T12:34:56Z","command":"disconnect"}}"#
);
assert!(serde_json::from_str::<RevocationNotificationRequest>(&body).is_err());
}
}
5 changes: 5 additions & 0 deletions crates/buzz-relay/src/router.rs
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,11 @@ pub fn build_router(state: Arc<AppState>) -> Router {
"/operator/communities/transfer",
post(api::operator::transfer_community),
)
.route(
"/operator/revocation-notifications",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: let's include auth in this path maybe? Like /operator/auth/revocation-notifications or something. I'm working on adding something for the closing of a session, and was planning on some auth path like that.

post(api::operator_revocations::receive_revocation_notification)
.layer(RequestBodyLimitLayer::new(4096)),
)
// Relay invites: mint (owner/admin) + claim (membership-gate exempt)
.route("/api/invites", post(api::invites::mint_invite))
.route("/api/join-policy", get(api::invites::join_policy))
Expand Down
Loading