diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0e50798020f..a854e6ebd96 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/AGENTS.md b/AGENTS.md index 6801a6b96f6..7c8049fd850 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -634,6 +634,10 @@ first, then implement handling in the relay. **Channel scoping**: Channels use `h` tags (NIP-29 group tag), not `e` tags. Filters and queries must scope to `h` tags when operating within a channel. +This applies to events *inside* a channel. Addressable events that describe a +channel carry its id in their `d` tag instead: kind:39000 (metadata), +kind:39001, kind:39002 (membership). `get_channels` resolves a user's channels +from the `d` tag of their kind:39002 events, not from `h`. **Agent-facing operations go in `buzz-cli`**: New agent-facing features belong in `buzz-cli` — add a subcommand there first, then wire the REST/WebSocket call in `client.rs`. `buzz-dev-mcp` (shell + file tools for `buzz-agent`) is separate. diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 5c8e263a2a4..892082d96c6 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -139,7 +139,7 @@ The `kind` integer is the only dispatch switch. The relay routes, stores, and fa | 46001–46012 | KIND_WORKFLOW_* | Workflow execution events | | 20001 | KIND_PRESENCE_UPDATE | Ephemeral presence heartbeat | -`buzz-core` defines all 81 kinds as `pub const KIND_*: u32` and exports `ALL_KINDS: &[u32]`. Kinds are `u32` (NIP-01 specifies unsigned integer; `u32` covers the full range). Buzz uses both standard Nostr kinds (e.g., kind 7 for reactions) and custom ranges (40000+). +`buzz-core` defines each event kind as a `pub const u32` and exports the full registry as `ALL_KINDS: &[u32]` (127 kinds at the time of writing); `crates/buzz-core/src/kind.rs` is the source of truth for the current list. Kinds are `u32` (NIP-01 specifies unsigned integer; `u32` covers the full range). Buzz uses both standard Nostr kinds (e.g., kind 7 for reactions) and custom ranges (40000+). Note: `KIND_AUTH` (22242) is `pub const KIND_AUTH: u32` in `buzz-core/src/kind.rs` and imported by `buzz-relay/src/handlers/event.rs`. `KIND_CANVAS` (40100) is likewise `pub const KIND_CANVAS: u32` in `buzz-core/src/kind.rs`. diff --git a/NOSTR.md b/NOSTR.md index 59df31b991b..cce70f2f77f 100644 --- a/NOSTR.md +++ b/NOSTR.md @@ -39,7 +39,7 @@ just relay & # relay on :3000 PGPASSWORD=buzz_dev psql -h localhost -U buzz -d buzz -c \ "INSERT INTO pubkey_allowlist (pubkey) VALUES (decode('<64-char-hex-pubkey>', 'hex'))" -# 5. Connect any NIP-29 + NIP-42 client to ws://localhost:3000 +# 4. Connect any NIP-29 + NIP-42 client to ws://localhost:3000 ``` ### What Works @@ -163,6 +163,10 @@ nak req -k 9 --tag "h=" --stream \ nak event -k 7 -c "+" --tag "h=" --tag "e=" \ --auth --sec ws://localhost:3000 +# Subscribe to reactions to channel messages — include #h for live delivery (see note below) +nak req -k 7 --tag "h=" --stream \ + --auth --sec ws://localhost:3000 + # Delete a message (#h optional; #e required; must be self-authored) nak event -k 5 -c "reason" --tag "h=" --tag "e=" \ --auth --sec ws://localhost:3000 @@ -185,6 +189,14 @@ nak req -k 1059 --tag "p=" \ --auth --sec ws://localhost:3000 ``` +> **Note:** The relay derives a reaction's channel from its `#e` target (client `#h` is +> ignored for channel determination). Reactions to channel-scoped events are therefore +> channel-scoped. Live fan-out keeps channel-scoped and global subscriptions strictly +> separate, which means a kinds-only subscription (`{"kinds":[7]}`) receives none of +> those reactions — subscribe with `{"kinds":[7],"#h":[""]}` instead. +> `#h` matching works whether or not the signed reaction carries an `h` tag: explicit +> `h` tags are matched directly, and tagless reactions match via their stored channel. + ### Tested Clients (Direct) | Client | Platform | Evidence | Notes | @@ -354,3 +366,7 @@ but only admins/owners can set it. Full spec: --- ## Further Reading + +- [nostr-protocol/nips](https://github.com/nostr-protocol/nips) — the upstream NIP specifications (NIP-01, NIP-29, NIP-42, and the other NIPs referenced throughout this guide). +- [`docs/nips/`](docs/nips/) — Buzz's own NIP extension documents. +- [`ARCHITECTURE.md`](ARCHITECTURE.md) — event kinds, wire protocol, and relay internals. diff --git a/README.md b/README.md index a049e870720..56439f00bc1 100644 --- a/README.md +++ b/README.md @@ -116,7 +116,19 @@ New to Buzz? Pick the path that matches you. ### I just want to try the app -Grab a packaged build from the [latest release](https://github.com/block/buzz/releases/latest) — macOS (`.dmg`), Linux (`.AppImage` / `.deb`), or Windows (`.exe`). Install it like any other app. +Grab a packaged build from the [latest release](https://github.com/block/buzz/releases/latest): + +| Platform | File | +|---|---| +| macOS (Apple Silicon) | `Buzz__aarch64.dmg` | +| macOS (Intel) | `Buzz__x64.dmg` | +| Linux (x86_64) | `Buzz__amd64.AppImage` or `Buzz__amd64.deb` | +| Windows (x64) | `Buzz__x64-setup_alpha-unsigned.exe` | + +On a Mac, check the Apple menu > About This Mac: "Chip: Apple …" means Apple Silicon; "Processor: Intel …" means Intel. + +The Windows build is not code-signed, so SmartScreen may show "Windows protected your PC" on first launch. If available, click **More info**, then **Run anyway**. + By default the app connects to `ws://localhost:3000`. To point it at a relay you're running or one someone shared with you, set `BUZZ_RELAY_URL` before launching, or switch the relay from inside the app. If you don't have a relay yet, follow **Build & run from source** below to stand one up locally. diff --git a/crates/buzz-db/src/lib.rs b/crates/buzz-db/src/lib.rs index 245e49bb2d7..9b268767470 100644 --- a/crates/buzz-db/src/lib.rs +++ b/crates/buzz-db/src/lib.rs @@ -3999,8 +3999,33 @@ impl Db { } /// Returns `true` if `pubkey` (64-char hex) is a member of `community`. + /// + /// Replica-routed on the bounded arm — the one PERMISSION read routed by + /// explicit product decision (bounded-stale membership beats the 10s + /// cache it replaced). Admits and revokes may lag by at most the budget + /// `B`; everything else fails closed to the writer, exactly like + /// [`Db::query_events_routed_bounded`]. Not precedent for routing other + /// permission reads. pub async fn is_relay_member(&self, community: CommunityId, pubkey: &str) -> Result { - relay_members::is_relay_member(&self.pool, community, pubkey).await + let path = "relay_membership"; + match self.route_read(path, RoutePredicate::Bounded).await { + RouteDecision::Replica(mut tx, _entry, reason) => { + match relay_members::is_relay_member_on(&mut tx, community, pubkey).await { + Ok(is_member) => { + Self::record_route(path, "replica", reason); + Ok(is_member) + } + Err(e) => { + tracing::warn!(path, "replica read failed; re-running on writer: {e}"); + Self::record_route(path, "writer", "replica_error"); + relay_members::is_relay_member(&self.pool, community, pubkey).await + } + } + } + RouteDecision::Writer => { + relay_members::is_relay_member(&self.pool, community, pubkey).await + } + } } /// Returns the relay member record for `pubkey` in `community`, or `None` if not found. @@ -4096,6 +4121,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 @@ -7408,6 +7439,78 @@ mod tests { drop_scratch_db(&admin, writer, &wname).await; } + /// Routed relay-membership check: budget unset ⇒ writer; budget set + + /// fresh proved entry ⇒ replica (bounded arm); over-budget entry ⇒ + /// writer. Divergent membership rows prove which pool answered. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn is_relay_member_is_bounded_routed_and_fails_closed() { + let admin = PgPool::connect(&admin_url().await) + .await + .expect("connect admin"); + let (writer, wname) = create_scratch_db(&admin, "mem_w").await; + let (replica, rname) = create_scratch_db(&admin, "mem_r").await; + + let community = Uuid::new_v4(); + for pool in [&writer, &replica] { + sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)") + .bind(community) + .bind(format!("member-routing-{}.example", community.simple())) + .execute(pool) + .await + .expect("insert community"); + } + let cid = CommunityId::from_uuid(community); + let writer_only = "aa".repeat(32); + let replica_only = "bb".repeat(32); + relay_members::add_relay_member(&writer, cid, &writer_only, "member", None) + .await + .expect("seed writer member"); + relay_members::add_relay_member(&replica, cid, &replica_only, "member", None) + .await + .expect("seed replica member"); + + let mut db = Db::from_pools(writer.clone(), replica.clone()); + db.fence().force_open_for_tests(chrono::Utc::now()); + + // Budget unset ⇒ bounded arm disabled ⇒ writer. + assert!( + db.is_relay_member(cid, &writer_only) + .await + .expect("gate off"), + "budget unset must answer from the writer" + ); + assert!(!db.is_relay_member(cid, &replica_only).await.unwrap()); + + // Budget set + fresh entry ⇒ replica. + db.set_replica_read_max_age_for_tests(Some(std::time::Duration::from_secs(5))); + assert!( + db.is_relay_member(cid, &replica_only) + .await + .expect("gate on"), + "budget set must answer from the replica" + ); + assert!(!db.is_relay_member(cid, &writer_only).await.unwrap()); + + // Entry older than the budget ⇒ fail closed to the writer. Close + // first so no prior fresh entry can be the one proved (matches the + // count test; today `force_open_for_tests_at` also clears the ring). + db.fence().close(); + db.fence().force_open_for_tests_at( + chrono::Utc::now(), + std::time::Instant::now() - std::time::Duration::from_secs(10), + ); + assert!( + db.is_relay_member(cid, &writer_only) + .await + .expect("entry too old"), + "an over-budget entry must fail closed to the writer" + ); + + drop_scratch_db(&admin, replica, &rname).await; + drop_scratch_db(&admin, writer, &wname).await; + } + /// Community separation across every routed seam, verified on /// REPLICA-SERVED reads. /// diff --git a/crates/buzz-db/src/relay_members.rs b/crates/buzz-db/src/relay_members.rs index bfc56f82de9..402229cdec5 100644 --- a/crates/buzz-db/src/relay_members.rs +++ b/crates/buzz-db/src/relay_members.rs @@ -29,14 +29,41 @@ pub struct RelayMember { /// Returns `true` if `pubkey` (64-char hex) is a member of `community`. pub async fn is_relay_member(pool: &PgPool, community: CommunityId, pubkey: &str) -> Result { + let mut conn = pool.acquire().await?; + is_relay_member_on(&mut conn, community, pubkey).await +} + +/// [`is_relay_member`] on a specific session — the replica-routing path runs +/// the lookup on the exact reader connection whose heartbeat observation +/// proved fence coverage. +pub(crate) async fn is_relay_member_on( + conn: &mut sqlx::PgConnection, + community: CommunityId, + pubkey: &str, +) -> Result { let row = sqlx::query("SELECT 1 FROM relay_members WHERE community_id = $1 AND pubkey = $2") .bind(community.as_uuid()) .bind(pubkey) - .fetch_optional(pool) + .fetch_optional(conn) .await?; 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 3f58a9c2aa8..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; @@ -94,6 +94,35 @@ 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 +/// 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" +} + /// 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,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 sender_role != "admin" && sender_role != "owner" { + // 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(); @@ -562,6 +615,46 @@ 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. 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() { + 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 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_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] fn workspace_icon_https_ok() { assert!(validate_workspace_icon("https://example.com/icon.png").is_ok()); @@ -591,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") + ); + } } diff --git a/deploy/charts/buzz/examples/argocd-app.yaml b/deploy/charts/buzz/examples/argocd-app.yaml index 8f6cb762286..a29a6919b72 100644 --- a/deploy/charts/buzz/examples/argocd-app.yaml +++ b/deploy/charts/buzz/examples/argocd-app.yaml @@ -16,9 +16,14 @@ metadata: spec: project: default source: - repoURL: oci://ghcr.io/block/buzz/charts - chart: buzz - targetRevision: 0.1.0 + # Argo CD >= 3.1 native OCI sources: repoURL must be the FULL chart + # artifact path — with the `repoURL: …/charts` + `chart: buzz` split + # form, the `chart` field is ignored for oci:// URLs and the fetch + # fails with a 403 (`repository:block/buzz/charts:pull` denied). The + # spec validator still requires `path`; use "." for OCI sources. + repoURL: oci://ghcr.io/block/buzz/charts/buzz + path: . + targetRevision: 0.1.7 helm: releaseName: buzz values: | diff --git a/desktop/src/features/channels/ui/ChannelPane.tsx b/desktop/src/features/channels/ui/ChannelPane.tsx index 20eff07ea61..770c45e4452 100644 --- a/desktop/src/features/channels/ui/ChannelPane.tsx +++ b/desktop/src/features/channels/ui/ChannelPane.tsx @@ -681,6 +681,7 @@ export const ChannelPane = React.memo(function ChannelPane({ onMarkUnread={onMarkUnread} onMarkRead={onMarkRead} onReply={activeChannel?.archivedAt ? undefined : onOpenThread} + onOpenThread={onOpenThread} channelName={activeChannel?.name} channelType={activeChannel?.channelType ?? null} isSendingVideoReviewComment={isSending} diff --git a/desktop/src/features/messages/ui/MessageRow.tsx b/desktop/src/features/messages/ui/MessageRow.tsx index 688b5d5f0d7..286526b6581 100644 --- a/desktop/src/features/messages/ui/MessageRow.tsx +++ b/desktop/src/features/messages/ui/MessageRow.tsx @@ -88,6 +88,7 @@ export const MessageRow = React.memo( onMarkRead, onToggleReaction, onReply, + onOpenThread, onEntranceComplete, playEntrance = false, onUnfollowThread, @@ -135,6 +136,7 @@ export const MessageRow = React.memo( remove: boolean, ) => Promise; onReply?: (message: TimelineMessage) => void; + onOpenThread?: (message: TimelineMessage) => void; onUnfollowThread?: (message: TimelineMessage) => void; onEntranceComplete?: (messageId: string) => void; playEntrance?: boolean; @@ -335,7 +337,7 @@ export const MessageRow = React.memo( ); default: diff --git a/desktop/src/features/messages/ui/MessageThreadPanel.tsx b/desktop/src/features/messages/ui/MessageThreadPanel.tsx index 6234af22d1b..85a6f6b9308 100644 --- a/desktop/src/features/messages/ui/MessageThreadPanel.tsx +++ b/desktop/src/features/messages/ui/MessageThreadPanel.tsx @@ -755,6 +755,7 @@ export function MessageThreadPanel({ onMarkUnread={onMarkUnread} onMarkRead={onMarkRead} onReply={onSelectReplyTarget} + onOpenThread={onExpandReplies} onToggleReaction={onToggleReaction} profiles={profiles} showDepthGuides={shouldShowThreadBranchGuides} diff --git a/desktop/src/features/messages/ui/MessageTimeline.tsx b/desktop/src/features/messages/ui/MessageTimeline.tsx index cc5fb1e3dc6..954da08b010 100644 --- a/desktop/src/features/messages/ui/MessageTimeline.tsx +++ b/desktop/src/features/messages/ui/MessageTimeline.tsx @@ -84,6 +84,7 @@ type MessageTimelineProps = { onMarkUnread?: (message: TimelineMessage) => void; onMarkRead?: (message: TimelineMessage) => void; onReply?: (message: TimelineMessage) => void; + onOpenThread?: (message: TimelineMessage) => void; isSendingVideoReviewComment?: boolean; onSendVideoReviewComment?: ( message: TimelineMessage, @@ -178,6 +179,7 @@ const MessageTimelineBase = React.forwardRef< onMarkUnread, onMarkRead, onReply, + onOpenThread, channelName, channelType, isSendingVideoReviewComment = false, @@ -635,6 +637,7 @@ const MessageTimelineBase = React.forwardRef< onMarkUnread={onMarkUnread} onMarkRead={onMarkRead} onReply={onReply} + onOpenThread={onOpenThread} isSendingVideoReviewComment={isSendingVideoReviewComment} onSendVideoReviewComment={onSendVideoReviewComment} onStartReached={loadOlderViaVirtualizer} diff --git a/desktop/src/features/messages/ui/TimelineMessageList.tsx b/desktop/src/features/messages/ui/TimelineMessageList.tsx index b724d995eb5..89183d0856a 100644 --- a/desktop/src/features/messages/ui/TimelineMessageList.tsx +++ b/desktop/src/features/messages/ui/TimelineMessageList.tsx @@ -77,6 +77,7 @@ type TimelineMessageListProps = { onMarkUnread?: (message: TimelineMessage) => void; onMarkRead?: (message: TimelineMessage) => void; onReply?: (message: TimelineMessage) => void; + onOpenThread?: (message: TimelineMessage) => void; isSendingVideoReviewComment?: boolean; onSendVideoReviewComment?: ( message: TimelineMessage, @@ -142,6 +143,7 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({ onMarkUnread, onMarkRead, onReply, + onOpenThread, isSendingVideoReviewComment = false, onSendVideoReviewComment, onToggleReaction, @@ -255,6 +257,7 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({ onMarkRead={onMarkRead} onMarkUnread={onMarkUnread} onReply={onReply} + onOpenThread={onOpenThread} onToggleReaction={onToggleReaction} profiles={profiles} searchActiveMessageId={searchActiveMessageId} @@ -286,6 +289,7 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({ onMarkRead, onMarkUnread, onReply, + onOpenThread, onToggleReaction, profiles, ownerProfiles, @@ -699,6 +703,7 @@ type MessageRowItemProps = Pick< | "onMarkUnread" | "onMarkRead" | "onReply" + | "onOpenThread" | "onToggleReaction" | "profiles" | "searchActiveMessageId" @@ -737,6 +742,7 @@ function MessageRowItem({ onMarkUnread, onMarkRead, onReply, + onOpenThread, onToggleReaction, profiles, searchActiveMessageId, @@ -755,7 +761,7 @@ function MessageRowItem({ const canDelete = canManage && onDelete ? onDelete : undefined; const canEdit = canManage && onEdit ? onEdit : undefined; - if (summary && onReply) { + if (summary && onOpenThread) { const isHighlighted = message.id === highlightedMessageId; return (
unfollowThreadById(message.id) @@ -802,7 +809,7 @@ function MessageRowItem({ '00000000-0000-0000-0000-000000000000'::uuid)