From 5252c876ba01b7f9d5ab1b640a87d04d383e90c2 Mon Sep 17 00:00:00 2001 From: coder 3 <3bc2c73cf6228485ecee3c37d374aa143eab158665389f5168c4cbbf82ef7018@buzz.block.builderlab.xyz> Date: Thu, 17 Sep 2026 19:23:53 -0400 Subject: [PATCH] Advertise NIP-FI discovery in relay NIP-11 Signed-off-by: coder 3 <3bc2c73cf6228485ecee3c37d374aa143eab158665389f5168c4cbbf82ef7018@buzz.block.builderlab.xyz> --- .env.example | 14 +-- crates/buzz-relay/src/config.rs | 99 +++++++++++++------ crates/buzz-relay/src/nip11.rs | 42 ++++---- deploy/charts/buzz/README.md | 8 +- deploy/charts/buzz/tests/render_test.yaml | 58 +++++------ deploy/charts/buzz/tests/validation_test.yaml | 14 +++ deploy/charts/buzz/values.schema.json | 4 +- deploy/charts/buzz/values.yaml | 10 +- 8 files changed, 155 insertions(+), 94 deletions(-) diff --git a/.env.example b/.env.example index 796b1621d1..e49781a5b8 100644 --- a/.env.example +++ b/.env.example @@ -74,12 +74,14 @@ RELAY_URL=ws://localhost:3000 # (use `just web` for Vite HMR instead). # BUZZ_WEB_DIR=./web/dist -# NIP-FI enterprise auth. Off by default. Set to enforce only in deployments -# that already require client-attached federated identity evidence. In enforce -# mode NIP-11 advertises only the privacy-safe requirement signal defined by -# docs/nips/NIP-FI.md: no issuer, audience, JWKS, Auth0/IdP, login, tenant, or -# other identity details are exposed. deny_protected is a fail-closed repair -# posture and does not advertise a client-auth contract. +# NIP-FI enterprise auth advertisement. Off by default. Set to shadow only when +# the deployment wants clients to discover and prepare for a federated-identity +# limitation before relay-side enforcement exists. Shadow mode changes only the +# NIP-11 document: it emits the privacy-safe requirement signal defined by +# docs/nips/NIP-FI.md, with no issuer, audience, JWKS, Auth0/IdP, login, tenant, +# or other identity details. Admission remains unchanged. The enforce value is +# reserved but rejected until relay-side validation and protected-ingress +# enforcement are implemented. # BUZZ_NIP_FI_MODE=off # NIP-PL mobile push is an explicit deployment opt-in. A gateway URL alone diff --git a/crates/buzz-relay/src/config.rs b/crates/buzz-relay/src/config.rs index b47824125c..5d992e4a65 100644 --- a/crates/buzz-relay/src/config.rs +++ b/crates/buzz-relay/src/config.rs @@ -72,6 +72,19 @@ pub struct AdminConfig { pub web_dir: Option, } +/// NIP-FI advertisement mode for the NIP-11 relay document. +/// +/// This setting controls discovery only. It does not enable NIP-FI assertion +/// validation, protected-ingress checks, or any other admission enforcement. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum NipFiAdvertisementMode { + /// Do not advertise any federated-identity requirement. + Off, + /// Advertise the privacy-safe federated-identity limitation and descriptor + /// so clients can discover and prepare for the enterprise-auth contract. + Shadow, +} + /// Relay-hosted policy content presented on join surfaces. #[derive(Debug, Clone)] pub struct JoinPolicyConfig { @@ -209,13 +222,13 @@ pub struct Config { /// are permitted regardless of auth method (API token, NIP-42). pub require_relay_membership: bool, - /// NIP-FI federated-identity mode selected for this relay deployment. + /// NIP-FI advertisement mode selected for this relay deployment. /// - /// `Enforce` is the configured enterprise-auth requirement source of truth - /// used by NIP-11 discovery. Other modes do not advertise a requirement: - /// `Off` is unconfigured, and `DenyProtected` is an operator fail-closed - /// repair posture rather than a usable client-auth contract. - pub nip_fi_mode: buzz_auth::NipFiMode, + /// This controls NIP-11 discovery only. `Shadow` emits the privacy-safe + /// federated-identity limitation/descriptor while admission remains + /// unchanged. `Enforce` is intentionally rejected by config parsing until + /// relay-side NIP-FI validation and protected-ingress enforcement exist. + pub nip_fi_mode: NipFiAdvertisementMode, /// Whether this deployment can serve huddle (voice) audio. /// @@ -498,19 +511,24 @@ fn parse_optional_bool(name: &str) -> Result { parse_bool(name, false) } -fn parse_nip_fi_mode_value(raw: Option<&str>) -> Result { +fn parse_nip_fi_mode_value(raw: Option<&str>) -> Result { match raw.map(str::trim).map(str::to_ascii_lowercase).as_deref() { - None | Some("") | Some("off") => Ok(buzz_auth::NipFiMode::Off), - Some("enforce") => Ok(buzz_auth::NipFiMode::Enforce), - Some("deny_protected") | Some("deny-protected") => Ok(buzz_auth::NipFiMode::DenyProtected), + None | Some("") | Some("off") => Ok(NipFiAdvertisementMode::Off), + Some("shadow") => Ok(NipFiAdvertisementMode::Shadow), + Some("enforce") => Err(ConfigError::InvalidValue( + "BUZZ_NIP_FI_MODE=enforce is reserved for future relay-side NIP-FI enforcement, but enforcement is not implemented; use \"shadow\" for discovery-only NIP-11 advertisement" + .to_string(), + )), Some(other) => Err(ConfigError::InvalidValue(format!( - "BUZZ_NIP_FI_MODE must be \"off\", \"enforce\", or \"deny_protected\"; got {other:?}" + "BUZZ_NIP_FI_MODE must be \"off\" or \"shadow\"; \"enforce\" is reserved but unimplemented; got {other:?}" ))), } } -fn parse_nip_fi_mode() -> Result { - match std::env::var("BUZZ_NIP_FI_MODE") { +fn parse_nip_fi_mode_from( + lookup: impl Fn(&str) -> Result, +) -> Result { + match lookup("BUZZ_NIP_FI_MODE") { Err(std::env::VarError::NotPresent) => parse_nip_fi_mode_value(None), Err(error) => Err(ConfigError::InvalidValue(format!( "BUZZ_NIP_FI_MODE must be valid UTF-8: {error}" @@ -519,6 +537,10 @@ fn parse_nip_fi_mode() -> Result { } } +fn parse_nip_fi_mode() -> Result { + parse_nip_fi_mode_from(|name| std::env::var(name)) +} + fn ensure_git_repo_path( raw: impl Into, ) -> Result { @@ -1422,7 +1444,7 @@ mod tests { ); assert_eq!( config.nip_fi_mode, - buzz_auth::NipFiMode::Off, + NipFiAdvertisementMode::Off, "NIP-FI mode should default to off" ); assert!( @@ -2109,33 +2131,52 @@ mod tests { } #[test] - fn nip_fi_mode_defaults_off_and_accepts_enforcement_modes() { + fn nip_fi_mode_defaults_off_and_accepts_shadow() { assert_eq!( parse_nip_fi_mode_value(None).unwrap(), - buzz_auth::NipFiMode::Off + NipFiAdvertisementMode::Off ); assert_eq!( parse_nip_fi_mode_value(Some("")).unwrap(), - buzz_auth::NipFiMode::Off + NipFiAdvertisementMode::Off ); assert_eq!( parse_nip_fi_mode_value(Some("off")).unwrap(), - buzz_auth::NipFiMode::Off + NipFiAdvertisementMode::Off ); assert_eq!( - parse_nip_fi_mode_value(Some("ENFORCE")).unwrap(), - buzz_auth::NipFiMode::Enforce - ); - assert_eq!( - parse_nip_fi_mode_value(Some(" deny_protected ")).unwrap(), - buzz_auth::NipFiMode::DenyProtected - ); - assert_eq!( - parse_nip_fi_mode_value(Some("deny-protected")).unwrap(), - buzz_auth::NipFiMode::DenyProtected + parse_nip_fi_mode_value(Some(" SHADOW ")).unwrap(), + NipFiAdvertisementMode::Shadow ); } + #[test] + fn nip_fi_mode_rejects_enforce_as_unimplemented() { + let result = parse_nip_fi_mode_value(Some("enforce")); + assert!(matches!( + result, + Err(ConfigError::InvalidValue(ref message)) + if message.contains("BUZZ_NIP_FI_MODE=enforce") + && message.contains("enforcement is not implemented") + && message.contains("shadow") + )); + } + + #[test] + fn nip_fi_mode_from_env_rejects_enforce_at_config_load() { + let result = parse_nip_fi_mode_from(|name| { + assert_eq!(name, "BUZZ_NIP_FI_MODE"); + Ok("enforce".to_string()) + }); + + assert!(matches!( + result, + Err(ConfigError::InvalidValue(ref message)) + if message.contains("BUZZ_NIP_FI_MODE=enforce") + && message.contains("enforcement is not implemented") + )); + } + #[test] fn nip_fi_mode_rejects_unknown_values() { let result = parse_nip_fi_mode_value(Some("sometimes")); @@ -2143,6 +2184,8 @@ mod tests { result, Err(ConfigError::InvalidValue(ref message)) if message.contains("BUZZ_NIP_FI_MODE") + && message.contains("off") + && message.contains("shadow") )); } diff --git a/crates/buzz-relay/src/nip11.rs b/crates/buzz-relay/src/nip11.rs index 2f107f5e10..a8dc013fd4 100644 --- a/crates/buzz-relay/src/nip11.rs +++ b/crates/buzz-relay/src/nip11.rs @@ -21,10 +21,16 @@ impl NIP11Requirements { } } - fn from_nip11_facts(relay_membership: bool, nip_fi_mode: buzz_auth::NipFiMode) -> Self { + fn from_nip11_facts( + relay_membership: bool, + nip_fi_mode: crate::config::NipFiAdvertisementMode, + ) -> Self { Self { relay_membership, - federated_identity: matches!(nip_fi_mode, buzz_auth::NipFiMode::Enforce), + federated_identity: matches!( + nip_fi_mode, + crate::config::NipFiAdvertisementMode::Shadow + ), } } } @@ -91,8 +97,8 @@ pub struct RelayInfo { /// provider-agnostic; provider credentials remain server-side. #[serde(skip_serializing_if = "Option::is_none")] pub gif: Option, - /// NIP-FI federated identity requirement descriptor. Present only when - /// this deployment requires client-attached federated identity evidence. + /// NIP-FI federated identity requirement descriptor. Present only in + /// discovery shadow mode, while admission remains unchanged. /// The descriptor is privacy-safe by construction: no issuer, audience, /// provider, login, assertion, tenant, or deployment-local identifiers. #[serde(skip_serializing_if = "Option::is_none")] @@ -135,8 +141,10 @@ pub struct RelayLimitation { pub payment_required: bool, /// Whether writes are restricted to authorized pubkeys. pub restricted_writes: bool, - /// Whether this deployment requires NIP-FI federated identity assertions. - /// Omitted when false to preserve the pre-NIP-FI wire shape. + /// Whether this deployment advertises a NIP-FI federated identity + /// limitation for client discovery/readiness. This is discovery-only; + /// admission remains unchanged. Omitted when false to preserve the + /// pre-NIP-FI wire shape. #[serde(default, skip_serializing_if = "std::ops::Not::not")] pub federated_identity: bool, /// NIP-ER: how the relay delivers due reminders ("push" or "lazy"). @@ -198,9 +206,9 @@ impl RelayInfo { /// the caller so `build` itself stays static-input. /// /// `requirements` controls which configured server-side requirements are - /// advertised. It is a pre-derived deployment scalar: callers must set - /// `federated_identity` only when this relay's configured deployment - /// already enforces client-attached NIP-FI evidence. When + /// advertised. `federated_identity` is a discovery-only shadow signal for + /// client readiness; it does not imply this relay validates NIP-FI + /// assertions or enforces protected-ingress admission. When /// `relay_membership` is set, NIP-43 (relay membership) is added to /// `supported_nips`; only set it when the relay actually emits and gates on /// NIP-43 events — i.e. has a stable key AND enforces membership. NIP-43 @@ -738,11 +746,10 @@ mod tests { } #[test] - fn nip_fi_requirement_is_derived_only_from_enforce_mode() { + fn nip_fi_requirement_is_derived_only_from_shadow_mode() { let cases = [ - (buzz_auth::NipFiMode::Off, false), - (buzz_auth::NipFiMode::DenyProtected, false), - (buzz_auth::NipFiMode::Enforce, true), + (crate::config::NipFiAdvertisementMode::Off, false), + (crate::config::NipFiAdvertisementMode::Shadow, true), ]; for (mode, expected) in cases { @@ -755,7 +762,7 @@ mod tests { } async fn test_state_for_nip11_mode( - mode: buzz_auth::NipFiMode, + mode: crate::config::NipFiAdvertisementMode, ) -> (crate::state::AppState, crate::state::AuditShutdownHandle) { let mut config = crate::config::Config::from_env().expect("default config loads"); config.nip_fi_mode = mode; @@ -793,11 +800,10 @@ mod tests { } #[tokio::test] - async fn nip11_document_advertises_nip_fi_for_enforce_mode_only() { + async fn nip11_document_advertises_nip_fi_for_shadow_mode_only() { for (mode, should_advertise) in [ - (buzz_auth::NipFiMode::Off, false), - (buzz_auth::NipFiMode::DenyProtected, false), - (buzz_auth::NipFiMode::Enforce, true), + (crate::config::NipFiAdvertisementMode::Off, false), + (crate::config::NipFiAdvertisementMode::Shadow, true), ] { let (state, audit_shutdown) = test_state_for_nip11_mode(mode).await; let json = serde_json::to_value(nip11_document(&state, "").await).expect("serialize"); diff --git a/deploy/charts/buzz/README.md b/deploy/charts/buzz/README.md index 22b5c0dddd..cedf86d261 100644 --- a/deploy/charts/buzz/README.md +++ b/deploy/charts/buzz/README.md @@ -65,13 +65,7 @@ See: | `secrets.existingSecret` | Name of pre-created Secret | Production / GitOps | | `externalPostgresql.url` / `externalRedis.url` / `s3.endpoint` | External service URLs | Production — when the matching bundled service is disabled (the default) | -Set `relay.nipFiMode=enforce` only for deployments that already require -client-attached NIP-FI evidence. The chart renders this as `BUZZ_NIP_FI_MODE`, -which makes NIP-11 advertise only the privacy-safe requirement shape from -`docs/nips/NIP-FI.md`: no issuer, audience, JWKS, Auth0/IdP, login, tenant, or -other identity details belong in chart values or discovery output. The default -`off` mode omits the discovery fields; `deny_protected` is a fail-closed repair -posture and intentionally does not advertise a usable client-auth contract. +Set `relay.nipFiMode=shadow` only when the deployment wants clients to discover and prepare for a federated-identity limitation before relay-side enforcement exists. The chart renders this as `BUZZ_NIP_FI_MODE`. Shadow mode makes NIP-11 advertise only the privacy-safe requirement shape from `docs/nips/NIP-FI.md`: no issuer, audience, JWKS, Auth0/IdP, login, tenant, or other identity details belong in chart values or discovery output. Admission remains unchanged. The default `off` mode omits the discovery fields. The value `enforce` is reserved for the future enforced mode, but the current relay rejects it at startup until NIP-FI validation and protected-ingress enforcement are implemented. The chart fails at `helm install` / `helm template` time with a clear message if any of these are missing or malformed (see `templates/_validate.tpl`). diff --git a/deploy/charts/buzz/tests/render_test.yaml b/deploy/charts/buzz/tests/render_test.yaml index 2ed3f9eac0..1a87c84d1b 100644 --- a/deploy/charts/buzz/tests/render_test.yaml +++ b/deploy/charts/buzz/tests/render_test.yaml @@ -54,7 +54,35 @@ tests: name: BUZZ_NIP_FI_MODE value: "off" template: templates/deployment.yaml - - it: renders configured NIP-FI enforcement without identity-provider details + - it: renders NIP-FI shadow advertisement without identity-provider details + set: + relayUrl: wss://buzz.example.com + ownerPubkey: "0000000000000000000000000000000000000000000000000000000000000000" + externalPostgresql.url: postgres://u:p@h:5432/d + externalRedis.url: redis://h:6379 + s3.endpoint: http://minio:9000 + s3.accessKey: a + s3.secretKey: s + relay.nipFiMode: shadow + asserts: + - contains: + path: spec.template.spec.containers[0].env + content: + name: BUZZ_NIP_FI_MODE + value: "shadow" + template: templates/deployment.yaml + - notContains: + path: spec.template.spec.containers[0].env + content: + name: BUZZ_AUTH0_ISSUER + template: templates/deployment.yaml + - notContains: + path: spec.template.spec.containers[0].env + content: + name: BUZZ_NIP_FI_ISSUER + template: templates/deployment.yaml + + - it: renders reserved NIP-FI enforce mode for relay startup rejection set: relayUrl: wss://buzz.example.com ownerPubkey: "0000000000000000000000000000000000000000000000000000000000000000" @@ -71,33 +99,7 @@ tests: name: BUZZ_NIP_FI_MODE value: "enforce" template: templates/deployment.yaml - - notContains: - path: spec.template.spec.containers[0].env - content: - name: BUZZ_AUTH0_ISSUER - template: templates/deployment.yaml - - notContains: - path: spec.template.spec.containers[0].env - content: - name: BUZZ_NIP_FI_ISSUER - template: templates/deployment.yaml - - it: renders NIP-FI deny_protected as a non-advertising fail-closed mode - set: - relayUrl: wss://buzz.example.com - ownerPubkey: "0000000000000000000000000000000000000000000000000000000000000000" - externalPostgresql.url: postgres://u:p@h:5432/d - externalRedis.url: redis://h:6379 - s3.endpoint: http://minio:9000 - s3.accessKey: a - s3.secretKey: s - relay.nipFiMode: deny_protected - asserts: - - contains: - path: spec.template.spec.containers[0].env - content: - name: BUZZ_NIP_FI_MODE - value: "deny_protected" - template: templates/deployment.yaml + - it: renders virtual-hosted S3 addressing for providers that require it set: relayUrl: wss://buzz.example.com diff --git a/deploy/charts/buzz/tests/validation_test.yaml b/deploy/charts/buzz/tests/validation_test.yaml index 3498189c8a..45a174cf27 100644 --- a/deploy/charts/buzz/tests/validation_test.yaml +++ b/deploy/charts/buzz/tests/validation_test.yaml @@ -12,6 +12,20 @@ tests: - failedTemplate: errorPattern: "image.digest: Does not match pattern" + - it: rejects unknown NIP-FI mode at schema level + set: + relayUrl: wss://buzz.example.com + ownerPubkey: "0000000000000000000000000000000000000000000000000000000000000000" + externalPostgresql.url: postgres://u:p@h:5432/d + externalRedis.url: redis://h:6379 + s3.endpoint: http://minio:9000 + s3.accessKey: a + s3.secretKey: s + relay.nipFiMode: sometimes + asserts: + - failedTemplate: + errorPattern: "relay.nipFiMode:.*off.*shadow.*enforce" + - it: fails when relayUrl is missing set: relayUrl: "" diff --git a/deploy/charts/buzz/values.schema.json b/deploy/charts/buzz/values.schema.json index e748ee14eb..744e38dd2f 100644 --- a/deploy/charts/buzz/values.schema.json +++ b/deploy/charts/buzz/values.schema.json @@ -70,8 +70,8 @@ "allowNipOaAuth": { "type": "boolean" }, "nipFiMode": { "type": "string", - "enum": ["off", "enforce", "deny_protected", "deny-protected"], - "description": "NIP-FI enterprise-auth mode. enforce advertises only the privacy-safe NIP-11 federated_identity requirement shape; provider and issuer details stay outside chart values." + "enum": ["off", "shadow", "enforce"], + "description": "NIP-FI enterprise-auth advertisement mode. shadow advertises the privacy-safe NIP-11 federated_identity limitation/descriptor for client discovery while admission remains unchanged. enforce is reserved but rejected by the relay until enforcement is implemented." }, "huddleAudioAvailable": { "type": ["boolean", "null"], diff --git a/deploy/charts/buzz/values.yaml b/deploy/charts/buzz/values.yaml index 9902bfd568..5f1507a57f 100644 --- a/deploy/charts/buzz/values.yaml +++ b/deploy/charts/buzz/values.yaml @@ -120,11 +120,11 @@ relay: requireAuthToken: true requireRelayMembership: true allowNipOaAuth: true - # NIP-FI enterprise-auth mode. "enforce" makes the relay require - # client-attached federated identity evidence and advertise only the - # privacy-safe NIP-11 requirement shape (no issuer/audience/provider URLs). - # "deny_protected" is fail-closed repair posture and intentionally does not - # advertise a usable client-auth contract. + # NIP-FI enterprise-auth advertisement mode. "shadow" changes only the + # relay's NIP-11 document: it advertises the privacy-safe federated-identity + # limitation/descriptor for client discovery/readiness while admission remains + # unchanged. "enforce" is reserved but the relay rejects it until NIP-FI + # validation and protected-ingress enforcement are implemented. nipFiMode: "off" pubkeyAllowlist: false corsOrigins: []