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 <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
This commit is contained in:
Duncan
2026-08-12 23:20:29 -04:00
co-authored by Will Pfleger
parent bcfa86046f
commit 7a73eaa926
6 changed files with 151 additions and 12 deletions
+12
View File
@@ -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.
+14
View File
@@ -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
+61
View File
@@ -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");
}
}
+37 -12
View File
@@ -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<String>,
/// 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]
+6
View File
@@ -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
+21
View File
@@ -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: