From 36c78902e33683b9e5ef5abb186b7e69b6a1ba1e Mon Sep 17 00:00:00 2001 From: jm Date: Mon, 21 Sep 2026 21:11:18 +0000 Subject: [PATCH 1/4] feat(relay): accept revocation notifications Signed-off-by: jm Co-authored-by: Codex --- .env.example | 3 + crates/buzz-relay/src/api/mod.rs | 1 + crates/buzz-relay/src/api/operator.rs | 2 +- .../src/api/operator_revocations.rs | 227 ++++++++++++++++++ crates/buzz-relay/src/router.rs | 5 + docs/operator-revocation-notifications.md | 57 +++++ 6 files changed, 294 insertions(+), 1 deletion(-) create mode 100644 crates/buzz-relay/src/api/operator_revocations.rs create mode 100644 docs/operator-revocation-notifications.md diff --git a/.env.example b/.env.example index 683bceb4cc0..399a4c29153 100644 --- a/.env.example +++ b/.env.example @@ -118,6 +118,9 @@ BUZZ_PUSH_GATEWAY_DELIVERY_URL=https://push.buzz.xyz/v1/deliveries/apns # 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. +# The same origin and operator signer allowlist authenticate the receipt-only +# POST /operator/revocation-notifications integration; see +# docs/operator-revocation-notifications.md. # RELAY_OPERATOR_API_ORIGIN=http://127.0.0.1:3000 # Optional relay-owned KLIPY key. When set, NIP-11 advertises GIF search and diff --git a/crates/buzz-relay/src/api/mod.rs b/crates/buzz-relay/src/api/mod.rs index 5745b8d4e59..2bf8be5d14a 100644 --- a/crates/buzz-relay/src/api/mod.rs +++ b/crates/buzz-relay/src/api/mod.rs @@ -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. diff --git a/crates/buzz-relay/src/api/operator.rs b/crates/buzz-relay/src/api/operator.rs index 2c49ca6a5c3..b200bb07d75 100644 --- a/crates/buzz-relay/src/api/operator.rs +++ b/crates/buzz-relay/src/api/operator.rs @@ -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, headers: &HeaderMap, method: &str, diff --git a/crates/buzz-relay/src/api/operator_revocations.rs b/crates/buzz-relay/src/api/operator_revocations.rs new file mode 100644 index 00000000000..399c8591324 --- /dev/null +++ b/crates/buzz-relay/src/api/operator_revocations.rs @@ -0,0 +1,227 @@ +//! 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::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: String, + occurred_at: String, +} + +#[derive(Debug)] +struct ValidatedRevocationNotification { + id: String, + target_pubkey: String, + occurred_at: DateTime, +} + +#[derive(Debug, Serialize)] +pub(crate) struct RevocationNotificationResponse { + accepted: bool, + status: &'static str, + revocation_applied: bool, + id: String, +} + +fn validate_notification( + request: RevocationNotificationRequest, +) -> Result { + 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"); + } + + validate_target_pubkey(&request.target_pubkey)?; + let occurred_at = validate_occurred_at(&request.occurred_at)?; + + Ok(ValidatedRevocationNotification { + id: request.id, + target_pubkey: request.target_pubkey, + occurred_at, + }) +} + +fn validate_target_pubkey(value: &str) -> Result<(), &'static str> { + if value.len() != 64 + || !value + .bytes() + .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase()) + { + return Err("target_pubkey must be 64 lowercase hex characters"); + } + let pubkey = PublicKey::from_hex(value).map_err(|_| "target_pubkey is invalid")?; + pubkey + .xonly() + .map_err(|_| "target_pubkey is not a valid x-only secp256k1 public key")?; + Ok(()) +} + +fn validate_occurred_at(value: &str) -> Result, &'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>, + headers: HeaderMap, + body: Bytes, +) -> Result, (StatusCode, Json)> { + 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, + 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.to_string(), + occurred_at: "2026-09-21T12:34:56Z".to_string(), + } + } + + #[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, 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()); + + let mut uppercase_key = valid_request(); + uppercase_key.target_pubkey = VALID_PUBKEY.to_uppercase(); + assert!(validate_notification(uppercase_key).is_err()); + + let mut invalid_point = valid_request(); + invalid_point.target_pubkey = "0".repeat(64); + assert!(validate_notification(invalid_point).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::(&body).is_err()); + } +} diff --git a/crates/buzz-relay/src/router.rs b/crates/buzz-relay/src/router.rs index 61aedf70be0..8fe1aaede51 100644 --- a/crates/buzz-relay/src/router.rs +++ b/crates/buzz-relay/src/router.rs @@ -104,6 +104,11 @@ pub fn build_router(state: Arc) -> Router { "/operator/communities/transfer", post(api::operator::transfer_community), ) + .route( + "/operator/revocation-notifications", + 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)) diff --git a/docs/operator-revocation-notifications.md b/docs/operator-revocation-notifications.md new file mode 100644 index 00000000000..98ef8bba5e2 --- /dev/null +++ b/docs/operator-revocation-notifications.md @@ -0,0 +1,57 @@ +# Operator revocation notifications + +Buzz exposes a deployment-operator endpoint for integrations that normalize an +upstream offboarding signal to a Nostr public key: + +```http +POST /operator/revocation-notifications +Content-Type: application/json +Authorization: Nostr +``` + +```json +{ + "version": 1, + "id": "550e8400-e29b-41d4-a716-446655440000", + "type": "identity.revoked", + "target_pubkey": "<64 lowercase hex characters>", + "occurred_at": "2026-09-21T12:34:56Z" +} +``` + +The request uses the existing operator service authentication configured by +`RELAY_OPERATOR_API_ORIGIN` and `RELAY_OPERATOR_PUBKEYS`. The kind-27235 NIP-98 +event must sign the configured operator origin plus the exact endpoint path, +`POST`, and a `payload` tag containing the SHA-256 digest of the exact request +bytes. Existing freshness, replay, and operator-signer checks apply. The route +accepts at most 4 KiB. + +The JSON contract is strict: unknown fields are rejected; `version` and `type` +have the fixed values above; `id` is a canonical hyphenated UUID; +`target_pubkey` is a valid lowercase x-only secp256k1 public key; and +`occurred_at` is an RFC3339 UTC timestamp ending in `Z`. Fractional seconds are +accepted. An equivalent `+00:00` suffix is not accepted because the wire +contract requires `Z`. The occurrence time is informational and has no recency +requirement; NIP-98 independently supplies authentication freshness. + +Success returns: + +```json +{ + "accepted": true, + "status": "logged_stub", + "revocation_applied": false, + "id": "550e8400-e29b-41d4-a716-446655440000" +} +``` + +This acknowledgement means only that Buzz authenticated and validated the +notification and emitted a bounded structured log. It is not a durable audit +receipt and does not deduplicate deliveries, retry work, disconnect sessions, +change identity or database state, maintain a denylist, or enforce revocation. +Callers retrying a delivery must preserve the notification `id` and sign each +attempt with a fresh NIP-98 event. + +Future SCIM, identity-provider, or generic webhook adapters can produce this +normalized request once they have independently resolved a target public key. +This endpoint does not perform corporate-identity-to-key mapping. From ecfe1739e86613c879b7765a93df101131d85b8b Mon Sep 17 00:00:00 2001 From: jm Date: Mon, 21 Sep 2026 21:31:49 +0000 Subject: [PATCH 2/4] refactor(relay): retain typed revocation pubkey Signed-off-by: jm Co-authored-by: Codex --- .../src/api/operator_revocations.rs | 23 ++++++++----------- 1 file changed, 10 insertions(+), 13 deletions(-) diff --git a/crates/buzz-relay/src/api/operator_revocations.rs b/crates/buzz-relay/src/api/operator_revocations.rs index 399c8591324..72939be5a00 100644 --- a/crates/buzz-relay/src/api/operator_revocations.rs +++ b/crates/buzz-relay/src/api/operator_revocations.rs @@ -41,7 +41,7 @@ struct RevocationNotificationRequest { #[derive(Debug)] struct ValidatedRevocationNotification { id: String, - target_pubkey: String, + target_pubkey: PublicKey, occurred_at: DateTime, } @@ -68,29 +68,26 @@ fn validate_notification( return Err("id must be a canonical UUID"); } - validate_target_pubkey(&request.target_pubkey)?; + let target_pubkey = parse_target_pubkey(&request.target_pubkey)?; let occurred_at = validate_occurred_at(&request.occurred_at)?; Ok(ValidatedRevocationNotification { id: request.id, - target_pubkey: request.target_pubkey, + target_pubkey, occurred_at, }) } -fn validate_target_pubkey(value: &str) -> Result<(), &'static str> { - if value.len() != 64 - || !value - .bytes() - .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase()) - { +fn parse_target_pubkey(value: &str) -> Result { + let pubkey = PublicKey::from_hex(value) + .map_err(|_| "target_pubkey must be 64 lowercase hex characters")?; + if pubkey.to_hex() != value { return Err("target_pubkey must be 64 lowercase hex characters"); } - let pubkey = PublicKey::from_hex(value).map_err(|_| "target_pubkey is invalid")?; pubkey .xonly() .map_err(|_| "target_pubkey is not a valid x-only secp256k1 public key")?; - Ok(()) + Ok(pubkey) } fn validate_occurred_at(value: &str) -> Result, &'static str> { @@ -142,7 +139,7 @@ pub(crate) async fn receive_revocation_notification( tracing::info!( notification_id = %notification.id, signer = %signer.to_hex(), - target_pubkey = %notification.target_pubkey, + target_pubkey = %notification.target_pubkey.to_hex(), occurred_at = %occurred_at, status = "logged_stub", revocation_applied = false, @@ -177,7 +174,7 @@ mod tests { 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, VALID_PUBKEY); + assert_eq!(notification.target_pubkey.to_hex(), VALID_PUBKEY); } #[test] From 0cbfe3eeadbfead210c7d4d608cb07657db6f7bf Mon Sep 17 00:00:00 2001 From: jm Date: Mon, 21 Sep 2026 21:37:22 +0000 Subject: [PATCH 3/4] refactor(relay): deserialize revocation pubkeys with sdk Signed-off-by: jm Co-authored-by: Codex --- .../src/api/operator_revocations.rs | 48 ++++++++++--------- docs/operator-revocation-notifications.md | 14 +++--- 2 files changed, 34 insertions(+), 28 deletions(-) diff --git a/crates/buzz-relay/src/api/operator_revocations.rs b/crates/buzz-relay/src/api/operator_revocations.rs index 72939be5a00..e029f3e68af 100644 --- a/crates/buzz-relay/src/api/operator_revocations.rs +++ b/crates/buzz-relay/src/api/operator_revocations.rs @@ -13,7 +13,7 @@ use axum::{ response::Json, }; use chrono::{DateTime, SecondsFormat, Utc}; -use nostr::PublicKey; +use nostr::{secp256k1::XOnlyPublicKey, PublicKey}; use serde::{Deserialize, Serialize}; use serde_json::Value; use uuid::Uuid; @@ -34,7 +34,7 @@ struct RevocationNotificationRequest { id: String, #[serde(rename = "type")] notification_type: String, - target_pubkey: String, + target_pubkey: XOnlyPublicKey, occurred_at: String, } @@ -68,7 +68,7 @@ fn validate_notification( return Err("id must be a canonical UUID"); } - let target_pubkey = parse_target_pubkey(&request.target_pubkey)?; + let target_pubkey = PublicKey::from(request.target_pubkey); let occurred_at = validate_occurred_at(&request.occurred_at)?; Ok(ValidatedRevocationNotification { @@ -78,18 +78,6 @@ fn validate_notification( }) } -fn parse_target_pubkey(value: &str) -> Result { - let pubkey = PublicKey::from_hex(value) - .map_err(|_| "target_pubkey must be 64 lowercase hex characters")?; - if pubkey.to_hex() != value { - return Err("target_pubkey must be 64 lowercase hex characters"); - } - pubkey - .xonly() - .map_err(|_| "target_pubkey is not a valid x-only secp256k1 public key")?; - Ok(pubkey) -} - fn validate_occurred_at(value: &str) -> Result, &'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"); @@ -165,11 +153,17 @@ mod tests { version: 1, id: "550e8400-e29b-41d4-a716-446655440000".to_string(), notification_type: "identity.revoked".to_string(), - target_pubkey: VALID_PUBKEY.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"); @@ -204,14 +198,24 @@ mod tests { let mut noncanonical_id = valid_request(); noncanonical_id.id = "550E8400-E29B-41D4-A716-446655440000".to_string(); assert!(validate_notification(noncanonical_id).is_err()); + } - let mut uppercase_key = valid_request(); - uppercase_key.target_pubkey = VALID_PUBKEY.to_uppercase(); - assert!(validate_notification(uppercase_key).is_err()); + #[test] + fn target_pubkey_deserialization_uses_sdk_hex_type() { + let uppercase = VALID_PUBKEY.to_uppercase(); + let request = + serde_json::from_str::(&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 mut invalid_point = valid_request(); - invalid_point.target_pubkey = "0".repeat(64); - assert!(validate_notification(invalid_point).is_err()); + let invalid_point = "0".repeat(64); + for invalid in ["not-hex", invalid_point.as_str()] { + assert!( + serde_json::from_str::(&request_json(invalid)) + .is_err() + ); + } } #[test] diff --git a/docs/operator-revocation-notifications.md b/docs/operator-revocation-notifications.md index 98ef8bba5e2..887386e855a 100644 --- a/docs/operator-revocation-notifications.md +++ b/docs/operator-revocation-notifications.md @@ -14,7 +14,7 @@ Authorization: Nostr "version": 1, "id": "550e8400-e29b-41d4-a716-446655440000", "type": "identity.revoked", - "target_pubkey": "<64 lowercase hex characters>", + "target_pubkey": "<64 hex characters>", "occurred_at": "2026-09-21T12:34:56Z" } ``` @@ -28,11 +28,13 @@ accepts at most 4 KiB. The JSON contract is strict: unknown fields are rejected; `version` and `type` have the fixed values above; `id` is a canonical hyphenated UUID; -`target_pubkey` is a valid lowercase x-only secp256k1 public key; and -`occurred_at` is an RFC3339 UTC timestamp ending in `Z`. Fractional seconds are -accepted. An equivalent `+00:00` suffix is not accepted because the wire -contract requires `Z`. The occurrence time is informational and has no recency -requirement; NIP-98 independently supplies authentication freshness. +`target_pubkey` is a valid 64-character hexadecimal x-only secp256k1 public +key; and `occurred_at` is an RFC3339 UTC timestamp ending in `Z`. Public-key +hex is accepted in either letter case and normalized to lowercase in logs. +Fractional seconds are accepted. An equivalent `+00:00` suffix is not accepted +because the wire contract requires `Z`. The occurrence time is informational +and has no recency requirement; NIP-98 independently supplies authentication +freshness. Success returns: From 5948e9a40652167c9c1beb0ab44303e5f50d741c Mon Sep 17 00:00:00 2001 From: jm Date: Mon, 21 Sep 2026 22:40:14 +0000 Subject: [PATCH 4/4] docs(relay): trim revocation operator guidance Signed-off-by: jm Co-authored-by: Codex --- .env.example | 14 ++---- docs/operator-revocation-notifications.md | 59 ----------------------- 2 files changed, 5 insertions(+), 68 deletions(-) delete mode 100644 docs/operator-revocation-notifications.md diff --git a/.env.example b/.env.example index 399a4c29153..4e268d51288 100644 --- a/.env.example +++ b/.env.example @@ -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. @@ -113,14 +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. -# The same origin and operator signer allowlist authenticate the receipt-only -# POST /operator/revocation-notifications integration; see -# docs/operator-revocation-notifications.md. +# 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 diff --git a/docs/operator-revocation-notifications.md b/docs/operator-revocation-notifications.md deleted file mode 100644 index 887386e855a..00000000000 --- a/docs/operator-revocation-notifications.md +++ /dev/null @@ -1,59 +0,0 @@ -# Operator revocation notifications - -Buzz exposes a deployment-operator endpoint for integrations that normalize an -upstream offboarding signal to a Nostr public key: - -```http -POST /operator/revocation-notifications -Content-Type: application/json -Authorization: Nostr -``` - -```json -{ - "version": 1, - "id": "550e8400-e29b-41d4-a716-446655440000", - "type": "identity.revoked", - "target_pubkey": "<64 hex characters>", - "occurred_at": "2026-09-21T12:34:56Z" -} -``` - -The request uses the existing operator service authentication configured by -`RELAY_OPERATOR_API_ORIGIN` and `RELAY_OPERATOR_PUBKEYS`. The kind-27235 NIP-98 -event must sign the configured operator origin plus the exact endpoint path, -`POST`, and a `payload` tag containing the SHA-256 digest of the exact request -bytes. Existing freshness, replay, and operator-signer checks apply. The route -accepts at most 4 KiB. - -The JSON contract is strict: unknown fields are rejected; `version` and `type` -have the fixed values above; `id` is a canonical hyphenated UUID; -`target_pubkey` is a valid 64-character hexadecimal x-only secp256k1 public -key; and `occurred_at` is an RFC3339 UTC timestamp ending in `Z`. Public-key -hex is accepted in either letter case and normalized to lowercase in logs. -Fractional seconds are accepted. An equivalent `+00:00` suffix is not accepted -because the wire contract requires `Z`. The occurrence time is informational -and has no recency requirement; NIP-98 independently supplies authentication -freshness. - -Success returns: - -```json -{ - "accepted": true, - "status": "logged_stub", - "revocation_applied": false, - "id": "550e8400-e29b-41d4-a716-446655440000" -} -``` - -This acknowledgement means only that Buzz authenticated and validated the -notification and emitted a bounded structured log. It is not a durable audit -receipt and does not deduplicate deliveries, retry work, disconnect sessions, -change identity or database state, maintain a denylist, or enforce revocation. -Callers retrying a delivery must preserve the notification `id` and sign each -attempt with a fresh NIP-98 event. - -Future SCIM, identity-provider, or generic webhook adapters can produce this -normalized request once they have independently resolved a target public key. -This endpoint does not perform corporate-identity-to-key mapping.