From 7a73eaa9261f25f7bfc18d3ab95f8d653ee2e7df Mon Sep 17 00:00:00 2001 From: Duncan Date: Mon, 10 Aug 2026 20:06:41 -0400 Subject: [PATCH] fix(relay): stop requiring operator API origin for admin console boot RELAY_OPERATOR_PUBKEYS is the shared allowlist for both the NIP-98 admin console and the community-provisioning endpoints, but only provisioning needs RELAY_OPERATOR_API_ORIGIN. The boot hard-error forced admin-console operators to configure a provisioning surface they never use. Demote the boot error to a WARN naming the affected feature, and keep the provisioning endpoints fail-closed at request time: authorize_operator_request already rejects with a clean 500 when the origin is unset, before any replay or DB access. Document the decoupling and the NIP-11 admin_api advertisement in the env examples and the admin README. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- .env.example | 12 ++++++ CHANGELOG.md | 14 ++++++ crates/buzz-relay/src/api/operator.rs | 61 +++++++++++++++++++++++++++ crates/buzz-relay/src/config.rs | 49 +++++++++++++++------ deploy/compose/.env.example | 6 +++ docs/admin/README.md | 21 +++++++++ 6 files changed, 151 insertions(+), 12 deletions(-) diff --git a/.env.example b/.env.example index 369e5d167..c9d626d1a 100644 --- a/.env.example +++ b/.env.example @@ -88,10 +88,22 @@ RELAY_URL=ws://localhost:3000 # 2. RELAY_OWNER_PUBKEY — implicit Operator fallback when RELAY_OPERATOR_PUBKEYS is unset. # 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 +# (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. # RELAY_OPERATOR_PUBKEYS=<64-char hex pubkey>[,<64-char hex pubkey>...] # # 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. +# RELAY_OPERATOR_API_ORIGIN=http://127.0.0.1:3000 # Shared Redis-backed admission limits. Defaults shown below; each value must # be a positive integer. diff --git a/CHANGELOG.md b/CHANGELOG.md index 56b59c9d4..04ac3698c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,20 @@ - `Host`/`Origin` matching is retained in all modes as defense-in-depth. - **Migration from the previous `BUZZ_ADMIN_INSECURE_NO_AUTH=true`:** replace with `BUZZ_ADMIN_AUTH=disabled`. The behavior is identical. +- The relay NIP-11 relay-information document now advertises the admin API + origin in an optional `admin_api` field (`scheme://host[:port]`) whenever the + admin surface is configured (`BUZZ_ADMIN_HOST` set). The scheme follows the + same loopback rule as NIP-98 `u`-tag verification (`http` for + `localhost`/`127.x`/`::1`, else `https`). Clients can auto-discover the admin + console instead of requiring manual URL entry; the field is omitted entirely + when no admin surface is configured. +- `RELAY_OPERATOR_API_ORIGIN` is no longer required at boot when + `RELAY_OPERATOR_PUBKEYS` is set. The allowlist is shared by the NIP-98 admin + console (which needs no origin) and the community-provisioning endpoints + (which do). Setting the pubkeys for the admin console no longer forces an + origin; the relay logs a `WARN` naming the affected feature, and the + community-provisioning endpoints (`POST /operator/communities`) fail closed + at request time until `RELAY_OPERATOR_API_ORIGIN` is set. ## v0.5.11 diff --git a/crates/buzz-relay/src/api/operator.rs b/crates/buzz-relay/src/api/operator.rs index 5b69a4387..f19ac17d4 100644 --- a/crates/buzz-relay/src/api/operator.rs +++ b/crates/buzz-relay/src/api/operator.rs @@ -1249,4 +1249,65 @@ mod tests { assert_eq!(response.status(), StatusCode::BAD_REQUEST); } + + /// Regression for the RELAY_OPERATOR_API_ORIGIN decoupling: with the + /// operator allowlist set but no origin configured (the shape an + /// admin-console-only operator boots in), the provisioning endpoints must + /// fail closed with a clean 500 — never a panic, and never a silent + /// success. This exercises the request-time guard that replaced the boot + /// hard-error. It uses a lazy pool and needs no Postgres, because the + /// origin check in `authorize_operator_request` runs before any DB access. + #[tokio::test] + async fn provisioning_fails_closed_when_origin_unset_but_pubkeys_set() { + let operator = Keys::generate(); + + let mut config = crate::config::Config::from_env().expect("default config loads"); + config.require_relay_membership = false; + config.redis_url = "redis://127.0.0.1:1".to_string(); + config.relay_operator_pubkeys = vec![operator.public_key().to_hex()]; + config.relay_operator_api_origin = None; + + let pool = sqlx::PgPool::connect_lazy(&config.database_url).expect("lazy pg pool"); + 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)) + .expect("redis pool"); + let pubsub = Arc::new( + buzz_pubsub::PubSubManager::new(&config.redis_url, redis_pool.clone()) + .await + .expect("pubsub manager"), + ); + 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).expect("media storage"); + let (state, _audit_shutdown) = AppState::new( + config, + db, + redis_pool, + audit, + pubsub, + auth, + search, + workflow_engine, + Keys::generate(), + media_storage, + ); + let state = Arc::new(state); + + let response = + provision_community(state, &operator, "acme.example", &Keys::generate()).await; + + assert_eq!( + response.status(), + StatusCode::INTERNAL_SERVER_ERROR, + "provisioning must reject fail-closed when the operator API origin is unset" + ); + let body = read_json(response).await; + assert_eq!(body["error"], "internal server error"); + } } diff --git a/crates/buzz-relay/src/config.rs b/crates/buzz-relay/src/config.rs index b4aa1ddf9..54c83f55f 100644 --- a/crates/buzz-relay/src/config.rs +++ b/crates/buzz-relay/src/config.rs @@ -279,9 +279,14 @@ pub struct Config { /// Canonical HTTP origin of the deployment-global operator API. /// /// Every operator NIP-98 `u` tag is verified against this origin, independent - /// of the inbound HTTP `Host` header and tenant registry. Required when - /// `RELAY_OPERATOR_PUBKEYS` is non-empty. Set via `RELAY_OPERATOR_API_ORIGIN` - /// as an `http://` or `https://` origin with no path, query, or fragment. + /// of the inbound HTTP `Host` header and tenant registry. Required only to + /// *use* the community-provisioning endpoints: when it is unset, those + /// endpoints fail closed at request time (see + /// `api::operator::authorize_operator_request`). It is NOT required at boot + /// even when `RELAY_OPERATOR_PUBKEYS` is set, because that allowlist is + /// shared with the NIP-98 admin console, which needs no origin. Set via + /// `RELAY_OPERATOR_API_ORIGIN` as an `http://` or `https://` origin with no + /// path, query, or fragment. pub relay_operator_api_origin: Option, /// Deployment-level relay operator pubkeys allowed to use the @@ -780,10 +785,21 @@ impl Config { Err(_) => Vec::new(), }; if !relay_operator_pubkeys.is_empty() && relay_operator_api_origin.is_none() { - return Err(ConfigError::InvalidValue( - "RELAY_OPERATOR_API_ORIGIN is required when RELAY_OPERATOR_PUBKEYS is configured" - .to_string(), - )); + // Do NOT fail closed at boot: RELAY_OPERATOR_PUBKEYS is the shared + // allowlist for BOTH the community-provisioning endpoints and the + // NIP-98 admin console. Only provisioning needs the canonical + // origin, so requiring it at boot would force admin-console + // operators to configure a provisioning surface they never use. + // The provisioning endpoints stay fail-closed at request time + // (see `api::operator::authorize_operator_request`, which rejects + // when the origin is unconfigured); this warning names that so an + // operator who *did* want provisioning knows why it 500s. + warn!( + "RELAY_OPERATOR_PUBKEYS is set but RELAY_OPERATOR_API_ORIGIN is not — \ + the community-provisioning endpoints (POST /operator/communities) will \ + reject every request until RELAY_OPERATOR_API_ORIGIN is set. The NIP-98 \ + admin console does not require it and is unaffected." + ); } let auth = buzz_auth::AuthConfig { @@ -2004,7 +2020,11 @@ mod tests { } #[test] - fn relay_operator_pubkeys_require_api_origin() { + fn relay_operator_pubkeys_without_api_origin_boots_and_warns() { + // Regression: RELAY_OPERATOR_PUBKEYS is the shared allowlist for both + // community provisioning and the NIP-98 admin console. Configuring the + // admin console (pubkeys) must NOT force the provisioning origin — boot + // succeeds; provisioning stays fail-closed at request time. let _guard = ENV_MUTEX.lock().unwrap(); std::env::set_var( "RELAY_OPERATOR_PUBKEYS", @@ -2014,10 +2034,15 @@ mod tests { let result = Config::from_env(); std::env::remove_var("RELAY_OPERATOR_PUBKEYS"); - assert!(matches!( - result, - Err(ConfigError::InvalidValue(ref msg)) if msg.contains("RELAY_OPERATOR_API_ORIGIN is required") - )); + let config = result.expect("pubkeys-set/origin-unset must boot, not fail closed"); + assert_eq!( + config.relay_operator_pubkeys, + vec!["aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".to_string()] + ); + assert!( + config.relay_operator_api_origin.is_none(), + "origin stays unset — only the provisioning path requires it, at request time" + ); } #[test] diff --git a/deploy/compose/.env.example b/deploy/compose/.env.example index 234e6d7a3..3d9f83fbc 100644 --- a/deploy/compose/.env.example +++ b/deploy/compose/.env.example @@ -48,10 +48,16 @@ BUZZ_S3_ADDRESSING_STYLE=path # and the relay_operators table (DB-managed Operator/Moderator roster). # Dashboard requires a NIP-07 browser extension (nos2x or Alby). # Any unrecognised BUZZ_ADMIN_AUTH value, or conflicting combinations, aborts startup. +# When BUZZ_ADMIN_HOST is set, the relay advertises the admin origin in its NIP-11 +# document (`admin_api` field) so clients auto-discover the console without manual entry. +# Setting RELAY_OPERATOR_PUBKEYS for the console does NOT require RELAY_OPERATOR_API_ORIGIN; +# that origin is only for community provisioning (POST /operator/communities), which fails +# closed at request time until it is set (the relay boots with a WARN in the meantime). # BUZZ_ADMIN_HOST=admin.buzz.example.com # BUZZ_ADMIN_AUTH=token # BUZZ_ADMIN_TOKEN=CHANGE_ME_RANDOM_64_HEX # RELAY_OPERATOR_PUBKEYS=<64-char hex pubkey>[,<64-char hex pubkey>...] +# RELAY_OPERATOR_API_ORIGIN=https://admin.buzz.example.com # Optional host ports. Base compose publishes the relay directly on BUZZ_HTTP_PORT. BUZZ_HTTP_PORT=3000 diff --git a/docs/admin/README.md b/docs/admin/README.md index ba3b53737..1b62cd8fe 100644 --- a/docs/admin/README.md +++ b/docs/admin/README.md @@ -90,6 +90,27 @@ RELAY_OPERATOR_PUBKEYS=<64-char hex pubkey>[,<64-char hex pubkey>...] - `BUZZ_ADMIN_TOKEN` set alongside `nip98` is a startup error (ambiguous intent). - A malformed `RELAY_OWNER_PUBKEY` alongside `nip98` is a startup error (see owner fallback below). +- `RELAY_OPERATOR_API_ORIGIN` is **not** required to run the admin console. + That origin is only used by the community-provisioning endpoints + (`POST /operator/communities`), which share the `RELAY_OPERATOR_PUBKEYS` + allowlist. When the pubkeys are set but the origin is not, the relay boots + with a `WARN` and provisioning requests fail closed at request time until the + origin is set — the admin console is unaffected. + +#### Auto-discovery via NIP-11 + +When `BUZZ_ADMIN_HOST` is set, the relay advertises the admin API origin in its +NIP-11 relay-information document under an optional `admin_api` field: + +```json +{ "admin_api": "https://admin.example.com" } +``` + +The value is the canonical origin `scheme://host[:port]` (no path), with the +scheme derived by the same loopback rule as `u`-tag verification (`http` for +`localhost`/`127.x`/`::1`, else `https`). The field is omitted entirely when no +admin surface is configured. Clients (such as the desktop console) read this to +auto-discover the admin endpoint instead of requiring manual URL entry. Each request requires: