From 6cf164342552dfc9bba1960dacaf4e1e0e2a0d47 Mon Sep 17 00:00:00 2001 From: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz> Date: Fri, 31 Jul 2026 15:15:23 -0400 Subject: [PATCH 1/3] fix(relay): allow open relays to set their NIP-11 workspace icon MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit kind:9033 (set workspace profile) required an admin/owner row in relay_members, but open relays (BUZZ_REQUIRE_RELAY_MEMBERSHIP=false) enforce no roster — nobody holds a role there, so the icon was permanently unsettable. The desktop deliberately exposes the icon editor on open relays (#2640) and defers to this relay-side check, which always refused. Gate the role requirement on membership enforcement via a pure may_set_workspace_profile decision: closed relays keep the exact admin/owner check; open relays admit any NIP-42-authenticated sender, mirroring how they gate every other write. Ban admission (handle_relay_admin_event) and ingest auth/scope checks are untouched, as are kinds 9030-9032 (roster mutations stay role-gated everywhere). Co-authored-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz> Signed-off-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz> --- crates/buzz-relay/src/handlers/relay_admin.rs | 42 ++++++++++++++++++- 1 file changed, 41 insertions(+), 1 deletion(-) diff --git a/crates/buzz-relay/src/handlers/relay_admin.rs b/crates/buzz-relay/src/handlers/relay_admin.rs index 3f58a9c2aa8..ea8da8978d0 100644 --- a/crates/buzz-relay/src/handlers/relay_admin.rs +++ b/crates/buzz-relay/src/handlers/relay_admin.rs @@ -94,6 +94,23 @@ fn validate_workspace_icon(icon: &str) -> Result<(), String> { Ok(()) } +/// Whether `sender_role` may set the workspace profile (kind:9033). +/// +/// Closed relays (`membership_enforced == true`) require an `admin`/`owner` +/// row in `relay_members` — the enforced roster is the authority. Open relays +/// enforce no roster at all: nobody holds a role, so requiring one makes the +/// icon permanently unsettable — the desktop deliberately shows the icon +/// editor there (see `canEditCommunityProfile`, #2640) and defers to this +/// relay-side check, which used to always say no. Any authenticated member of +/// an open relay may set the icon, mirroring how open relays gate every other +/// write (NIP-42 auth, no membership). +fn may_set_workspace_profile(sender_role: &str, membership_enforced: bool) -> bool { + if !membership_enforced { + return true; + } + sender_role == "admin" || sender_role == "owner" +} + /// A relay-admin command failure, carrying the *category* of the failure so /// the ingest seam can map it to the right NIP-01 prefix and HTTP status. /// @@ -230,7 +247,7 @@ async fn execute_relay_admin_command( // kind:9033 — Set workspace profile (icon). Handled before p-tag // extraction: it targets the relay itself, not a member pubkey. if kind == RELAY_ADMIN_SET_WORKSPACE_PROFILE { - if sender_role != "admin" && sender_role != "owner" { + if !may_set_workspace_profile(sender_role, state.config.require_relay_membership) { return Err("actor not authorized: must be admin or owner".to_string()); } @@ -562,6 +579,29 @@ mod tests { assert!(validate_workspace_icon("").is_ok()); } + /// Closed relay (membership enforced): only an admin/owner row in + /// `relay_members` may set the workspace profile — a plain member, or a + /// pubkey with no row at all (empty role), must be refused. + #[test] + fn closed_relay_requires_admin_or_owner_for_workspace_profile() { + assert!(may_set_workspace_profile("owner", true)); + assert!(may_set_workspace_profile("admin", true)); + assert!(!may_set_workspace_profile("member", true)); + assert!(!may_set_workspace_profile("", true)); + } + + /// Open relay (no membership enforcement): there is no enforced roster to + /// hold a role, so any authenticated sender may set the icon — including + /// the roleless (empty role) case, which is *every* sender on an open + /// relay. This is the bug being fixed: the desktop shows the icon editor + /// on open relays (#2640) but the relay refused every 9033. + #[test] + fn open_relay_admits_any_authenticated_sender_for_workspace_profile() { + assert!(may_set_workspace_profile("", false)); + assert!(may_set_workspace_profile("member", false)); + assert!(may_set_workspace_profile("owner", false)); + } + #[test] fn workspace_icon_https_ok() { assert!(validate_workspace_icon("https://example.com/icon.png").is_ok()); From 4382f83150af6e703152cba3b4f113dbf9db4910 Mon Sep 17 00:00:00 2001 From: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz> Date: Fri, 31 Jul 2026 17:16:02 -0400 Subject: [PATCH 2/3] fix(relay): scope open-relay 9033 admit to communities with no steward MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dawn's trace showed the original premise was wrong: main.rs bootstraps RELAY_OWNER_PUBKEY as owner regardless of the membership flag, so our production open relay (bb-block) has an owner row — the old gate was refusing everyone except that owner, not everyone. Admitting any authenticated sender there widened an owner-only control with no audit trail. Revise the gate to steward-wins: on an open relay, admit any NIP-42-authenticated sender only while the community has no admin/owner row at all (the genuinely stuck ensure_configured_community case); the moment a steward exists, 9033 is admin/owner-only again. Closed relays are unchanged. Since 9033 writes no audit row and publishes no announcement (unlike 9030/9031), the rosterless admit now logs a warn with the sender pubkey as the only durable attribution. Also close Dawn's mutation gap: the previous unit tests pinned only the helper's truth table — inverting or deleting the call-site gate survived the full suite. Add two #[ignore]d Postgres integration tests driving handle_relay_admin_event with real AppState (open rosterless admit + steward flip, closed member refusal), wired into the Backend Integration CI job. Both mutants verified killed by these tests. Fix the doc comment citing the nonexistent canEditCommunityProfile (real symbol: canEditIcon). Co-authored-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz> Signed-off-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz> --- .github/workflows/ci.yml | 12 + crates/buzz-db/src/lib.rs | 6 + crates/buzz-db/src/relay_members.rs | 15 + crates/buzz-relay/src/handlers/relay_admin.rs | 313 ++++++++++++++++-- 4 files changed, 322 insertions(+), 24 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bc594e16ade..4c99b888daf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -692,6 +692,18 @@ jobs: --run-ignored ignored-only env: DATABASE_URL: postgres://buzz:${{ env.BUZZ_TEST_POSTGRES_PASSWORD }}@localhost:5432/buzz + - name: Workspace profile (kind:9033) gate tests + # Call-site integration for the 9033 authorization gate: open relay + # rosterless/steward transitions and the closed-relay admin/owner rule, + # against real Postgres. #[ignore]d in the default suite, selected + # explicitly here — see handlers::relay_admin::tests. + run: | + cargo nextest run \ + --archive-file target/ci/backend-integration-tests.tar.zst \ + -E 'package(buzz-relay) and test(/handlers::relay_admin::tests/)' \ + --run-ignored ignored-only + env: + DATABASE_URL: postgres://buzz:${{ env.BUZZ_TEST_POSTGRES_PASSWORD }}@localhost:5432/buzz - name: NIP-ER reminder e2e # Feature e2e for NIP-ER (Event Reminders, kind:30300): write-path # validation, author-only read filtering, and scheduler delivery against diff --git a/crates/buzz-db/src/lib.rs b/crates/buzz-db/src/lib.rs index 50aac1cbaf7..ed4b79fef6a 100644 --- a/crates/buzz-db/src/lib.rs +++ b/crates/buzz-db/src/lib.rs @@ -4085,6 +4085,12 @@ impl Db { relay_members::bootstrap_owner(&self.pool, community, owner_pubkey).await } + /// Returns `true` if any member of `community` holds the `admin` or + /// `owner` role. + pub async fn has_admin_or_owner(&self, community: CommunityId) -> Result { + relay_members::has_admin_or_owner(&self.pool, community).await + } + /// Atomically transfers ownership of `community` to `new_owner_pubkey`, /// demoting the previous owner(s) to `member`. Verifies /// `expected_owner_pubkey` matches the current owner inside the same diff --git a/crates/buzz-db/src/relay_members.rs b/crates/buzz-db/src/relay_members.rs index bfc56f82de9..bbd03491572 100644 --- a/crates/buzz-db/src/relay_members.rs +++ b/crates/buzz-db/src/relay_members.rs @@ -37,6 +37,21 @@ pub async fn is_relay_member(pool: &PgPool, community: CommunityId, pubkey: &str Ok(row.is_some()) } +/// Returns `true` if any member of `community` holds the `admin` or `owner` +/// role. Open relays don't *enforce* the roster, but startup +/// (`bootstrap_owner`) and operator provisioning still populate it — this is +/// how the workspace-profile gate detects whether a steward exists. +pub async fn has_admin_or_owner(pool: &PgPool, community: CommunityId) -> Result { + let row = sqlx::query( + "SELECT 1 FROM relay_members \ + WHERE community_id = $1 AND role IN ('admin', 'owner') LIMIT 1", + ) + .bind(community.as_uuid()) + .fetch_optional(pool) + .await?; + Ok(row.is_some()) +} + /// Returns the relay member record for `pubkey` in `community`, or `None`. pub async fn get_relay_member( pool: &PgPool, diff --git a/crates/buzz-relay/src/handlers/relay_admin.rs b/crates/buzz-relay/src/handlers/relay_admin.rs index ea8da8978d0..3782f2c516d 100644 --- a/crates/buzz-relay/src/handlers/relay_admin.rs +++ b/crates/buzz-relay/src/handlers/relay_admin.rs @@ -10,7 +10,7 @@ //! | 9030 | Add member | admin or owner | //! | 9031 | Remove member | admin or owner | //! | 9032 | Change role | owner only | -//! | 9033 | Set workspace profile (icon) | admin or owner | +//! | 9033 | Set workspace profile (icon) | admin or owner; on an open relay whose community has no admin/owner row at all, any authenticated sender (see [`may_set_workspace_profile`]) | use std::sync::Arc; @@ -98,14 +98,26 @@ fn validate_workspace_icon(icon: &str) -> Result<(), String> { /// /// Closed relays (`membership_enforced == true`) require an `admin`/`owner` /// row in `relay_members` — the enforced roster is the authority. Open relays -/// enforce no roster at all: nobody holds a role, so requiring one makes the -/// icon permanently unsettable — the desktop deliberately shows the icon -/// editor there (see `canEditCommunityProfile`, #2640) and defers to this -/// relay-side check, which used to always say no. Any authenticated member of -/// an open relay may set the icon, mirroring how open relays gate every other -/// write (NIP-42 auth, no membership). -fn may_set_workspace_profile(sender_role: &str, membership_enforced: bool) -> bool { - if !membership_enforced { +/// don't *enforce* the roster, but the data can still exist: startup +/// bootstraps `RELAY_OWNER_PUBKEY` as `owner` regardless of the flag +/// (`main.rs`), as does operator provisioning. So the rule is steward-wins: +/// +/// - a steward (any admin/owner row) exists → admin/owner only, exactly like +/// a closed relay. An open relay with a configured owner keeps its icon +/// owner-controlled instead of last-write-wins for every authenticated key. +/// - genuinely rosterless (e.g. a community created by +/// `ensure_configured_community`, which writes no owner row) → any +/// NIP-42-authenticated sender may set the icon, mirroring how open relays +/// gate every other write. Without this the icon is permanently unsettable: +/// the desktop deliberately shows the icon editor on open relays (see +/// `canEditIcon` in `EditCommunityDialog.tsx`, #2640) and defers to this +/// relay-side check, which used to always say no. +fn may_set_workspace_profile( + sender_role: &str, + membership_enforced: bool, + community_has_steward: bool, +) -> bool { + if !membership_enforced && !community_has_steward { return true; } sender_role == "admin" || sender_role == "owner" @@ -247,9 +259,33 @@ async fn execute_relay_admin_command( // kind:9033 — Set workspace profile (icon). Handled before p-tag // extraction: it targets the relay itself, not a member pubkey. if kind == RELAY_ADMIN_SET_WORKSPACE_PROFILE { - if !may_set_workspace_profile(sender_role, state.config.require_relay_membership) { + // Steward detection only matters on open relays (closed relays gate on + // the sender's own role either way), so skip the extra query there. + let community_has_steward = if state.config.require_relay_membership { + true + } else { + state + .db + .has_admin_or_owner(tenant.community()) + .await + .map_err(|e| format!("database error: {e}"))? + }; + if !may_set_workspace_profile( + sender_role, + state.config.require_relay_membership, + community_has_steward, + ) { return Err("actor not authorized: must be admin or owner".to_string()); } + if sender_role != "admin" && sender_role != "owner" { + // Rosterless-open-relay admit: 9033 writes no audit row and + // publishes no announcement event (unlike 9030/9031), so this warn + // is the only durable attribution of who changed the icon. + warn!( + sender = %sender_hex, + "workspace profile change admitted without a roster role (open relay, no steward)" + ); + } // Empty or missing icon tag clears the workspace icon. let icon = extract_tag_value(event, "icon").unwrap_or_default(); @@ -581,25 +617,42 @@ mod tests { /// Closed relay (membership enforced): only an admin/owner row in /// `relay_members` may set the workspace profile — a plain member, or a - /// pubkey with no row at all (empty role), must be refused. + /// pubkey with no row at all (empty role), must be refused. The steward + /// flag is irrelevant when membership is enforced (call sites pass `true`, + /// but the rule must not depend on it). #[test] fn closed_relay_requires_admin_or_owner_for_workspace_profile() { - assert!(may_set_workspace_profile("owner", true)); - assert!(may_set_workspace_profile("admin", true)); - assert!(!may_set_workspace_profile("member", true)); - assert!(!may_set_workspace_profile("", true)); + for steward in [true, false] { + assert!(may_set_workspace_profile("owner", true, steward)); + assert!(may_set_workspace_profile("admin", true, steward)); + assert!(!may_set_workspace_profile("member", true, steward)); + assert!(!may_set_workspace_profile("", true, steward)); + } } - /// Open relay (no membership enforcement): there is no enforced roster to - /// hold a role, so any authenticated sender may set the icon — including - /// the roleless (empty role) case, which is *every* sender on an open - /// relay. This is the bug being fixed: the desktop shows the icon editor - /// on open relays (#2640) but the relay refused every 9033. + /// Open relay with a steward: startup bootstraps `RELAY_OWNER_PUBKEY` as + /// `owner` regardless of `require_relay_membership`, so an open relay's + /// community can hold admin/owner rows. When one exists, the icon stays + /// steward-only — the fix must not widen an owner-controlled icon to + /// every authenticated key. #[test] - fn open_relay_admits_any_authenticated_sender_for_workspace_profile() { - assert!(may_set_workspace_profile("", false)); - assert!(may_set_workspace_profile("member", false)); - assert!(may_set_workspace_profile("owner", false)); + fn open_relay_with_steward_keeps_workspace_profile_steward_only() { + assert!(may_set_workspace_profile("owner", false, true)); + assert!(may_set_workspace_profile("admin", false, true)); + assert!(!may_set_workspace_profile("member", false, true)); + assert!(!may_set_workspace_profile("", false, true)); + } + + /// Open relay, genuinely rosterless (no admin/owner row anywhere): any + /// authenticated sender may set the icon — including the roleless (empty + /// role) case, which is *every* sender there. This is the bug being + /// fixed: the desktop shows the icon editor on open relays (#2640) but + /// the relay refused every 9033. + #[test] + fn rosterless_open_relay_admits_any_authenticated_sender_for_workspace_profile() { + assert!(may_set_workspace_profile("", false, false)); + assert!(may_set_workspace_profile("member", false, false)); + assert!(may_set_workspace_profile("owner", false, false)); } #[test] @@ -631,4 +684,216 @@ mod tests { let long_data = format!("data:image/png;base64,{}", "A".repeat(98_304)); assert!(validate_workspace_icon(&long_data).is_err()); } + + // ─── Call-site integration: the 9033 gate wired to real config + DB ──── + // + // The unit tests above pin `may_set_workspace_profile`'s truth table, but + // not its wiring: mutation-testing showed that inverting + // `state.config.require_relay_membership` at the call site — an exact + // inversion of the security contract — survives the default suite. These + // tests drive `handle_relay_admin_event` with a real `AppState` against + // Postgres, on both relay modes, so the wiring itself is pinned. Selected + // explicitly in CI's Backend Integration job; requires local Postgres + // (and hard-fails rather than skipping when it is unreachable). + + const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; // sadscan:disable np.postgres.1 + + /// Build a real `AppState` + tenant for a fresh community on `host`, with + /// `require_relay_membership` set as given. Mirrors + /// `api::invites::tests::invite_test_state`. + async fn workspace_profile_test_state( + host: &str, + require_relay_membership: bool, + ) -> (Arc, TenantContext) { + let mut config = crate::config::Config::from_env().expect("config from env"); + let database_url = std::env::var("BUZZ_TEST_DATABASE_URL") + .or_else(|_| std::env::var("DATABASE_URL")) + .unwrap_or_else(|_| TEST_DB_URL.to_string()); + config.database_url = database_url.clone(); + config.redis_url = "redis://127.0.0.1:1".to_string(); + config.relay_url = format!("wss://{host}"); + config.require_relay_membership = require_relay_membership; + + let pool = sqlx::PgPool::connect(&database_url) + .await + .expect("requires reachable Postgres"); + let db = buzz_db::Db::from_pool(pool.clone()); + let record = db + .ensure_configured_community(host) + .await + .expect("ensure community"); + let tenant = TenantContext::resolved(record.id, host); + + let redis_pool = deadpool_redis::Config::from_url(&config.redis_url) + .create_pool(Some(deadpool_redis::Runtime::Tokio1)) + .expect("redis pool config"); + 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, + ); + (Arc::new(state), tenant) + } + + /// Sign a fresh kind:9033 with `icon` and run it through the real + /// admission + command path. + async fn submit_9033( + state: &Arc, + tenant: &TenantContext, + keys: &Keys, + icon: &str, + ) -> Result<(), RelayAdminError> { + let event = EventBuilder::new(Kind::Custom(9033), "") + .tags(vec![Tag::parse(["icon", icon]).expect("icon tag")]) + .sign_with_keys(keys) + .expect("sign 9033"); + handle_relay_admin_event(tenant, state, &event).await + } + + async fn stored_icon(state: &Arc, tenant: &TenantContext) -> Option { + state + .db + .get_community_icon(tenant.community()) + .await + .expect("read icon") + } + + /// Open relay (`require_relay_membership = false`): a rosterless + /// community admits any authenticated sender, but the moment a steward + /// (admin/owner row) exists the gate reverts to steward-only. + /// + /// Discriminating: fails if the call site inverts or drops + /// `require_relay_membership`, or stops consulting `has_admin_or_owner`. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn open_relay_9033_admits_roleless_only_until_a_steward_exists() { + let host = format!("icon-gate-open-{}.example", uuid::Uuid::new_v4().simple()); + let (state, tenant) = workspace_profile_test_state(&host, false).await; + let roleless = Keys::generate(); + let owner = Keys::generate(); + + // Rosterless: the roleless sender may set the icon. + submit_9033(&state, &tenant, &roleless, "https://example.com/open.png") + .await + .expect("rosterless open relay must admit an authenticated sender"); + assert_eq!( + stored_icon(&state, &tenant).await.as_deref(), + Some("https://example.com/open.png"), + "icon must actually be stored" + ); + + // Seed a steward — the same roleless sender must now be refused, and + // the previously stored icon must survive the refused attempt. + state + .db + .add_relay_member( + tenant.community(), + &owner.public_key().to_hex(), + "owner", + None, + ) + .await + .expect("seed owner"); + let refused = submit_9033(&state, &tenant, &roleless, "https://evil.example/pwn.png").await; + assert_eq!( + refused, + Err(RelayAdminError::Rejected( + "actor not authorized: must be admin or owner".to_string() + )), + "an open relay with a steward must refuse a roleless sender" + ); + assert_eq!( + stored_icon(&state, &tenant).await.as_deref(), + Some("https://example.com/open.png"), + "refused attempt must not mutate the icon" + ); + + // The steward still can. + submit_9033(&state, &tenant, &owner, "https://example.com/owner.png") + .await + .expect("the steward must retain icon control"); + assert_eq!( + stored_icon(&state, &tenant).await.as_deref(), + Some("https://example.com/owner.png") + ); + } + + /// Closed relay (`require_relay_membership = true`): admin/owner only — + /// a plain member and a roleless key are refused even though the + /// community also *looks* rosterless-then-stewarded to the open-relay + /// branch. Together with the open-relay test this kills the inverted-flag + /// mutant: no assignment of the flag satisfies both. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn closed_relay_9033_still_requires_admin_or_owner() { + let host = format!("icon-gate-closed-{}.example", uuid::Uuid::new_v4().simple()); + let (state, tenant) = workspace_profile_test_state(&host, true).await; + let roleless = Keys::generate(); + let member = Keys::generate(); + let admin = Keys::generate(); + state + .db + .add_relay_member( + tenant.community(), + &member.public_key().to_hex(), + "member", + None, + ) + .await + .expect("seed member"); + state + .db + .add_relay_member( + tenant.community(), + &admin.public_key().to_hex(), + "admin", + None, + ) + .await + .expect("seed admin"); + + for (keys, label) in [(&roleless, "roleless"), (&member, "member")] { + let refused = submit_9033(&state, &tenant, keys, "https://evil.example/pwn.png").await; + assert_eq!( + refused, + Err(RelayAdminError::Rejected( + "actor not authorized: must be admin or owner".to_string() + )), + "closed relay must refuse a {label} sender" + ); + } + assert_eq!( + stored_icon(&state, &tenant).await, + None, + "refused attempts must not set an icon" + ); + + submit_9033(&state, &tenant, &admin, "https://example.com/closed.png") + .await + .expect("closed-relay admin must set the icon"); + assert_eq!( + stored_icon(&state, &tenant).await.as_deref(), + Some("https://example.com/closed.png") + ); + } } From 297148f623b994101afae070b0ac50335c04d9ad Mon Sep 17 00:00:00 2001 From: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz> Date: Fri, 31 Jul 2026 20:17:34 -0400 Subject: [PATCH 3/3] fix(schema): add communities.icon to desired-state schema MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Migration 0003_community_icon.sql added the icon column, but schema/schema.sql (the desired-state file CI's Backend Integration job applies via pgschema) was never updated — pre-existing drift that nothing exercised until the new 9033 integration tests wrote the column in that job and hit 'column "icon" of relation "communities" does not exist'. Co-authored-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz> Signed-off-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz> --- schema/schema.sql | 3 +++ 1 file changed, 3 insertions(+) diff --git a/schema/schema.sql b/schema/schema.sql index 5980695d32a..3c647293672 100644 --- a/schema/schema.sql +++ b/schema/schema.sql @@ -54,6 +54,9 @@ CREATE TABLE communities ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), host VARCHAR(255) NOT NULL, signing_key BYTEA, + -- Per-community workspace icon (NIP-11 `icon`), set via kind:9033. + -- Added by migration 0003; kept here so desired-state applies match. + icon TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), archived_at TIMESTAMPTZ, CONSTRAINT chk_communities_id_not_nil CHECK (id <> '00000000-0000-0000-0000-000000000000'::uuid)