Advertise NIP-FI discovery in relay NIP-11

Signed-off-by: coder 3 <3bc2c73cf6228485ecee3c37d374aa143eab158665389f5168c4cbbf82ef7018@buzz.block.builderlab.xyz>
This commit is contained in:
coder 3
2026-09-17 19:23:53 -04:00
parent 9865fa71f3
commit 5252c876ba
8 changed files with 155 additions and 94 deletions

View File

@@ -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

View File

@@ -72,6 +72,19 @@ pub struct AdminConfig {
pub web_dir: Option<std::path::PathBuf>,
}
/// 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<bool, ConfigError> {
parse_bool(name, false)
}
fn parse_nip_fi_mode_value(raw: Option<&str>) -> Result<buzz_auth::NipFiMode, ConfigError> {
fn parse_nip_fi_mode_value(raw: Option<&str>) -> Result<NipFiAdvertisementMode, ConfigError> {
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<buzz_auth::NipFiMode, ConfigError> {
match std::env::var("BUZZ_NIP_FI_MODE") {
fn parse_nip_fi_mode_from(
lookup: impl Fn(&str) -> Result<String, std::env::VarError>,
) -> Result<NipFiAdvertisementMode, ConfigError> {
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<buzz_auth::NipFiMode, ConfigError> {
}
}
fn parse_nip_fi_mode() -> Result<NipFiAdvertisementMode, ConfigError> {
parse_nip_fi_mode_from(|name| std::env::var(name))
}
fn ensure_git_repo_path(
raw: impl Into<std::path::PathBuf>,
) -> Result<std::path::PathBuf, ConfigError> {
@@ -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")
));
}

View File

@@ -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<GifDescriptor>,
/// 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");

View File

@@ -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`).

View File

@@ -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

View File

@@ -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: ""

View File

@@ -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"],

View File

@@ -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: []