mirror of
https://github.com/block/buzz.git
synced 2026-10-31 06:21:34 +00:00
Advertise NIP-FI discovery in relay NIP-11
Signed-off-by: coder 3 <3bc2c73cf6228485ecee3c37d374aa143eab158665389f5168c4cbbf82ef7018@buzz.block.builderlab.xyz>
This commit is contained in:
14
.env.example
14
.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
|
||||
|
||||
@@ -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")
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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`).
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: ""
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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: []
|
||||
|
||||
Reference in New Issue
Block a user