diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 90cbbac0cf..b53ce55806 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -543,6 +543,12 @@ Note: Both `TriggerDef` and `ActionDef` use serde internally-tagged enums. Trigg | `request_approval` | Suspend execution; fields: `from`, `message`, `timeout` (default 24h) | | `delay` | Pause execution (max 300 seconds) | +**Workflow-to-agent authority:** `send_message` output is a kind `9` event signed by the relay identity advertised as NIP-11 `self`. The event carries exactly one `["buzz:workflow", "true"]` marker and exactly one `["workflow-owner", "<64-hex-pubkey>"]` authority tag. The latter names the workflow owner whose authorization is being exercised; `p` tags remain attribution and mention/wake routing only and never grant authority. + +An ACP harness may evaluate a workflow message under `workflow-owner` instead of the event signer only when all of the following verify: kind `9`; signature/author equals the configured endpoint's valid NIP-11 `self`; one exact workflow marker; and one syntactically valid, unambiguous `workflow-owner`. Missing NIP-11 identity, malformed/duplicate provenance, a non-relay signer, or a bare owner `p` tag fails closed to ordinary author authorization. The derived workflow owner is then subject to the same `respond_to` policy and DM hardening as a directly authored message. This delegates no broader authority to relay-authored events. + +**Discovery query correctness:** multi-value `#h` workflow filters are intersected with the authenticated reader's accessible channels and pushed into SQL before the historical `LIMIT`. Unlike the broader access-scope predicate, an explicit `#h` filter excludes channel-less global events. + **Template variables:** `{{trigger.text}}`, `{{trigger.author}}`, `{{steps.ID.output.FIELD}}`. Single-pass resolution (not recursive). Unknown variables left as literal text. **Condition evaluation:** `evalexpr` with `HashMapContext`. Dot notation converted to underscores (`trigger.text` → `trigger_text`). Custom functions registered: `str_contains`, `str_starts_with`, `str_ends_with`, `str_len`. 100ms timeout prevents adversarial expressions from blocking. diff --git a/crates/buzz-acp/src/lib.rs b/crates/buzz-acp/src/lib.rs index 03b75a4211..30eb71ccb7 100644 --- a/crates/buzz-acp/src/lib.rs +++ b/crates/buzz-acp/src/lib.rs @@ -232,6 +232,48 @@ async fn is_owner_or_sibling( /// siblings may fire a turn — the explicit allowlist and `anyone` mode do /// NOT apply inside DMs. `Nobody` still drops everything. Callers must /// resolve `is_dm` fail-closed: unknown channel type ⇒ treat as DM. +/// Return the workflow principal asserted by a trusted relay-generated event. +/// +/// Authority requires all of: kind:9, the configured endpoint's NIP-11 `self` +/// as cryptographic author, exactly one `buzz:workflow=true` marker, and exactly +/// one valid `workflow-owner` pubkey. `p` tags are deliberately ignored here: +/// they route mentions and cannot grant authority. +fn trusted_workflow_owner(event: &nostr::Event, relay_self: Option<&str>) -> Option { + let relay_self = relay_self?; + if event.kind.as_u16() as u32 != buzz_core::kind::KIND_STREAM_MESSAGE + || !event.pubkey.to_hex().eq_ignore_ascii_case(relay_self) + || event.verify().is_err() + { + return None; + } + + let workflow_markers: Vec<_> = event + .tags + .iter() + .filter(|tag| tag.as_slice().first().map(String::as_str) == Some("buzz:workflow")) + .collect(); + if workflow_markers.len() != 1 || workflow_markers[0].as_slice() != ["buzz:workflow", "true"] { + return None; + } + + let owner_tags: Vec<_> = event + .tags + .iter() + .filter(|tag| tag.as_slice().first().map(String::as_str) == Some("workflow-owner")) + .collect(); + if owner_tags.len() != 1 || owner_tags[0].as_slice().len() != 2 { + return None; + } + let owner = owner_tags[0].as_slice()[1].to_ascii_lowercase(); + if owner.len() != 64 + || !owner.chars().all(|c| c.is_ascii_hexdigit()) + || nostr::PublicKey::from_hex(&owner).is_err() + { + return None; + } + Some(owner) +} + async fn author_allowed( respond_to: &RespondTo, allowlist: &HashSet, @@ -1345,6 +1387,8 @@ async fn tokio_main() -> Result<()> { .await .map_err(|e| anyhow::anyhow!("relay connect error: {e}"))?; + let relay_self = relay.relay_self().map(str::to_owned); + // Tell the relay background task the watermark so it can use // `since = watermark - 5s` on the first REQ instead of `since=now`. // Best-effort: a failure here is non-fatal (we just lose the startup window @@ -2145,6 +2189,9 @@ async fn tokio_main() -> Result<()> { // it never revokes same-owner team bots. { let author = buzz_event.event.pubkey.to_hex(); + let workflow_owner = + trusted_workflow_owner(&buzz_event.event, relay_self.as_deref()); + let principal = workflow_owner.as_deref().unwrap_or(&author); // DM hardening: resolve channel type (fail-closed // to DM) so allowlist/anyone modes cannot be // exercised by non-owner authors inside DMs. @@ -2153,7 +2200,7 @@ async fn tokio_main() -> Result<()> { let allowed = author_allowed( &config.respond_to, &config.respond_to_allowlist, - &author, + principal, is_dm, &owner_cache, &ctx.rest_client, @@ -2162,7 +2209,9 @@ async fn tokio_main() -> Result<()> { if !allowed { tracing::debug!( channel_id = %buzz_event.channel_id, - author = %buzz_event.event.pubkey.to_hex(), + author = %author, + principal = %principal, + workflow_delegated = workflow_owner.is_some(), mode = %config.respond_to, is_dm, "inbound author gate — dropping event" @@ -4139,6 +4188,104 @@ fn build_mcp_servers(config: &Config) -> Vec { }] } +#[cfg(test)] +mod workflow_authority_tests { + use super::*; + use nostr::{EventBuilder, Keys, Kind, Tag}; + + fn workflow_event(signer: &Keys, owner: Option<&str>, marker: bool) -> nostr::Event { + let mut tags = Vec::new(); + if marker { + tags.push(Tag::parse(["buzz:workflow", "true"]).unwrap()); + } + if let Some(owner) = owner { + tags.push(Tag::parse(["workflow-owner", owner]).unwrap()); + } + EventBuilder::new(Kind::Custom(9), "run") + .tags(tags) + .sign_with_keys(signer) + .unwrap() + } + + #[test] + fn requires_relay_signature_marker_and_dedicated_owner() { + let relay = Keys::generate(); + let owner = Keys::generate().public_key().to_hex(); + let event = workflow_event(&relay, Some(&owner), true); + assert_eq!( + trusted_workflow_owner(&event, Some(&relay.public_key().to_hex())), + Some(owner) + ); + } + + #[test] + fn rejects_forged_missing_and_ambiguous_provenance() { + let relay = Keys::generate(); + let attacker = Keys::generate(); + let owner = Keys::generate().public_key().to_hex(); + let relay_hex = relay.public_key().to_hex(); + assert!(trusted_workflow_owner( + &workflow_event(&attacker, Some(&owner), true), + Some(&relay_hex) + ) + .is_none()); + assert!(trusted_workflow_owner( + &workflow_event(&relay, Some(&owner), false), + Some(&relay_hex) + ) + .is_none()); + assert!( + trusted_workflow_owner(&workflow_event(&relay, None, true), Some(&relay_hex)).is_none() + ); + + let duplicate = EventBuilder::new(Kind::Custom(9), "run") + .tags([ + Tag::parse(["buzz:workflow", "true"]).unwrap(), + Tag::parse(["workflow-owner", &owner]).unwrap(), + Tag::parse(["workflow-owner", &owner]).unwrap(), + ]) + .sign_with_keys(&relay) + .unwrap(); + assert!(trusted_workflow_owner(&duplicate, Some(&relay_hex)).is_none()); + + let malformed_marker = EventBuilder::new(Kind::Custom(9), "run") + .tags([ + Tag::parse(["buzz:workflow", "false"]).unwrap(), + Tag::parse(["workflow-owner", &owner]).unwrap(), + ]) + .sign_with_keys(&relay) + .unwrap(); + assert!(trusted_workflow_owner(&malformed_marker, Some(&relay_hex)).is_none()); + + let malformed_owner = EventBuilder::new(Kind::Custom(9), "run") + .tags([ + Tag::parse(["buzz:workflow", "true"]).unwrap(), + Tag::parse(["workflow-owner", &owner, "extra"]).unwrap(), + ]) + .sign_with_keys(&relay) + .unwrap(); + assert!(trusted_workflow_owner(&malformed_owner, Some(&relay_hex)).is_none()); + + let mut tampered = workflow_event(&relay, Some(&owner), true); + tampered.content = "tampered after signing".to_string(); + assert!(trusted_workflow_owner(&tampered, Some(&relay_hex)).is_none()); + } + + #[test] + fn p_tags_never_supply_workflow_authority() { + let relay = Keys::generate(); + let owner = Keys::generate().public_key().to_hex(); + let event = EventBuilder::new(Kind::Custom(9), "run") + .tags([ + Tag::parse(["buzz:workflow", "true"]).unwrap(), + Tag::parse(["p", &owner]).unwrap(), + ]) + .sign_with_keys(&relay) + .unwrap(); + assert!(trusted_workflow_owner(&event, Some(&relay.public_key().to_hex())).is_none()); + } +} + #[cfg(test)] mod heartbeat_base_prompt_tests { use super::*; diff --git a/crates/buzz-acp/src/relay.rs b/crates/buzz-acp/src/relay.rs index c8312cc61e..be9d6dd501 100644 --- a/crates/buzz-acp/src/relay.rs +++ b/crates/buzz-acp/src/relay.rs @@ -546,6 +546,9 @@ pub struct HarnessRelay { keys: Keys, /// Optional NIP-OA auth tag for relay membership delegation. auth_tag: Option, + /// Relay signing identity advertised by NIP-11 `self`. Relay-authored + /// workflow messages are trusted only when their signature matches this key. + relay_self: Option, /// Handle to the background task (for clean shutdown). /// Wrapped in `Option` so `shutdown()` can take ownership without conflicting /// with `Drop` (which only has `&mut self`). @@ -599,6 +602,16 @@ impl HarnessRelay { agent_pubkey_hex: &str, auth_tag: Option, ) -> Result { + let http = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(10)) + .connect_timeout(std::time::Duration::from_secs(5)) + .build() + .map_err(|e| RelayError::Http(format!("failed to build HTTP client: {e}")))?; + // NIP-11 is the authority binding between this configured endpoint and + // its relay signing key. Missing, malformed, or unreachable info fails + // closed for workflow delegation without preventing ordinary ACP use. + let relay_self = fetch_relay_self(&http, relay_url).await; + // Perform the initial connection and auth handshake, retrying // transient failures (dropped handshake, timeout) with bounded // jittered backoff. A terminal error (bad URL, bad auth tag, @@ -636,14 +649,11 @@ impl HarnessRelay { event_rx, observer_control_rx: Some(observer_control_rx), cmd_tx, - http: reqwest::Client::builder() - .timeout(std::time::Duration::from_secs(10)) - .connect_timeout(std::time::Duration::from_secs(5)) - .build() - .map_err(|e| RelayError::Http(format!("failed to build HTTP client: {e}")))?, + http, relay_url: relay_url.to_string(), keys: keys.clone(), auth_tag, + relay_self, bg_handle: Some(bg_handle), }) } @@ -712,6 +722,11 @@ impl HarnessRelay { Ok(map) } + /// Return the configured endpoint's NIP-11 relay signing identity. + pub fn relay_self(&self) -> Option<&str> { + self.relay_self.as_deref() + } + /// Build a [`RestClient`] that shares this relay's HTTP credentials. /// /// The returned client is cheap to clone (wraps `reqwest::Client` which is @@ -3467,6 +3482,33 @@ async fn send_auth_response( /// `ws://host:port` → `http://host:port` /// `wss://host:port` → `https://host:port` /// Trailing slashes are stripped. +#[derive(serde::Deserialize)] +struct RelayInformationDocument { + #[serde(default, rename = "self")] + relay_self: Option, +} + +async fn fetch_relay_self(http: &reqwest::Client, relay_url: &str) -> Option { + let response = http + .get(relay_ws_to_http(relay_url)) + .header("Accept", "application/nostr+json") + .send() + .await + .ok()?; + if !response.status().is_success() { + return None; + } + let value = response + .json::() + .await + .ok()? + .relay_self? + .to_ascii_lowercase(); + nostr::PublicKey::from_hex(&value) + .ok() + .map(|key| key.to_hex()) +} + pub(crate) fn relay_ws_to_http(url: &str) -> String { url.replace("wss://", "https://") .replace("ws://", "http://") diff --git a/crates/buzz-db/src/event.rs b/crates/buzz-db/src/event.rs index 5adcb05bdc..ccd8efbdc6 100644 --- a/crates/buzz-db/src/event.rs +++ b/crates/buzz-db/src/event.rs @@ -67,6 +67,10 @@ pub struct EventQuery { /// channel-less global events. Applied before SQL `LIMIT` so access-filtered /// historical pages have exact exhaustion semantics. pub channel_ids: Option>, + /// Restrict results to events in channels where this pubkey has an active + /// membership. The membership is resolved by PostgreSQL with an indexed + /// `EXISTS` subquery, avoiding client-supplied channel-ID arrays. + pub member_channel_pubkey: Option>, /// Override the default limit clamp (1000). Used by COUNT fallback path /// which needs to fetch all matching events for post-filter counting. /// When None, the default clamp of 1000 applies. @@ -98,6 +102,7 @@ impl EventQuery { ids: None, e_tags: None, channel_ids: None, + member_channel_pubkey: None, max_limit: None, } } @@ -390,6 +395,33 @@ pub async fn query_events(pool: &PgPool, q: &EventQuery) -> Result nostr::Event { let keys = Keys::generate(); EventBuilder::new(Kind::Custom(9), content) diff --git a/crates/buzz-relay/src/api/bridge.rs b/crates/buzz-relay/src/api/bridge.rs index 8372e49a2a..6402ecbf7a 100644 --- a/crates/buzz-relay/src/api/bridge.rs +++ b/crates/buzz-relay/src/api/bridge.rs @@ -997,6 +997,85 @@ async fn query_events_authed( )); } + // `member_channels` is an authenticated HTTP-bridge extension for global + // overviews of channel-scoped resources. Keep it deliberately narrow: the + // only current contract is workflow definitions, and mixed filters are + // rejected rather than falling back to the generic membership-array path. + let member_workflow_overview = raw_filters + .iter() + .zip(filters.iter()) + .filter(|(raw, _)| extension_flag(raw, "member_channels")) + .collect::>(); + if !member_workflow_overview.is_empty() { + if member_workflow_overview.len() != filters.len() { + return Err(api_error( + StatusCode::BAD_REQUEST, + "member_channels filters cannot be mixed with ordinary filters", + )); + } + + let mut overview_events = Vec::new(); + for (raw, filter) in member_workflow_overview { + let workflow_only = filter.kinds.as_ref().is_some_and(|kinds| { + kinds.len() == 1 && kinds.iter().all(|kind| kind.as_u16() == 30_620) + }); + if !workflow_only || extract_channel_from_filter(filter).is_some() { + return Err(api_error( + StatusCode::BAD_REQUEST, + "member_channels requires kinds:[30620] without #h", + )); + } + + let mut query = crate::handlers::req::build_event_query_from_filter( + filter, + &pubkey_bytes, + state, + tenant.community(), + ) + .await; + query.member_channel_pubkey = Some(pubkey_bytes.clone()); + + match extract_before_id(raw) { + BeforeId::Malformed => { + return Err(api_error( + StatusCode::BAD_REQUEST, + "before_id must be a 64-char hex event id", + )); + } + BeforeId::Valid(before_id) => { + if query.until.is_none() { + return Err(api_error( + StatusCode::BAD_REQUEST, + "before_id requires until to be set", + )); + } + query.before_id = Some(before_id); + } + BeforeId::Absent => {} + } + + let stored_events = state + .db + .query_events(&query) + .await + .map_err(|e| internal_error(&format!("member workflow query error: {e}")))?; + for stored in stored_events { + if buzz_core::filter::filters_match(std::slice::from_ref(filter), &stored) + && buzz_core::filter::reader_authorized_for_event( + &stored.event, + &authed_pubkey_hex, + ) + { + overview_events.push( + serde_json::to_value(&stored.event) + .map_err(|e| internal_error(&format!("event serialize: {e}")))?, + ); + } + } + } + return Ok(Json(Value::Array(overview_events))); + } + // Get channels this user can access — same enforcement as WS REQ handler. let accessible_channels = state .get_accessible_channel_ids_cached(tenant.community(), &pubkey_bytes) diff --git a/crates/buzz-relay/src/workflow_sink.rs b/crates/buzz-relay/src/workflow_sink.rs index 97c31c2561..703e6c0ec8 100644 --- a/crates/buzz-relay/src/workflow_sink.rs +++ b/crates/buzz-relay/src/workflow_sink.rs @@ -252,12 +252,15 @@ impl ActionSink for RelayActionSink { // 3. Build kind:9 Nostr event // - Signed by relay keypair (event.pubkey = relay pubkey) - // - `p` tag attributes the message to the workflow owner + // - `workflow-owner` carries the sole authorization principal; + // `p` tags remain attribution/mention routing only // - `h` tag scopes to the channel (NIP-29, canonical UUID) // - `buzz:workflow` tag prevents recursive workflow triggering // - one `p` tag per `@Name` that resolves to a channel member, // so mentioned agents are woken (wake is `p`-tag gated) let mut tags = vec![ + Tag::parse(["workflow-owner", &author_pubkey_hex]) + .map_err(|e| ActionSinkError::EventBuild(format!("workflow-owner tag: {e}")))?, Tag::parse(["p", &author_pubkey_hex]) .map_err(|e| ActionSinkError::EventBuild(format!("p tag: {e}")))?, Tag::parse(["h", &channel_id_canonical]) @@ -707,5 +710,23 @@ mod integration_tests { p_tag_targets.contains(&agent_hex.as_str()), "mentioned member {agent_hex} must be p-tagged so it wakes; got {p_tag_targets:?}" ); + + let workflow_owner_targets: Vec<&str> = stored + .event + .tags + .iter() + .filter(|t| t.as_slice().first().map(|s| s.as_str()) == Some("workflow-owner")) + .filter_map(|t| t.as_slice().get(1).map(|s| s.as_str())) + .collect(); + assert_eq!( + workflow_owner_targets, + vec![author_hex.as_str()], + "workflow output must carry exactly one dedicated owner authority tag" + ); + assert_eq!( + stored.event.pubkey, + state.relay_keypair.public_key(), + "workflow output must be signed by the relay identity" + ); } } diff --git a/crates/buzz-workflow/src/action_sink.rs b/crates/buzz-workflow/src/action_sink.rs index 0c6002e74e..6a81022974 100644 --- a/crates/buzz-workflow/src/action_sink.rs +++ b/crates/buzz-workflow/src/action_sink.rs @@ -56,7 +56,8 @@ pub trait ActionSink: Send + Sync { /// - `channel_id`: UUID string of the target channel /// - `text`: message body (must not be empty/whitespace-only) /// - `author_pubkey`: hex-encoded pubkey of the workflow owner (used for - /// the `p` attribution tag; the relay keypair signs the event) + /// the dedicated `workflow-owner` authority tag and a `p` attribution + /// tag; the relay keypair signs the event) /// /// Returns the event ID hex string on success. fn send_message( diff --git a/desktop/src-tauri/src/commands/workflows.rs b/desktop/src-tauri/src/commands/workflows.rs index 1d5f309fb5..de429d6093 100644 --- a/desktop/src-tauri/src/commands/workflows.rs +++ b/desktop/src-tauri/src/commands/workflows.rs @@ -66,34 +66,42 @@ pub async fn get_channel_workflows( Ok(events.iter().map(workflow_from_event).collect()) } -/// Fetch workflows across many channels in a single relay round-trip. -/// -/// The Workflows overview screen previously issued one `get_channel_workflows` -/// query per member channel (`Promise.all` fanout in `WorkflowsView`), i.e. N -/// relay POSTs. A nostr `#h` filter matches ANY of its listed values, so one -/// query with all channel ids returns the same set. Each `WorkflowWire` carries -/// its own `channel_id` (from the event's `h` tag), so the frontend can still -/// group results by channel. Neither this nor the per-channel command sets a -/// `limit`, so batching does not change result completeness. +const WORKFLOW_OVERVIEW_PAGE_SIZE: usize = 500; + +fn advance_workflow_cursor(filter: &mut Value, page: &[nostr::Event]) { + let last = page + .last() + .expect("a full workflow overview page always has a last event"); + filter["until"] = serde_json::json!(last.created_at.as_secs()); + filter["before_id"] = serde_json::json!(last.id.to_hex()); +} + +/// Fetch every workflow in channels where the authenticated user is an active +/// member. The relay resolves membership server-side; channel UUIDs never cross +/// the Desktop/relay boundary. Composite keyset pagination keeps each DB read +/// bounded while preserving workflows created in the same second. #[tauri::command] -pub async fn get_channels_workflows( - channel_ids: Vec, +pub async fn get_member_channel_workflows( state: State<'_, AppState>, ) -> Result, String> { - if channel_ids.is_empty() { - return Ok(Vec::new()); + let mut filter = serde_json::json!({ + "kinds": [30620], + "member_channels": true, + "limit": WORKFLOW_OVERVIEW_PAGE_SIZE, + }); + let mut workflows = Vec::new(); + + loop { + let page = query_relay(&state, &[filter.clone()]).await?; + let done = page.len() < WORKFLOW_OVERVIEW_PAGE_SIZE; + if !done { + advance_workflow_cursor(&mut filter, &page); + } + workflows.extend(page.iter().map(workflow_from_event)); + if done { + return Ok(workflows); + } } - - let events = query_relay( - &state, - &[serde_json::json!({ - "kinds": [30620], - "#h": channel_ids, - })], - ) - .await?; - - Ok(events.iter().map(workflow_from_event).collect()) } #[tauri::command] diff --git a/desktop/src-tauri/src/commands/workflows_tests.rs b/desktop/src-tauri/src/commands/workflows_tests.rs index f07f4b0f42..b5c9f91eb2 100644 --- a/desktop/src-tauri/src/commands/workflows_tests.rs +++ b/desktop/src-tauri/src/commands/workflows_tests.rs @@ -188,6 +188,23 @@ fn workflow_wire_serializes_with_snake_case_keys() { } } +#[test] +fn workflow_overview_cursor_uses_timestamp_and_event_id() { + let first = wf_event(WF, CHAN, YAML); + let second = wf_event("33333333-3333-3333-3333-333333333333", CHAN, YAML); + let mut filter = serde_json::json!({ + "kinds": [30620], + "member_channels": true, + "limit": WORKFLOW_OVERVIEW_PAGE_SIZE, + }); + + advance_workflow_cursor(&mut filter, &[first, second.clone()]); + + assert_eq!(filter["until"], second.created_at.as_secs()); + assert_eq!(filter["before_id"], second.id.to_hex()); + assert!(filter.get("#h").is_none()); +} + #[test] fn runs_and_approvals_serialize_to_bare_empty_array() { // Regression guard for the crash class this fix closed. The frontend diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 3b47866ac4..abcc3cd861 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -830,7 +830,7 @@ pub fn run() { preview_team_snapshot_import, confirm_team_snapshot_import, get_channel_workflows, - get_channels_workflows, + get_member_channel_workflows, get_workflow, create_workflow, update_workflow, diff --git a/desktop/src/features/workflows/ui/WorkflowsView.tsx b/desktop/src/features/workflows/ui/WorkflowsView.tsx index 449db37818..c0f549043f 100644 --- a/desktop/src/features/workflows/ui/WorkflowsView.tsx +++ b/desktop/src/features/workflows/ui/WorkflowsView.tsx @@ -10,7 +10,7 @@ import { WorkflowDialog } from "@/features/workflows/ui/WorkflowDialog"; import type { Channel, Workflow } from "@/shared/api/types"; import { deleteWorkflow, - getChannelsWorkflows, + getMemberChannelWorkflows, triggerWorkflow, } from "@/shared/api/tauriWorkflows"; import { Button } from "@/shared/ui/button"; @@ -83,12 +83,13 @@ export function WorkflowsView({ const allWorkflowsQuery = useQuery({ queryKey: allWorkflowsQueryKey(channelIdKey), queryFn: async () => { - // Single batched relay query for all member channels, then group by the - // channel_id each workflow carries — replaces the per-channel fanout. + // The relay resolves active memberships server-side and returns bounded, + // keyset-paginated workflow pages. Desktop only retains channel names for + // presentation and never sends its full membership list. const channelNameById = new Map( memberChannels.map((channel) => [channel.id, channel.name]), ); - const workflows = await getChannelsWorkflows(channelIds); + const workflows = await getMemberChannelWorkflows(); const results: WorkflowWithChannel[] = []; for (const workflow of workflows) { results.push({ diff --git a/desktop/src/shared/api/tauriWorkflows.ts b/desktop/src/shared/api/tauriWorkflows.ts index 2fbf4be044..4d4fa3790e 100644 --- a/desktop/src/shared/api/tauriWorkflows.ts +++ b/desktop/src/shared/api/tauriWorkflows.ts @@ -170,18 +170,12 @@ export async function getChannelWorkflows( } /** - * Fetch workflows across many channels in a single relay round-trip. - * - * Replaces the per-channel `Promise.all(getChannelWorkflows)` fanout on the - * Workflows overview: the backend `#h` filter matches any listed channel, and - * each returned workflow carries its own `channelId` so callers can group. + * Fetch workflows from every channel where the authenticated user is a member. + * Channel membership is resolved and paginated by the relay; Desktop does not + * send its full channel list. */ -export async function getChannelsWorkflows( - channelIds: string[], -): Promise { - const raw = await invokeTauri("get_channels_workflows", { - channelIds, - }); +export async function getMemberChannelWorkflows(): Promise { + const raw = await invokeTauri("get_member_channel_workflows"); return raw.map(fromRawWorkflow); } diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index a7d58e70cc..cf37c88ac2 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -2951,11 +2951,8 @@ function handleGetChannelWorkflows(args: { channelId: string }) { return mockWorkflows.filter((w) => w.channel_id === args.channelId); } -function handleGetChannelsWorkflows(args: { channelIds: string[] }) { - const ids = new Set(args.channelIds); - return mockWorkflows.filter( - (w) => w.channel_id != null && ids.has(w.channel_id), - ); +function handleGetMemberChannelWorkflows() { + return [...mockWorkflows]; } function handleGetWorkflow(args: { workflowId: string }) { @@ -10855,10 +10852,8 @@ export function maybeInstallE2eTauriMocks() { return handleGetChannelWorkflows( payload as Parameters[0], ); - case "get_channels_workflows": - return handleGetChannelsWorkflows( - payload as Parameters[0], - ); + case "get_member_channel_workflows": + return handleGetMemberChannelWorkflows(); case "get_workflow": return handleGetWorkflow( payload as Parameters[0],