diff --git a/.env.example b/.env.example index 6d12738247..d05d4c5f10 100644 --- a/.env.example +++ b/.env.example @@ -69,6 +69,11 @@ RELAY_URL=ws://localhost:3000 # Stable relay signing key (required). `just bootstrap` generates a random key in # the gitignored .env file. Preserve that value across restarts and backups. # BUZZ_RELAY_PRIVATE_KEY=<32-byte hex private key> +# Host-wide NIP-11 workflow lifecycle advertisement: default off; only true/1 opts in. +# Enable only AFTER all incompatible endpoints AND existing connections are drained. +# Gates discovery only, not lifecycle enforcement. See the two-phase operator sequence: +# deploy/charts/buzz/README.md#workflow-lifecycle-activation +BUZZ_ADVERTISE_WORKFLOW_LIFECYCLE=false # Optional: path to the web UI dist directory. When set, the relay serves # the web frontend at / for browser requests. Leave unset for local dev # (use `just web` for Vite HMR instead). diff --git a/crates/buzz-relay/src/config.rs b/crates/buzz-relay/src/config.rs index e035752ec3..5f332d2ca0 100644 --- a/crates/buzz-relay/src/config.rs +++ b/crates/buzz-relay/src/config.rs @@ -209,6 +209,13 @@ pub struct Config { /// are permitted regardless of auth method (API token, NIP-42). pub require_relay_membership: bool, + /// Advertise the host-wide workflow lifecycle contract in NIP-11. + /// Default off. Enable `BUZZ_ADVERTISE_WORKFLOW_LIFECYCLE=true` only after + /// every endpoint and existing connection for every served host runs + /// compatible lifecycle code; see deploy/charts/buzz/README.md#workflow-lifecycle-activation. + /// This gates discovery only, never the atomic save/delete enforcement. + pub advertise_workflow_lifecycle: bool, + /// Whether this deployment can serve huddle (voice) audio. /// /// Huddle audio frames are relayed peer-to-peer *within a single pod* @@ -671,6 +678,10 @@ impl Config { .map(|v| v == "true" || v == "1") .unwrap_or(false); + let advertise_workflow_lifecycle = std::env::var("BUZZ_ADVERTISE_WORKFLOW_LIFECYCLE") + .map(|v| v == "true" || v == "1") + .unwrap_or(false); + // Defaults true → single-pod (N=1) keeps today's huddle behavior. A // horizontally-scaled deployment sets this false; see the field doc. let huddle_audio_available = std::env::var("BUZZ_HUDDLE_AUDIO_AVAILABLE") @@ -1226,6 +1237,7 @@ impl Config { metrics_port, pubkey_allowlist_enabled, require_relay_membership, + advertise_workflow_lifecycle, huddle_audio_available, mesh, mesh_demo_echo, @@ -1334,6 +1346,42 @@ mod tests { assert!(found.is_empty(), "unrelated vars must not warn: {found:?}"); } + #[test] + fn workflow_lifecycle_advertisement_requires_explicit_opt_in() { + // Config::from_env readers elsewhere do not share ENV_MUTEX. Keep + // this flag matrix in an isolated child rather than racing them. + const CHILD: &str = "BUZZ_TEST_WORKFLOW_ADVERTISEMENT_CONFIG_CHILD"; + let _guard = ENV_MUTEX.lock().unwrap(); + if std::env::var_os(CHILD).is_none() { + crate::test_support::run_exact_test_child( + "config::tests::workflow_lifecycle_advertisement_requires_explicit_opt_in", + CHILD, + ); + return; + } + for (value, enabled) in [ + (None, false), + (Some(""), false), + (Some("false"), false), + (Some("0"), false), + (Some("typo"), false), + (Some("true"), true), + (Some("1"), true), + ] { + match value { + Some(value) => std::env::set_var("BUZZ_ADVERTISE_WORKFLOW_LIFECYCLE", value), + None => std::env::remove_var("BUZZ_ADVERTISE_WORKFLOW_LIFECYCLE"), + } + assert_eq!( + Config::from_env() + .expect("config") + .advertise_workflow_lifecycle, + enabled, + "advertisement config {value:?}" + ); + } + } + #[test] fn defaults_are_valid() { let _guard = ENV_MUTEX.lock().unwrap(); diff --git a/crates/buzz-relay/src/nip11.rs b/crates/buzz-relay/src/nip11.rs index 3df57e2114..3775423691 100644 --- a/crates/buzz-relay/src/nip11.rs +++ b/crates/buzz-relay/src/nip11.rs @@ -327,7 +327,11 @@ pub(crate) async fn nip11_document(state: &crate::state::AppState, raw_host: &st .push("nip-pl".to_string()); info.push = Some(push); } - if let (Some(_), Some(host)) = (relay_self, tenant_host) { + if let (true, Some(_), Some(host)) = ( + state.config.advertise_workflow_lifecycle, + relay_self, + tenant_host, + ) { info.supported_extensions .get_or_insert_default() .push("buzz-workflows".to_string()); @@ -419,6 +423,9 @@ const _RELAY_INFO_BUILD_STATIC_INPUT_FENCE: fn( Option<&str>, ) -> RelayInfo = RelayInfo::build; +#[cfg(test)] +mod postgres_tests; + #[cfg(test)] mod tests { use super::*; diff --git a/crates/buzz-relay/src/nip11/postgres_tests.rs b/crates/buzz-relay/src/nip11/postgres_tests.rs new file mode 100644 index 0000000000..1c70e41da9 --- /dev/null +++ b/crates/buzz-relay/src/nip11/postgres_tests.rs @@ -0,0 +1,120 @@ +//! Exercise both served NIP-11 routes; nextest supplies an isolated database. +use std::sync::Arc; + +use axum::{body::Body, http::Request}; +use serde_json::{json, Value}; +use tower::ServiceExt; + +use crate::{config::Config, state::AppState}; + +async fn served_info(state: &Arc, path: &str, host: &str) -> Value { + let response = crate::router::build_router(state.clone()) + .oneshot( + Request::builder() + .uri(path) + .header("host", host) + .header("accept", "application/nostr+json") + .body(Body::empty()) + .expect("request"), + ) + .await + .expect("NIP-11 response"); + assert_eq!(response.status(), axum::http::StatusCode::OK); + let body = axum::body::to_bytes(response.into_body(), 64 * 1024) + .await + .expect("bounded NIP-11 body"); + serde_json::from_slice(&body).expect("NIP-11 JSON") +} + +#[tokio::test] +#[ignore = "requires isolated PostgreSQL"] +async fn workflow_advertisement_requires_activation_identity_and_host_on_both_routes() { + let url = std::env::var("BUZZ_TEST_DATABASE_URL").expect("isolated PostgreSQL URL"); + let pool = sqlx::PgPool::connect(&url).await.expect("database"); + let db = buzz_db::Db::from_pool(pool.clone()); + let host = format!("activation-{}.example:8443", uuid::Uuid::new_v4()); + db.ensure_configured_community(&host) + .await + .expect("community"); + let keys = nostr::Keys::generate(); + let mut config = Config::from_env().expect("config"); + config.database_url = url; + config.redis_url = "redis://127.0.0.1:1".into(); + config.admin = None; + // An unrelated enabled extension must neither bypass nor be hidden by + // lifecycle activation. No gateway, Redis, or relay process is contacted. + config.push_enabled = true; + let cache = tempfile::tempdir().expect("cache directory"); + config.git_pack_cache_path = cache.path().to_path_buf(); + let redis = 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.clone()) + .await + .expect("pubsub"), + ); + let engine = Arc::new(buzz_workflow::WorkflowEngine::new( + db.clone(), + buzz_workflow::WorkflowConfig::default(), + )); + let auth = buzz_auth::AuthService::new(config.auth.clone()); + let search = buzz_search::SearchService::new(pool.clone()); + let media = buzz_media::MediaStorage::new(&config.media).expect("media config"); + let (state, _) = AppState::new( + config, db, redis, None, pubsub, auth, search, engine, keys, media, + ); + let mut state = Arc::new(state); + + for enabled in [false, true] { + for stable in [false, true] { + let mutable = Arc::get_mut(&mut state).expect("sole state owner"); + let config = Arc::make_mut(&mut mutable.config); + config.advertise_workflow_lifecycle = enabled; + config.relay_private_key = + stable.then(|| mutable.relay_keypair.secret_key().to_secret_hex()); + for request_host in [ + host.to_uppercase(), + "unmapped.invalid".into(), + String::new(), + ] { + let bound = request_host.eq_ignore_ascii_case(&host); + for path in ["/", "/info"] { + let info = served_info(&state, path, &request_host).await; + let advertised = enabled && stable && bound; + assert_eq!( + info.get("workflows").is_some(), + advertised, + "{path} enabled={enabled} stable={stable} host={request_host}" + ); + let extensions = info["supported_extensions"].as_array().expect("extensions"); + assert_eq!(extensions.contains(&json!("buzz-workflows")), advertised); + if advertised { + assert_eq!(info["workflows"], json!({"lifecycle": 1, "host": host})); + } + assert_eq!( + info.get("self").is_some(), + stable, + "identity is independent of activation" + ); + assert_eq!( + info.get("push").is_some(), + bound, + "push is independent of activation" + ); + assert_eq!(extensions.contains(&json!("nip-pl")), bound); + } + } + } + } + // Enabled + stable still fails closed if host resolution fails at runtime. + pool.close().await; + for path in ["/", "/info"] { + let info = served_info(&state, path, &host).await; + assert!(info.get("workflows").is_none()); + assert!(!info["supported_extensions"] + .as_array() + .expect("extensions") + .contains(&json!("buzz-workflows"))); + } +} diff --git a/deploy/charts/buzz/README.md b/deploy/charts/buzz/README.md index 5e77827913..bfd02817c7 100644 --- a/deploy/charts/buzz/README.md +++ b/deploy/charts/buzz/README.md @@ -291,12 +291,60 @@ default so long-lived WebSocket connections have time to drain. ## Upgrades -Schema migrations are embedded in the relay binary via `sqlx::migrate!` and run at startup, gated by `BUZZ_AUTO_MIGRATE` (default `true`). Multiple replicas race-safely behind a Postgres advisory lock. `helm upgrade` is the entire upgrade procedure. +Schema migrations are embedded in the relay binary via `sqlx::migrate!` and run at startup, gated by `BUZZ_AUTO_MIGRATE` (default `true`). Multiple replicas race-safely behind a Postgres advisory lock. Use `helm upgrade` for code rollouts; host-wide capability activation may require a separate post-drain step, as described below. Migration 0032 is a hard compatibility boundary for relay versions that publish repaired channel rosters. The relay verifies the roster-fence trigger catalog and behavior before opening listeners and refuses to start if 0032 is missing or inert. Apply migrations before rolling the relay; for large installations, prefer a controlled `buzz-admin migrate` job with PostgreSQL lock monitoring before the code rollout. If you prefer decoupling migrations from serving, set `migrate.autoMigrate=false`. **In that mode the chart does not run migrations for you** — you own running `buzz-admin migrate` (separate Pod / one-shot Job) against the database before every `helm install` / `helm upgrade`. Readiness probes only verify DB connectivity, not schema freshness, so a pod will appear healthy against an unmigrated schema and fail under load. A pre-upgrade Helm Job for this is on the chart roadmap; the values knob `migrate.preUpgradeJob.enabled` is reserved. +### Workflow lifecycle activation + +The NIP-11 `buzz-workflows` extension and `workflows: {lifecycle: 1, host: ...}` +are a **host-wide** forward save/delete guarantee, not a statement about the pod +that answered discovery. `BUZZ_ADVERTISE_WORKFLOW_LIFECYCLE` defaults to off; +only `true` or `1` enables it. This startup setting gates advertisement only: +new binaries enforce atomic lifecycle writes even while it is off. Stable relay +identity and successful request-host binding are still required to advertise. + +Do not enable it in the same rolling upgrade that first installs lifecycle-capable +code. `RollingUpdate` and the version-independent Service can route a later +connection to an old replica; existing WebSockets can also continue writing +through an old replica after it leaves the Service endpoints. The additive +`workflow_deletions` migration/serving fence does not drain those old processes. + +1. **Deploy with advertisement off** (unset or `false` on every replica). Apply + the required schema and roll out the lifecycle-capable image. While old and + new replicas coexist, clients must not rely on lifecycle 1. Existing split + state, including writes made during this phase, is not repaired by activation. +2. **Verify drain before enabling.** Wait for rollout completion, then verify + every HTTP/WebSocket endpoint behind every served host uses compatible code. + Include canary pools, alternate ingress routes and other deployments sharing + those hosts. Remove incompatible endpoints and finish draining/terminating + their existing connections and in-flight writes. A single successful NIP-11 + probe or a ready new pod is not evidence of this host-wide condition. +3. **Activate separately**, retaining the compatible image, using the existing + `relay.extraEnv` list (append this entry; preserve other entries): + + ```yaml + relay: + extraEnv: + - name: BUZZ_ADVERTISE_WORKFLOW_LIFECYCLE + value: "true" + ``` + + Run a second `helm upgrade`/GitOps sync and wait for this config rollout. + Mixed off/on replicas are now safe because all replicas already enforce the + contract. Verify `/info` and `/` with `Accept: application/nostr+json` for each + served host: `buzz-workflows` is listed, `workflows.lifecycle` is `1`, and + `workflows.host` matches the normalized request host. + +**Rollback:** switching this flag off does not disable enforcement or revoke +capabilities clients already observed. Prefer rolling back only to compatible +code. Before reintroducing incompatible code, stop traffic for the affected +hosts, disable advertisement everywhere, drain active connections/in-flight +writes, and require clients to discard cached discovery and rediscover before +resuming. If that cannot be guaranteed, do not downgrade those hosts. + ## Backups Save these. Losing any of them is data loss. See NOTES.txt printed by `helm install` for the live list: