From 2a02440b50dfbd2fe4bf58759c1eff9764e7f488 Mon Sep 17 00:00:00 2001 From: Roberto Michelena <77797875+rmichelena@users.noreply.github.com> Date: Sun, 13 Sep 2026 15:20:13 -0500 Subject: [PATCH] fix(relay): record the NIP-OA owner for direct members on closed relays MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On a closed relay an agent that is itself a relay member never gets its NIP-OA owner recorded, however many valid attestations it presents. `enforce_relay_membership` returns `Ok(None)` as soon as direct membership is established, and the only owner-extraction fallback at either call site was gated on `!require_relay_membership` — off precisely where it is needed. With `agent_owner_pubkey` left NULL the relay cannot tell the agent from a human: `owner_only` policies have nothing to match, observer frames are refused, and the connection is rate-classed as human. Closes #4223, #4937. `relay_members::resolve_nip_oa_owner` settles the question once for both call sites. Admission by delegation keeps the owner it already proved is a member; an open relay keeps today's behaviour; a direct member on a closed relay has its attestation resolved, with the owner required to be a relay member. That membership requirement is load-bearing. `materialize_nip_oa_owner` is first-write-wins and the recorded owner selects the agent rate class, so without it any member could attest itself with a throwaway key, raise its own rate budget, and bind the mapping permanently against the real owner. `allow_nip_oa_auth` deliberately does not gate this: per its own doc comment the flag governs whether NIP-OA may *grant membership* on a closed relay, and nothing here grants access — the caller is already in. Time bounds now come from #7004 (`verify_auth_tag_for_auth_event` and the `signed_created_at` carried on `VerifiedBridgeAuth`), so this branch builds on those primitives rather than duplicating them. Nine tests exercise both production paths against real PostgreSQL and Redis: `submit_event_authed` and the full `POST /events` router, plus `handle_auth` on the WebSocket side. They live in `postgres_tests` modules so the structural PostgreSQL discovery lane picks them up, which is why this branch no longer patches `ci.yml`. Co-authored-by: Ravneet Arora Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: Roberto Michelena <77797875+rmichelena@users.noreply.github.com> --- crates/buzz-relay/src/api/bridge.rs | 416 ++++++++++++++++++++++++- crates/buzz-relay/src/api/mod.rs | 61 ++++ crates/buzz-relay/src/handlers/auth.rs | 299 ++++++++++++++++-- 3 files changed, 745 insertions(+), 31 deletions(-) diff --git a/crates/buzz-relay/src/api/bridge.rs b/crates/buzz-relay/src/api/bridge.rs index 37c549610de..73e5ad2bc48 100644 --- a/crates/buzz-relay/src/api/bridge.rs +++ b/crates/buzz-relay/src/api/bridge.rs @@ -921,7 +921,7 @@ async fn submit_event_authed( // Enforce relay membership (with NIP-OA fallback via x-auth-tag header). let auth_tag = super::relay_members::extract_auth_tag_header(headers); - let nip_oa_owner = match super::relay_members::enforce_relay_membership( + let admitted_via = match super::relay_members::enforce_relay_membership( state, tenant.community(), &pubkey_bytes, @@ -930,17 +930,7 @@ async fn submit_event_authed( ) .await { - Ok(owner) => owner.or_else(|| { - if !state.config.require_relay_membership { - super::relay_members::extract_nip_oa_owner( - &pubkey_bytes, - auth_tag, - signed_auth_created_at, - ) - } else { - None - } - }), + Ok(owner) => owner, Err(e) => { return SubmitOutcome::Err { status: e.0, @@ -948,6 +938,18 @@ async fn submit_event_authed( }; } }; + // A direct member is admitted before delegation is ever consulted, so its + // attestation still has to be resolved here or the relay never learns it is + // an agent at all. + let nip_oa_owner = super::relay_members::resolve_nip_oa_owner( + state, + tenant.community(), + &pubkey_bytes, + auth_tag, + signed_auth_created_at, + admitted_via, + ) + .await; if let Some(owner) = nip_oa_owner { super::relay_members::materialize_nip_oa_owner(state, tenant, &pubkey, &owner).await; } @@ -4238,4 +4240,394 @@ mod postgres_tests { "attribution line must carry the pubkey;\nlog:\n{log}" ); } + + // ── NIP-OA owner materialization over the real HTTP path ──────────────── + // + // These enter at `submit_event_authed`, the authenticated core of + // `POST /events`: everything outside it is NIP-98 verification and the + // attribution log, and everything the owner path touches — the membership + // gate, `resolve_nip_oa_owner`, `materialize_nip_oa_owner` — is inside. + // The `outer_nip98` pair additionally drives the axum router so the signed + // authentication timestamp cannot be dropped before it reaches the owner + // path. + + /// These tests are `#[ignore]`d and run only when explicitly selected, so a + /// silent skip is never what the caller wanted: it turns "the database was + /// missing" into a passing run. Fail loudly instead — this is the same + /// false-green shape the tests themselves exist to rule out. + fn require_infra(value: Option) -> T { + value.expect("NIP-OA owner tests need PostgreSQL and Redis; refusing to pass without them") + } + + async fn nip_oa_test_state() -> Option<(Arc, sqlx::PgPool)> { + let mut config = crate::config::Config::from_env().ok()?; + // The regression is closed-relay-only: on an open relay the owner was + // always recorded. + config.require_relay_membership = true; + config.database_url = crate::test_support::database_url(); + config.redis_url = + std::env::var("REDIS_URL").unwrap_or_else(|_| "redis://127.0.0.1:6379".to_string()); + + let pool = sqlx::PgPool::connect(&config.database_url).await.ok()?; + let db = buzz_db::Db::from_pool(pool.clone()); + let redis_pool = deadpool_redis::Config::from_url(&config.redis_url) + .create_pool(Some(deadpool_redis::Runtime::Tokio1)) + .ok()?; + let pubsub = Arc::new( + buzz_pubsub::PubSubManager::new(&config.redis_url, redis_pool.clone()) + .await + .ok()?, + ); + let audit = buzz_audit::AuditService::new(pool.clone()); + let auth = buzz_auth::AuthService::new(config.auth.clone()); + let search = buzz_search::SearchService::new(pool.clone()); + let workflow_engine = Arc::new(buzz_workflow::WorkflowEngine::new( + db.clone(), + buzz_workflow::WorkflowConfig::default(), + )); + let media_storage = buzz_media::MediaStorage::new(&config.media).ok()?; + let (state, _audit_shutdown) = crate::state::AppState::new( + config, + db, + redis_pool, + audit, + pubsub, + auth, + search, + workflow_engine, + Keys::generate(), + media_storage, + ); + Some((Arc::new(state), pool)) + } + + async fn seed_community(pool: &sqlx::PgPool) -> TenantContext { + let id = uuid::Uuid::new_v4(); + let host = format!("nip-oa-{}.example", id.simple()); + sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)") + .bind(id) + .bind(&host) + .execute(pool) + .await + .expect("insert test community"); + TenantContext::resolved(buzz_core::CommunityId::from_uuid(id), host) + } + + /// A signed event for the request body. Its ingest outcome is irrelevant: + /// owner materialization happens before ingest, so the assertion holds + /// whether or not the event itself is accepted. + fn body_event(keys: &Keys) -> Vec { + let event = EventBuilder::new(Kind::TextNote, "nip-oa owner materialization probe") + .sign_with_keys(keys) + .expect("sign body event"); + serde_json::to_vec(&event).expect("serialize body event") + } + + fn auth_tag_headers(tag_json: &str) -> HeaderMap { + let mut headers = HeaderMap::new(); + headers.insert("x-auth-tag", tag_json.parse().expect("header value")); + headers + } + + async fn stored_owner( + state: &AppState, + tenant: &TenantContext, + agent: &nostr::PublicKey, + ) -> Option> { + state + .db + .get_agent_channel_policy(tenant.community(), agent.as_bytes()) + .await + .expect("read agent policy") + .and_then(|(_, owner)| owner) + } + + async fn seed_relay_members(state: &AppState, tenant: &TenantContext, members: &[&Keys]) { + for keys in members { + state + .db + .add_relay_member( + tenant.community(), + &keys.public_key().to_hex(), + "member", + None, + ) + .await + .expect("add relay member"); + } + } + + /// Run one `POST /events` submission as `agent`, presenting `tag_json`. + /// + /// Panics if the submit fails before reaching owner materialization. + /// Admission and the NIP-98 replay guard run first and both fail closed on + /// a Redis blip, which short-circuits the request; without this check that + /// shows up downstream as "the owner was not recorded" and reads exactly + /// like a product bug instead of the infrastructure failure it is. + async fn submit_with_tag( + state: &Arc, + tenant: &TenantContext, + agent: &Keys, + tag_json: Option<&str>, + auth_event_created_at: u64, + ) { + let headers = match tag_json { + Some(tag) => auth_tag_headers(tag), + None => HeaderMap::new(), + }; + let outcome = submit_event_authed( + state, + tenant, + &headers, + &body_event(agent), + agent.public_key(), + fresh_nip98_event_id_bytes(), + Some(auth_event_created_at), + ) + .await; + if let SubmitOutcome::Err { status, .. } = &outcome { + panic!( + "submit failed before owner materialization (status {status}) — \ + infrastructure, not the owner path" + ); + } + } + + /// Drive the complete NIP-98 `POST /events` boundary through the axum + /// router. Unlike `submit_with_tag`, this makes the production wrapper + /// authenticate the signed request and carry its timestamp into the + /// ownership policy. + async fn post_events_with_nip98_tag( + state: &Arc, + tenant: &TenantContext, + agent: &Keys, + tag_json: &str, + auth_event_created_at: u64, + ) -> StatusCode { + use axum::body::Body; + use axum::http::{header, Request}; + use tower::ServiceExt; + + let body = body_event(agent); + let url = nip98_expected_url(&state.config.relay_url, tenant, "/events"); + let auth_event = EventBuilder::new(Kind::HttpAuth, "") + .tags([ + Tag::parse(["u", url.as_str()]).expect("u tag"), + Tag::parse(["method", "POST"]).expect("method tag"), + ]) + .custom_created_at(nostr::Timestamp::from(auth_event_created_at)) + .sign_with_keys(agent) + .expect("sign NIP-98 event"); + let auth_event_json = serde_json::to_string(&auth_event).expect("serialize auth event"); + let mut headers = nip98_auth_headers(&auth_event_json); + headers.insert( + header::HOST, + tenant.host().parse().expect("valid tenant host header"), + ); + headers.insert("x-auth-tag", tag_json.parse().expect("header value")); + + let mut request = Request::builder() + .method("POST") + .uri("/events") + .body(Body::from(body)) + .expect("build request"); + *request.headers_mut() = headers; + + crate::router::build_router(state.clone()) + .oneshot(request) + .await + .expect("router oneshot") + .status() + } + + /// The regression itself: a direct relay member on a closed relay presents + /// a valid attestation, and the owner is recorded. Before the fix this + /// silently resolved to no owner, so `owner_only` policies had nothing to + /// match and observer frames were refused. + #[tokio::test] + #[ignore = "requires PostgreSQL and Redis"] + async fn nip_oa_owner_http_records_owner_for_direct_member() { + let (state, pool) = require_infra(nip_oa_test_state().await); + let tenant = seed_community(&pool).await; + let agent = Keys::generate(); + let owner = Keys::generate(); + seed_relay_members(&state, &tenant, &[&agent, &owner]).await; + + let tag = buzz_sdk::nip_oa::compute_auth_tag(&owner, &agent.public_key(), "") + .expect("compute auth tag"); + submit_with_tag(&state, &tenant, &agent, Some(&tag), 1_000).await; + + assert_eq!( + stored_owner(&state, &tenant, &agent.public_key()).await, + Some(owner.public_key().to_bytes().to_vec()), + "a direct member's verified owner must be recorded on a closed relay", + ); + } + + /// The outer HTTP regression boundary: the router must authenticate a real + /// NIP-98 request and preserve its signed timestamp through `submit_event` + /// into owner materialization. Supplying `None` at that handoff makes this + /// assertion fail while the inner-path test above remains green. + /// + /// Authored by Ravneet Arora on the previous revision of this branch and + /// carried forward. + #[tokio::test] + #[ignore = "requires PostgreSQL and Redis"] + async fn nip_oa_owner_http_outer_nip98_records_owner_for_direct_member() { + let (state, pool) = require_infra(nip_oa_test_state().await); + let tenant = seed_community(&pool).await; + let agent = Keys::generate(); + let owner = Keys::generate(); + seed_relay_members(&state, &tenant, &[&agent, &owner]).await; + + let tag = buzz_sdk::nip_oa::compute_auth_tag(&owner, &agent.public_key(), "") + .expect("compute auth tag"); + let status = post_events_with_nip98_tag( + &state, + &tenant, + &agent, + &tag, + nostr::Timestamp::now().as_secs(), + ) + .await; + + assert_eq!(status, StatusCode::OK, "real POST /events must succeed"); + assert_eq!( + stored_owner(&state, &tenant, &agent.public_key()).await, + Some(owner.public_key().to_bytes().to_vec()), + "the outer NIP-98 wrapper must carry its signed timestamp into owner materialization", + ); + } + + /// A member cannot mint a throwaway keypair, attest itself, and have that + /// key trusted: the resolved owner selects the agent rate class and is + /// first-write-wins, so an untrusted key must never reach the record. + #[tokio::test] + #[ignore = "requires PostgreSQL and Redis"] + async fn nip_oa_owner_http_refuses_an_owner_that_is_not_a_relay_member() { + let (state, pool) = require_infra(nip_oa_test_state().await); + let tenant = seed_community(&pool).await; + let agent = Keys::generate(); + let stranger = Keys::generate(); + seed_relay_members(&state, &tenant, &[&agent]).await; + + let tag = buzz_sdk::nip_oa::compute_auth_tag(&stranger, &agent.public_key(), "") + .expect("compute auth tag"); + submit_with_tag(&state, &tenant, &agent, Some(&tag), 1_000).await; + + assert_eq!( + stored_owner(&state, &tenant, &agent.public_key()).await, + None, + "a non-member owner must not be recorded on a closed relay", + ); + } + + /// The new direct-member path must inherit the attestation's time bounds + /// rather than route around them: it resolves through + /// `extract_nip_oa_owner`, which evaluates `created_at` conditions against + /// the signed authentication event. The inside-window control proves the + /// refusal is the bound and not some unrelated rejection. + #[tokio::test] + #[ignore = "requires PostgreSQL and Redis"] + async fn nip_oa_owner_http_refuses_an_expired_attestation() { + let (state, pool) = require_infra(nip_oa_test_state().await); + let tenant = seed_community(&pool).await; + let agent = Keys::generate(); + let owner = Keys::generate(); + seed_relay_members(&state, &tenant, &[&agent, &owner]).await; + + let expired = + buzz_sdk::nip_oa::compute_auth_tag(&owner, &agent.public_key(), "created_at<1000") + .expect("compute auth tag"); + // Auth event is at the bound, which is outside it — bounds are strict. + submit_with_tag(&state, &tenant, &agent, Some(&expired), 1_000).await; + + assert_eq!( + stored_owner(&state, &tenant, &agent.public_key()).await, + None, + "an expired attestation must not be materialized", + ); + + // Same tag, inside its window: proves the refusal above is the time + // bound and not some unrelated rejection. + submit_with_tag(&state, &tenant, &agent, Some(&expired), 999).await; + assert_eq!( + stored_owner(&state, &tenant, &agent.public_key()).await, + Some(owner.public_key().to_bytes().to_vec()), + "the same attestation inside its window must be recorded", + ); + } + + /// The real NIP-98 wrapper must apply strict attestation bounds to the + /// timestamp it verified. The inside-window control proves the expired + /// refusal is caused by the bound rather than a broken outer request. + /// + /// Authored by Ravneet Arora on the previous revision of this branch and + /// carried forward. + #[tokio::test] + #[ignore = "requires PostgreSQL and Redis"] + async fn nip_oa_owner_http_outer_nip98_refuses_expired_attestation() { + let (state, pool) = require_infra(nip_oa_test_state().await); + let tenant = seed_community(&pool).await; + let agent = Keys::generate(); + let owner = Keys::generate(); + seed_relay_members(&state, &tenant, &[&agent, &owner]).await; + + let auth_event_created_at = nostr::Timestamp::now().as_secs(); + let tag = buzz_sdk::nip_oa::compute_auth_tag( + &owner, + &agent.public_key(), + &format!("created_at<{auth_event_created_at}"), + ) + .expect("compute auth tag"); + let expired_status = + post_events_with_nip98_tag(&state, &tenant, &agent, &tag, auth_event_created_at).await; + + assert_eq!( + expired_status, + StatusCode::OK, + "expired ownership metadata must not reject the otherwise valid event", + ); + assert_eq!( + stored_owner(&state, &tenant, &agent.public_key()).await, + None, + "an attestation at its strict upper bound must not materialize through the outer wrapper", + ); + + let inside_status = post_events_with_nip98_tag( + &state, + &tenant, + &agent, + &tag, + auth_event_created_at.saturating_sub(1), + ) + .await; + assert_eq!( + inside_status, + StatusCode::OK, + "the same attestation inside its window must reach owner materialization", + ); + assert_eq!( + stored_owner(&state, &tenant, &agent.public_key()).await, + Some(owner.public_key().to_bytes().to_vec()), + "the inside-window control must prove the expired refusal is not vacuous", + ); + } + + /// Membership alone never invents an owner. + #[tokio::test] + #[ignore = "requires PostgreSQL and Redis"] + async fn nip_oa_owner_http_without_a_tag_records_nothing() { + let (state, pool) = require_infra(nip_oa_test_state().await); + let tenant = seed_community(&pool).await; + let agent = Keys::generate(); + seed_relay_members(&state, &tenant, &[&agent]).await; + + submit_with_tag(&state, &tenant, &agent, None, 1_000).await; + + assert_eq!( + stored_owner(&state, &tenant, &agent.public_key()).await, + None, + ); + } } diff --git a/crates/buzz-relay/src/api/mod.rs b/crates/buzz-relay/src/api/mod.rs index 5745b8d4e59..3b7eba5de84 100644 --- a/crates/buzz-relay/src/api/mod.rs +++ b/crates/buzz-relay/src/api/mod.rs @@ -209,6 +209,67 @@ pub mod relay_members { } } + /// Decide which NIP-OA owner to record for a caller that is already admitted. + /// + /// `admitted_via` is what [`enforce_relay_membership`] returned: `Some` only + /// when admission itself went through delegation, which already proved the + /// owner is a relay member. `None` means the caller got in on its own — an + /// open relay, or a *direct* relay member. + /// + /// The direct-member case is the one this exists for. A closed relay never + /// reached owner extraction for it, because [`enforce_relay_membership`] + /// returns `Ok(None)` as soon as membership is established, so an agent that + /// was added to `relay_members` had no owner on file however many valid + /// attestations it presented (#4223, #4937). + /// + /// On a closed relay the attested owner must itself be a relay member. + /// [`materialize_nip_oa_owner`] is first-write-wins and the recorded owner + /// selects the agent rate class, so accepting an unverified owner would let + /// any member attest itself with a throwaway key — elevating its own rate + /// class and permanently binding the mapping against the real owner. + /// + /// `allow_nip_oa_auth` deliberately does not gate this. That flag governs + /// whether NIP-OA may *grant membership* on a closed relay; nothing here + /// grants access, the caller is already in. + pub async fn resolve_nip_oa_owner( + state: &AppState, + community: CommunityId, + pubkey_bytes: &[u8], + auth_tag_header: Option<&str>, + signed_auth_created_at: Option, + admitted_via: Option, + ) -> Option { + if admitted_via.is_some() { + return admitted_via; + } + + let owner = extract_nip_oa_owner(pubkey_bytes, auth_tag_header, signed_auth_created_at)?; + if !state.config.require_relay_membership { + return Some(owner); + } + + let owner_hex = owner.to_hex(); + match state.db.is_relay_member(community, &owner_hex).await { + Ok(true) => Some(owner), + Ok(false) => { + info!( + agent = %hex::encode(pubkey_bytes), + owner = %owner_hex, + "NIP-OA owner is not a relay member; not recording the attested mapping" + ); + None + } + Err(e) => { + tracing::warn!( + owner = %owner_hex, + error = %e, + "relay membership check (NIP-OA owner) failed; not recording the mapping" + ); + None + } + } + } + /// Persist a cryptographically verified NIP-OA agent→owner relationship. /// /// Both principals are ensured first because `agent_owner_pubkey` has a diff --git a/crates/buzz-relay/src/handlers/auth.rs b/crates/buzz-relay/src/handlers/auth.rs index 02e2cc03a64..7a47b43284c 100644 --- a/crates/buzz-relay/src/handlers/auth.rs +++ b/crates/buzz-relay/src/handlers/auth.rs @@ -2,9 +2,11 @@ //! //! Relay membership enforcement uses the shared //! [`crate::api::relay_members::enforce_relay_membership`] helper, which supports -//! NIP-OA owner-delegation fallback on closed relays. On open relays, the auth -//! handler calls [`crate::api::relay_members::extract_nip_oa_owner`] directly to -//! extract the owner pubkey for agent→owner backfill (observer frame auth). +//! NIP-OA owner-delegation fallback on closed relays. Whatever that helper +//! decides, the handler then calls +//! [`crate::api::relay_members::resolve_nip_oa_owner`] to settle the agent→owner +//! backfill (observer frame auth) — including for a direct relay member, whose +//! attestation admission never had to consult. //! //! For WebSocket auth, the NIP-OA `auth` tag is extracted from the signed AUTH //! event itself (the tag is integrity-protected by the event signature). @@ -216,7 +218,7 @@ pub async fn handle_auth(event: nostr::Event, conn: Arc, state: } // Relay membership gate — uses the shared helper with NIP-OA fallback. - let nip_oa_owner = match crate::api::relay_members::enforce_relay_membership( + let admitted_via = match crate::api::relay_members::enforce_relay_membership( &state, conn.tenant.community(), pubkey.as_bytes(), @@ -240,21 +242,19 @@ pub async fn handle_auth(event: nostr::Event, conn: Arc, state: } }; - // Open relay NIP-OA backfill: extract owner for agent→owner DB mapping - // (needed for observer frame auth). Only runs on open relays — on closed - // relays, enforce_relay_membership already handles NIP-OA delegation. - // No feature flag needed: NIP-OA is cryptographically self-proving. - let nip_oa_owner = nip_oa_owner.or_else(|| { - if !state.config.require_relay_membership && auth_tag_json.is_some() { - crate::api::relay_members::extract_nip_oa_owner( - pubkey.as_bytes(), - auth_tag_json.as_deref(), - Some(signed_auth_created_at), - ) - } else { - None - } - }); + // NIP-OA backfill: record the agent→owner mapping observer-frame auth + // and the agent rate class both read. `enforce_relay_membership` only + // reports an owner when admission itself went through delegation, so a + // direct member's attestation is resolved here or not at all. + let nip_oa_owner = crate::api::relay_members::resolve_nip_oa_owner( + &state, + conn.tenant.community(), + pubkey.as_bytes(), + auth_tag_json.as_deref(), + Some(signed_auth_created_at), + admitted_via, + ) + .await; // Stash NIP-OA owner on the auth context only after the shared // backfill confirms the first-write-wins relationship. @@ -352,3 +352,264 @@ mod tests { assert_eq!(extract_auth_tag_json(&event), None); } } + +/// NIP-OA owner materialization over the real NIP-42 path. +/// +/// These drive `handle_auth` itself, so reverting its production call site +/// makes the first one fail. +#[cfg(test)] +mod postgres_tests { + use std::collections::HashMap; + use std::sync::atomic::AtomicU8; + use std::sync::Arc; + + use nostr::{EventBuilder, Keys, Tag}; + use tokio::sync::{mpsc, Mutex, RwLock}; + use tokio_util::sync::CancellationToken; + use uuid::Uuid; + + use crate::connection::{AuthState, ConnectionState}; + use crate::state::AppState; + use buzz_core::tenant::TenantContext; + + /// An `#[ignore]`d test that was explicitly selected must never pass by + /// skipping: that turns "the database was missing" into a green run. + fn require_infra(value: Option) -> T { + value.expect("NIP-OA owner tests need PostgreSQL and Redis; refusing to pass without them") + } + + async fn ws_test_state() -> Option<(Arc, sqlx::PgPool)> { + let mut config = crate::config::Config::from_env().ok()?; + // The regression is closed-relay-only. + config.require_relay_membership = true; + config.database_url = crate::test_support::database_url(); + config.redis_url = + std::env::var("REDIS_URL").unwrap_or_else(|_| "redis://127.0.0.1:6379".to_string()); + + let pool = sqlx::PgPool::connect(&config.database_url).await.ok()?; + let db = buzz_db::Db::from_pool(pool.clone()); + let redis_pool = deadpool_redis::Config::from_url(&config.redis_url) + .create_pool(Some(deadpool_redis::Runtime::Tokio1)) + .ok()?; + let pubsub = Arc::new( + buzz_pubsub::PubSubManager::new(&config.redis_url, redis_pool.clone()) + .await + .ok()?, + ); + let audit = buzz_audit::AuditService::new(pool.clone()); + let auth = buzz_auth::AuthService::new(config.auth.clone()); + let search = buzz_search::SearchService::new(pool.clone()); + let workflow_engine = Arc::new(buzz_workflow::WorkflowEngine::new( + db.clone(), + buzz_workflow::WorkflowConfig::default(), + )); + let media_storage = buzz_media::MediaStorage::new(&config.media).ok()?; + let (state, _audit_shutdown) = AppState::new( + config, + db, + redis_pool, + audit, + pubsub, + auth, + search, + workflow_engine, + Keys::generate(), + media_storage, + ); + Some((Arc::new(state), pool)) + } + + async fn ws_seed_community(pool: &sqlx::PgPool) -> TenantContext { + let id = Uuid::new_v4(); + let host = format!("nip-oa-ws-{}.example", id.simple()); + sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)") + .bind(id) + .bind(&host) + .execute(pool) + .await + .expect("insert test community"); + TenantContext::resolved(buzz_core::CommunityId::from_uuid(id), host) + } + + async fn seed_relay_members(state: &AppState, tenant: &TenantContext, members: &[&Keys]) { + for keys in members { + state + .db + .add_relay_member( + tenant.community(), + &keys.public_key().to_hex(), + "member", + None, + ) + .await + .expect("add relay member"); + } + } + + /// A pending connection ready to receive AUTH, plus its challenge. + fn pending_conn(tenant: &TenantContext) -> (Arc, String) { + let challenge = buzz_auth::generate_challenge(); + let (send_tx, _send_rx) = mpsc::channel(16); + let (ctrl_tx, _ctrl_rx) = mpsc::channel(16); + let conn = Arc::new(ConnectionState { + conn_id: Uuid::new_v4(), + tenant: tenant.clone(), + remote_addr: "127.0.0.1:1234".parse().expect("socket addr"), + auth_state: RwLock::new(AuthState::Pending { + challenge: challenge.clone(), + }), + subscriptions: Arc::new(Mutex::new(HashMap::new())), + send_tx, + ctrl_tx, + cancel: CancellationToken::new(), + backpressure_count: Arc::new(AtomicU8::new(0)), + grace_limit: 3, + }); + (conn, challenge) + } + + /// Sign a NIP-42 AUTH event for `state`'s relay URL, carrying `tag_json` + /// as an `auth` tag, stamped at `created_at`. + fn auth_event( + state: &AppState, + tenant: &TenantContext, + agent: &Keys, + challenge: &str, + tag_json: &str, + created_at: u64, + ) -> nostr::Event { + let relay_url = + crate::api::bridge::nip42_expected_relay_url(&state.config.relay_url, tenant); + let url = nostr::RelayUrl::parse(&relay_url).expect("relay url"); + let parts: Vec = serde_json::from_str(tag_json).expect("auth tag json"); + EventBuilder::auth(challenge, url) + .tags([Tag::parse(parts).expect("auth tag")]) + .custom_created_at(nostr::Timestamp::from(created_at)) + .sign_with_keys(agent) + .expect("sign auth event") + } + + /// NIP-42 rejects a stale AUTH event, so these tests stamp at the real + /// clock and express the tag's bounds relative to it — which is also how a + /// live deployment presents an expiring credential. + fn now_secs() -> u64 { + nostr::Timestamp::now().as_secs() + } + + async fn ws_stored_owner( + state: &AppState, + tenant: &TenantContext, + agent: &nostr::PublicKey, + ) -> Option> { + state + .db + .get_agent_channel_policy(tenant.community(), agent.as_bytes()) + .await + .expect("read agent policy") + .and_then(|(_, owner)| owner) + } + + /// The regression on the WebSocket path: a direct member authenticating + /// with a valid attestation gets its owner recorded *and* carried onto the + /// live auth context, which is what observer-frame authorization and the + /// agent rate class both read. + #[tokio::test] + #[ignore = "requires PostgreSQL and Redis"] + async fn nip_oa_owner_ws_records_owner_and_sets_auth_context() { + let (state, pool) = require_infra(ws_test_state().await); + let tenant = ws_seed_community(&pool).await; + let agent = Keys::generate(); + let owner = Keys::generate(); + seed_relay_members(&state, &tenant, &[&agent, &owner]).await; + + let tag = buzz_sdk::nip_oa::compute_auth_tag(&owner, &agent.public_key(), "") + .expect("compute auth tag"); + let (conn, challenge) = pending_conn(&tenant); + let event = auth_event(&state, &tenant, &agent, &challenge, &tag, now_secs()); + + super::handle_auth(event, Arc::clone(&conn), Arc::clone(&state)).await; + + assert_eq!( + ws_stored_owner(&state, &tenant, &agent.public_key()).await, + Some(owner.public_key().to_bytes().to_vec()), + "NIP-42 auth by a direct member must record its verified owner", + ); + + let auth_state = conn.auth_state.read().await; + match &*auth_state { + AuthState::Authenticated(ctx) => assert_eq!( + ctx.agent_owner_pubkey, + Some(owner.public_key()), + "the live auth context must carry the owner", + ), + other => panic!("expected authenticated connection, got {other:?}"), + } + } + + /// An expired attestation authenticates the agent but confers nothing: no + /// ownership record, and no owner on the session, so the connection cannot + /// be classified into the agent rate tier. + #[tokio::test] + #[ignore = "requires PostgreSQL and Redis"] + async fn nip_oa_owner_ws_refuses_an_expired_attestation() { + let (state, pool) = require_infra(ws_test_state().await); + let tenant = ws_seed_community(&pool).await; + let agent = Keys::generate(); + let owner = Keys::generate(); + seed_relay_members(&state, &tenant, &[&agent, &owner]).await; + + let now = now_secs(); + // Bound sits at the AUTH event's own timestamp; bounds are strict, so + // the credential is outside its window. + let expired = buzz_sdk::nip_oa::compute_auth_tag( + &owner, + &agent.public_key(), + &format!("created_at<{now}"), + ) + .expect("compute auth tag"); + let (conn, challenge) = pending_conn(&tenant); + let event = auth_event(&state, &tenant, &agent, &challenge, &expired, now); + + super::handle_auth(event, Arc::clone(&conn), Arc::clone(&state)).await; + + assert_eq!( + ws_stored_owner(&state, &tenant, &agent.public_key()).await, + None, + "an expired attestation must not be materialized", + ); + + let auth_state = conn.auth_state.read().await; + match &*auth_state { + AuthState::Authenticated(ctx) => assert_eq!( + ctx.agent_owner_pubkey, None, + "an expired attestation must not classify the session as an agent", + ), + other => panic!("expected authenticated connection, got {other:?}"), + } + } + + /// A non-member owner is not trusted on a closed relay, so nothing is + /// recorded and the session stays unclassified. + #[tokio::test] + #[ignore = "requires PostgreSQL and Redis"] + async fn nip_oa_owner_ws_refuses_an_owner_that_is_not_a_relay_member() { + let (state, pool) = require_infra(ws_test_state().await); + let tenant = ws_seed_community(&pool).await; + let agent = Keys::generate(); + let stranger = Keys::generate(); + seed_relay_members(&state, &tenant, &[&agent]).await; + + let tag = buzz_sdk::nip_oa::compute_auth_tag(&stranger, &agent.public_key(), "") + .expect("compute auth tag"); + let (conn, challenge) = pending_conn(&tenant); + let event = auth_event(&state, &tenant, &agent, &challenge, &tag, now_secs()); + + super::handle_auth(event, Arc::clone(&conn), Arc::clone(&state)).await; + + assert_eq!( + ws_stored_owner(&state, &tenant, &agent.public_key()).await, + None, + "a non-member owner must not be recorded on a closed relay", + ); + } +}