mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
fix(desktop): scope agent sends to their captured tenant; thread follow-ups onto the opener
Two fifth-round review findings on the Projects agent send path.
Cross-tenant in-flight sends (P1): the submit flow suspends across
managed-agent startup, DM open, and the send itself. A community switch
during any suspension does not cancel the callback — remounting only
removes the UI — so a stale callback could open a DM or publish the
captured tenant's content on the NEW tenant's relay. Fix: the caller
captures the community relay scope before its first await and passes it
as expected_relay_url through open_dm and send_channel_message. Each
command resolves its relay base exactly once, asserts the captured scope
against it (assert_expected_relay_scope, ws(s)→http(s) normalized), and
uses that same base for every side effect — thread-ref read, submit, and
metadata read. A mismatch fails closed ("active community changed before
the message was submitted; not sent") rather than publishing to the
wrong tenant. Absent scope preserves unscoped behavior for callers with
no tenant boundary. submit_event_with_created_at becomes
submit_event_at_created_at (explicit base) so the scope-checked base is
the one used at submit time — re-resolving there would reopen the race.
Same-second follow-up hiding (P2 of the pair): follow-up sends carried
no parentEventId, so a follow-up signed within the opener's second got a
random event id and roughly half sorted on the rejected side of the
same-second id tiebreak in isAtOrAfterConversationOpener — an immediate
follow-up could vanish. Fix: follow-ups now reply to the opener
(parentEventId = opener.eventId); the comparator always admits causal
e-tag replies, so visibility no longer depends on id luck.
Both submit handlers (ProjectAgentChatPanel, ProjectsAgentPromptPage)
route through a new pure orchestration function,
submitProjectAgentMessage, which never re-reads the scope after capture
and is unit-tested without React: switch-during-startup publishes
nothing to either tenant, switch-during-DM-open fails closed, the
captured scope rides every relay side effect, and follow-ups reference
the opener. The e2e bridge mirrors the backend check after its injected
delays so specs can drive a mid-flight community switch
deterministically.
File-size ratchet keeps its discipline: the scope check lives in
relay/scope.rs, resolve_thread_ref moves to
commands/messages/thread_ref.rs, sendChannelMessage moves to
shared/api/tauriMessages.ts (re-exported from tauri.ts), and OpenDmInput
moves next to openDm in tauriChannels.ts.
Co-authored-by: Thomas Petersen <thomasp@squareup.com>
Signed-off-by: Thomas Petersen <thomasp@squareup.com>
This commit is contained in:
co-authored by
Thomas Petersen
parent
bbbb3564b6
commit
a86f9e2d85
@@ -6,7 +6,10 @@ use crate::{
|
||||
events,
|
||||
models::ChannelInfo,
|
||||
nostr_convert,
|
||||
relay::{parse_command_response, query_relay, submit_event},
|
||||
relay::{
|
||||
assert_expected_relay_scope, parse_command_response, query_relay_at, submit_event,
|
||||
submit_event_at_with_keys,
|
||||
},
|
||||
};
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -17,18 +20,30 @@ struct OpenDmAck {
|
||||
#[tauri::command]
|
||||
pub async fn open_dm(
|
||||
pubkeys: Vec<String>,
|
||||
expected_relay_url: Option<String>,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<ChannelInfo, String> {
|
||||
// Resolve the relay once for the open + metadata read pair. Callers with
|
||||
// a captured tenant scope (Projects agent sends) pass
|
||||
// `expected_relay_url`; a mismatch means the active community changed
|
||||
// while their callback was suspended, and opening a DM on the new tenant
|
||||
// would hand the stale callback a channel in the wrong community — fail
|
||||
// closed instead.
|
||||
let api_base_url = crate::relay::relay_api_base_url_with_override(&state);
|
||||
assert_expected_relay_scope(expected_relay_url.as_deref(), &api_base_url)?;
|
||||
|
||||
// Submit a kind:41010 dm-open event; the relay replies with the channel id
|
||||
// in its OK message payload.
|
||||
let builder = events::build_dm_open(&pubkeys)?;
|
||||
let result = submit_event(builder, &state).await?;
|
||||
let keys = state.signing_keys()?;
|
||||
let result = submit_event_at_with_keys(builder, &state, &api_base_url, &keys).await?;
|
||||
let ack: OpenDmAck = parse_command_response(&result.message)?;
|
||||
|
||||
// Re-fetch the channel metadata so the frontend gets the same `ChannelInfo`
|
||||
// shape as `get_channel_details`.
|
||||
let metadata = query_relay(
|
||||
// shape as `get_channel_details` — through the same scope-checked base.
|
||||
let metadata = query_relay_at(
|
||||
&state,
|
||||
&api_base_url,
|
||||
&[serde_json::json!({
|
||||
"kinds": [39000],
|
||||
"#d": [ack.channel_id],
|
||||
|
||||
@@ -18,7 +18,8 @@ use crate::{
|
||||
},
|
||||
nostr_convert,
|
||||
relay::{
|
||||
query_relay, submit_event, submit_event_with_created_at, submit_event_with_keys_created_at,
|
||||
assert_expected_relay_scope, query_relay, submit_event, submit_event_at_created_at,
|
||||
submit_event_with_keys_created_at,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -433,54 +434,8 @@ pub async fn get_event(event_id: String, state: State<'_, AppState>) -> Result<S
|
||||
|
||||
// ── Writes ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Fetch a parent event and extract the thread root from its NIP-10 e-tags.
|
||||
async fn resolve_thread_ref(
|
||||
parent_event_id: &str,
|
||||
state: &AppState,
|
||||
) -> Result<events::ThreadRef, String> {
|
||||
let parent_eid =
|
||||
EventId::from_hex(parent_event_id).map_err(|e| format!("invalid parent event ID: {e}"))?;
|
||||
|
||||
let evs = query_relay(
|
||||
state,
|
||||
&[serde_json::json!({
|
||||
"ids": [parent_event_id],
|
||||
"kinds": [9, 40002, 45001, 45003, buzz_core_pkg::kind::KIND_HUDDLE_STARTED],
|
||||
"limit": 1
|
||||
})],
|
||||
)
|
||||
.await?;
|
||||
|
||||
let parent = evs
|
||||
.first()
|
||||
.ok_or_else(|| "parent event not found".to_string())?;
|
||||
|
||||
// Walk tags looking for NIP-10 root/reply markers.
|
||||
let (mut root, mut reply) = (None, None);
|
||||
for tag in parent.tags.iter() {
|
||||
let s = tag.as_slice();
|
||||
if s.len() >= 4 && s[0] == "e" {
|
||||
match s[3].as_str() {
|
||||
"root" => root = Some(s[1].clone()),
|
||||
"reply" => reply = Some(s[1].clone()),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
let root_hex = root.or(reply);
|
||||
|
||||
let root_eid = match root_hex {
|
||||
Some(hex) if hex != parent_event_id => {
|
||||
EventId::from_hex(&hex).map_err(|e| format!("invalid root event ID: {e}"))?
|
||||
}
|
||||
_ => parent_eid,
|
||||
};
|
||||
|
||||
Ok(events::ThreadRef {
|
||||
root_event_id: root_eid,
|
||||
parent_event_id: parent_eid,
|
||||
})
|
||||
}
|
||||
mod thread_ref;
|
||||
use thread_ref::resolve_thread_ref;
|
||||
|
||||
#[tauri::command]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
@@ -495,6 +450,7 @@ pub async fn send_channel_message(
|
||||
sent_from_thread_tag: Option<Vec<String>>,
|
||||
mention_pubkeys: Option<Vec<String>>,
|
||||
kind: Option<u32>,
|
||||
expected_relay_url: Option<String>,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<SendChannelMessageResponse, String> {
|
||||
let channel_uuid = uuid::Uuid::parse_str(&channel_id)
|
||||
@@ -505,7 +461,13 @@ pub async fn send_channel_message(
|
||||
let emoji = emoji_tags.unwrap_or_default();
|
||||
let mention_refs_only = mention_tags.unwrap_or_default();
|
||||
let link_previews = link_preview_tags.unwrap_or_default();
|
||||
// Resolve the relay once and use it for every read and the submission.
|
||||
// Callers that captured a tenant scope before an await (Projects agent
|
||||
// sends) pass `expected_relay_url`; a mismatch means the active community
|
||||
// changed mid-flight and the send must fail closed rather than publish
|
||||
// the captured tenant's content to the new tenant's relay.
|
||||
let relay_base = crate::relay::relay_api_base_url_with_override(&state);
|
||||
assert_expected_relay_scope(expected_relay_url.as_deref(), &relay_base)?;
|
||||
let kind_num = kind.unwrap_or(buzz_core_pkg::kind::KIND_STREAM_MESSAGE);
|
||||
if sent_from_thread_tag.is_some() && kind_num != buzz_core_pkg::kind::KIND_STREAM_MESSAGE {
|
||||
return Err("sent-from-thread provenance requires a stream message".into());
|
||||
@@ -525,7 +487,7 @@ pub async fn send_channel_message(
|
||||
let parent_id = parent_event_id
|
||||
.as_deref()
|
||||
.ok_or("forum comment requires parent_event_id")?;
|
||||
let thread_ref = resolve_thread_ref(parent_id, &state).await?;
|
||||
let thread_ref = resolve_thread_ref(parent_id, &state, &relay_base).await?;
|
||||
resolved_root = Some(thread_ref.root_event_id.to_hex());
|
||||
events::build_forum_comment(
|
||||
channel_uuid,
|
||||
@@ -539,7 +501,7 @@ pub async fn send_channel_message(
|
||||
_ => {
|
||||
let thread_ref = match parent_event_id.as_deref() {
|
||||
Some(pid) => {
|
||||
let tr = resolve_thread_ref(pid, &state).await?;
|
||||
let tr = resolve_thread_ref(pid, &state, &relay_base).await?;
|
||||
resolved_root = Some(tr.root_event_id.to_hex());
|
||||
Some(tr)
|
||||
}
|
||||
@@ -562,7 +524,9 @@ pub async fn send_channel_message(
|
||||
|
||||
// `created_at` is the signed event's own second, not a post-publication
|
||||
// clock read — persisted as an event cursor by the Projects opener.
|
||||
let (result, created_at) = submit_event_with_created_at(builder, &state).await?;
|
||||
// Submit through the base resolved (and scope-checked) above — a
|
||||
// re-resolve here would reopen the mid-command switch window.
|
||||
let (result, created_at) = submit_event_at_created_at(builder, &state, &relay_base).await?;
|
||||
|
||||
let depth = match (&parent_event_id, &resolved_root) {
|
||||
(None, _) => 0,
|
||||
@@ -779,7 +743,17 @@ pub async fn send_managed_agent_channel_message(
|
||||
let submission_auth_tag =
|
||||
managed_agent_submission_auth_tag(&record, &state, &keys.public_key())?;
|
||||
let thread_ref = match parent_event_id.as_deref() {
|
||||
Some(parent_id) => Some(resolve_thread_ref(parent_id, &state).await?),
|
||||
Some(parent_id) => Some(
|
||||
// Same active-relay resolution as before — this path has no
|
||||
// caller-captured tenant scope (yet), so resolve the override
|
||||
// here and read through it.
|
||||
resolve_thread_ref(
|
||||
parent_id,
|
||||
&state,
|
||||
&crate::relay::relay_api_base_url_with_override(&state),
|
||||
)
|
||||
.await?,
|
||||
),
|
||||
None => None,
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
use nostr::EventId;
|
||||
|
||||
use crate::{app_state::AppState, events, relay::query_relay_at};
|
||||
|
||||
/// Fetch a parent event and extract the thread root from its NIP-10 e-tags.
|
||||
///
|
||||
/// Reads through the explicit `api_base_url` the calling command resolved —
|
||||
/// never re-resolving the workspace override — so a mid-command community
|
||||
/// switch cannot split one logical send across two relays.
|
||||
pub(super) async fn resolve_thread_ref(
|
||||
parent_event_id: &str,
|
||||
state: &AppState,
|
||||
api_base_url: &str,
|
||||
) -> Result<events::ThreadRef, String> {
|
||||
let parent_eid =
|
||||
EventId::from_hex(parent_event_id).map_err(|e| format!("invalid parent event ID: {e}"))?;
|
||||
|
||||
let evs = query_relay_at(
|
||||
state,
|
||||
api_base_url,
|
||||
&[serde_json::json!({
|
||||
"ids": [parent_event_id],
|
||||
"kinds": [9, 40002, 45001, 45003, buzz_core_pkg::kind::KIND_HUDDLE_STARTED],
|
||||
"limit": 1
|
||||
})],
|
||||
)
|
||||
.await?;
|
||||
|
||||
let parent = evs
|
||||
.first()
|
||||
.ok_or_else(|| "parent event not found".to_string())?;
|
||||
|
||||
// Walk tags looking for NIP-10 root/reply markers.
|
||||
let (mut root, mut reply) = (None, None);
|
||||
for tag in parent.tags.iter() {
|
||||
let s = tag.as_slice();
|
||||
if s.len() >= 4 && s[0] == "e" {
|
||||
match s[3].as_str() {
|
||||
"root" => root = Some(s[1].clone()),
|
||||
"reply" => reply = Some(s[1].clone()),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
let root_hex = root.or(reply);
|
||||
|
||||
let root_eid = match root_hex {
|
||||
Some(hex) if hex != parent_event_id => {
|
||||
EventId::from_hex(&hex).map_err(|e| format!("invalid root event ID: {e}"))?
|
||||
}
|
||||
_ => parent_eid,
|
||||
};
|
||||
|
||||
Ok(events::ThreadRef {
|
||||
root_event_id: root_eid,
|
||||
parent_event_id: parent_eid,
|
||||
})
|
||||
}
|
||||
@@ -84,6 +84,9 @@ pub fn relay_http_base_url(relay_url: &str) -> String {
|
||||
trimmed.to_string()
|
||||
}
|
||||
|
||||
mod scope;
|
||||
pub use scope::assert_expected_relay_scope;
|
||||
|
||||
pub fn relay_api_base_url() -> String {
|
||||
if let Some(base) = configured_env_var("BUZZ_RELAY_HTTP") {
|
||||
return base.trim_end_matches('/').to_string();
|
||||
@@ -537,7 +540,7 @@ pub use get::get_relay_json;
|
||||
|
||||
mod submit;
|
||||
pub use submit::{
|
||||
submit_event, submit_event_at_with_keys, submit_event_with_created_at,
|
||||
submit_event, submit_event_at_created_at, submit_event_at_with_keys,
|
||||
submit_event_with_keys_created_at, submit_signed_event_at_with_keys, SubmitEventResponse,
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
use super::relay_http_base_url;
|
||||
|
||||
/// Fail closed when a caller-captured relay scope no longer matches the
|
||||
/// relay a command actually resolved.
|
||||
///
|
||||
/// Long-lived UI callbacks (e.g. the Projects agent submit flow) capture the
|
||||
/// community relay before their first await; a workspace switch during that
|
||||
/// await would otherwise retarget the eventual publication to the new
|
||||
/// tenant's relay. Callers pass the captured scope as a ws(s) URL; it is
|
||||
/// normalized through [`relay_http_base_url`] and compared against the base
|
||||
/// the command resolved once and uses for every side effect. `None` preserves
|
||||
/// the unscoped behavior for callers without a tenant boundary.
|
||||
pub fn assert_expected_relay_scope(
|
||||
expected_relay_url: Option<&str>,
|
||||
resolved_api_base_url: &str,
|
||||
) -> Result<(), String> {
|
||||
let Some(expected) = expected_relay_url.map(str::trim).filter(|s| !s.is_empty()) else {
|
||||
return Ok(());
|
||||
};
|
||||
let expected_base = relay_http_base_url(expected);
|
||||
if expected_base != resolved_api_base_url.trim().trim_end_matches('/') {
|
||||
return Err(
|
||||
"active community changed before the message was submitted; not sent".to_string(),
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::assert_expected_relay_scope;
|
||||
|
||||
#[test]
|
||||
fn matching_scope_passes_across_ws_http_normalization() {
|
||||
assert_expected_relay_scope(Some("wss://tenant-a.example"), "https://tenant-a.example")
|
||||
.unwrap();
|
||||
assert_expected_relay_scope(Some("ws://localhost:3000"), "http://localhost:3000").unwrap();
|
||||
// Trailing-slash and whitespace tolerance mirrors relay_http_base_url.
|
||||
assert_expected_relay_scope(
|
||||
Some(" wss://tenant-a.example/ "),
|
||||
"https://tenant-a.example/",
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn changed_scope_fails_closed() {
|
||||
let error =
|
||||
assert_expected_relay_scope(Some("wss://tenant-a.example"), "https://tenant-b.example")
|
||||
.unwrap_err();
|
||||
assert!(error.contains("active community changed"), "{error}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn absent_scope_preserves_unscoped_sends() {
|
||||
assert_expected_relay_scope(None, "https://anything.example").unwrap();
|
||||
assert_expected_relay_scope(Some(""), "https://anything.example").unwrap();
|
||||
assert_expected_relay_scope(Some(" "), "https://anything.example").unwrap();
|
||||
}
|
||||
}
|
||||
@@ -77,28 +77,35 @@ pub async fn submit_event(
|
||||
submit_event_at_with_keys(builder, state, &api_base_url, &keys).await
|
||||
}
|
||||
|
||||
/// Like [`submit_event`], but also returns the signed event's `created_at`.
|
||||
/// Sign, submit to an explicit HTTP API base URL, and also return the signed
|
||||
/// event's `created_at`.
|
||||
///
|
||||
/// Callers that persist a timestamp as an event cursor (e.g. the Projects
|
||||
/// conversation opener) need the signed event's own second — a
|
||||
/// post-publication clock read can land a second later and permanently
|
||||
/// exclude other events stamped in the event's real second.
|
||||
pub async fn submit_event_with_created_at(
|
||||
///
|
||||
/// The explicit base (rather than a re-read of the workspace override at
|
||||
/// submit time) matters for the same callers: they validated a tenant scope
|
||||
/// against the resolved base earlier in the same command, and re-resolving
|
||||
/// here would reopen the window where a workspace switch retargets the event
|
||||
/// after the check passed.
|
||||
pub async fn submit_event_at_created_at(
|
||||
builder: nostr::EventBuilder,
|
||||
state: &AppState,
|
||||
api_base_url: &str,
|
||||
) -> Result<(SubmitEventResponse, i64), String> {
|
||||
let api_base_url = relay_api_base_url_with_override(state);
|
||||
let keys = state.signing_keys()?;
|
||||
let event = builder
|
||||
.sign_with_keys(&keys)
|
||||
.map_err(|e| format!("failed to sign event: {e}"))?;
|
||||
let created_at = event.created_at.as_secs() as i64;
|
||||
let result = submit_signed_event_at_with_keys(&event, state, &api_base_url, &keys).await?;
|
||||
let result = submit_signed_event_at_with_keys(&event, state, api_base_url, &keys).await?;
|
||||
Ok((result, created_at))
|
||||
}
|
||||
|
||||
/// Like `submit_event_with_keys`, but also returns the signed event's
|
||||
/// `created_at` — same cursor rationale as [`submit_event_with_created_at`].
|
||||
/// `created_at` — same cursor rationale as [`submit_event_at_created_at`].
|
||||
pub async fn submit_event_with_keys_created_at(
|
||||
builder: nostr::EventBuilder,
|
||||
state: &AppState,
|
||||
|
||||
@@ -27,11 +27,11 @@ import type {
|
||||
Channel,
|
||||
ChannelDetail,
|
||||
CreateChannelInput,
|
||||
OpenDmInput,
|
||||
SetChannelPurposeInput,
|
||||
SetChannelTopicInput,
|
||||
UpdateChannelInput,
|
||||
} from "@/shared/api/types";
|
||||
import type { OpenDmInput } from "@/shared/api/tauriChannels";
|
||||
import { useIdentityQuery } from "@/shared/api/hooks";
|
||||
import { useFocusedRefetchInterval } from "@/shared/lib/useDocumentVisible";
|
||||
import { useCommunities } from "@/features/communities/useCommunities";
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
isAtOrAfterConversationOpener,
|
||||
mergeProjectAgentConversationEvents,
|
||||
restoreProjectsAgentConversation,
|
||||
submitProjectAgentMessage,
|
||||
visibleConversationMessages,
|
||||
} from "./projectAgentConversation.ts";
|
||||
import {
|
||||
@@ -287,3 +288,157 @@ test("storage round-trips opener-anchored pointers and clears them", () => {
|
||||
clearStoredProjectsAgentConversation(WORKSPACE_ID);
|
||||
assert.equal(readStoredProjectsAgentConversation(WORKSPACE_ID), null);
|
||||
});
|
||||
|
||||
// ── submitProjectAgentMessage ───────────────────────────────────────────────
|
||||
|
||||
/** Models the backend's fail-closed scope check: commands resolve the active
|
||||
* relay when they run and reject when a caller-captured scope no longer
|
||||
* matches. `active` is mutable so tests can switch communities mid-flight. */
|
||||
function makeScopedBackend(active) {
|
||||
const state = { active, dmOpens: [], sends: [] };
|
||||
const assertScope = (expectedRelayUrl) => {
|
||||
if (expectedRelayUrl !== undefined && expectedRelayUrl !== state.active) {
|
||||
throw new Error(
|
||||
"active community changed before the message was submitted; not sent",
|
||||
);
|
||||
}
|
||||
};
|
||||
return {
|
||||
state,
|
||||
openDm: async (input) => {
|
||||
assertScope(input.expectedRelayUrl);
|
||||
state.dmOpens.push({ relay: state.active, input });
|
||||
return { id: `dm-on-${state.active}` };
|
||||
},
|
||||
send: async (request) => {
|
||||
assertScope(request.expectedRelayUrl);
|
||||
state.sends.push({ relay: state.active, request });
|
||||
return { eventId: `f${"0".repeat(63)}`, createdAt: PROMPT_AT };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test("a community switch during agent startup publishes nothing to either tenant", async () => {
|
||||
const backend = makeScopedBackend("wss://tenant-a.example");
|
||||
let releaseStart;
|
||||
const startGate = new Promise((resolve) => {
|
||||
releaseStart = resolve;
|
||||
});
|
||||
|
||||
const pending = submitProjectAgentMessage({
|
||||
agent: { pubkey: AGENT_PUBKEY, isManaged: true, isActive: false },
|
||||
conversation: null,
|
||||
content: "tenant A repo context",
|
||||
mentionPubkeys: [AGENT_PUBKEY],
|
||||
relayScope: "wss://tenant-a.example",
|
||||
startAgent: () => startGate,
|
||||
openDm: backend.openDm,
|
||||
send: backend.send,
|
||||
});
|
||||
|
||||
// The user switches communities while the callback is suspended on the
|
||||
// managed-agent startup await. Remounting removed the panel, but this
|
||||
// callback keeps running.
|
||||
backend.state.active = "wss://tenant-b.example";
|
||||
releaseStart();
|
||||
|
||||
await assert.rejects(pending, /active community changed/);
|
||||
assert.deepEqual(backend.state.dmOpens, []);
|
||||
assert.deepEqual(backend.state.sends, []);
|
||||
});
|
||||
|
||||
test("a community switch during the DM open fails the send closed", async () => {
|
||||
const backend = makeScopedBackend("wss://tenant-a.example");
|
||||
const scopedOpenDm = backend.openDm;
|
||||
const pending = submitProjectAgentMessage({
|
||||
agent: { pubkey: AGENT_PUBKEY, isManaged: false, isActive: true },
|
||||
conversation: null,
|
||||
content: "tenant A repo context",
|
||||
mentionPubkeys: [AGENT_PUBKEY],
|
||||
relayScope: "wss://tenant-a.example",
|
||||
startAgent: () => {
|
||||
throw new Error("inactive relay agents are not startable");
|
||||
},
|
||||
openDm: async (input) => {
|
||||
const channel = await scopedOpenDm(input);
|
||||
// The switch lands after the DM was opened on tenant A but before the
|
||||
// message submit — the narrowest window Carl's finding names.
|
||||
backend.state.active = "wss://tenant-b.example";
|
||||
return channel;
|
||||
},
|
||||
send: backend.send,
|
||||
});
|
||||
|
||||
await assert.rejects(pending, /active community changed/);
|
||||
// The DM was legitimately opened while tenant A was still active…
|
||||
assert.equal(backend.state.dmOpens.length, 1);
|
||||
assert.equal(backend.state.dmOpens[0].relay, "wss://tenant-a.example");
|
||||
// …but nothing was ever published anywhere.
|
||||
assert.deepEqual(backend.state.sends, []);
|
||||
});
|
||||
|
||||
test("the captured scope rides every relay side effect of a first send", async () => {
|
||||
const backend = makeScopedBackend("wss://tenant-a.example");
|
||||
const result = await submitProjectAgentMessage({
|
||||
agent: { pubkey: AGENT_PUBKEY, isManaged: false, isActive: true },
|
||||
conversation: null,
|
||||
content: "opener",
|
||||
mentionPubkeys: [AGENT_PUBKEY],
|
||||
relayScope: "wss://tenant-a.example",
|
||||
startAgent: async () => {},
|
||||
openDm: backend.openDm,
|
||||
send: backend.send,
|
||||
});
|
||||
|
||||
assert.equal(
|
||||
backend.state.dmOpens[0].input.expectedRelayUrl,
|
||||
"wss://tenant-a.example",
|
||||
);
|
||||
assert.equal(
|
||||
backend.state.sends[0].request.expectedRelayUrl,
|
||||
"wss://tenant-a.example",
|
||||
);
|
||||
// The opener is a thread root: no parent reference.
|
||||
assert.equal(backend.state.sends[0].request.parentEventId, undefined);
|
||||
assert.equal(result.channel.id, "dm-on-wss://tenant-a.example");
|
||||
});
|
||||
|
||||
test("follow-ups reply to the opener so same-second id ordering cannot hide them", async () => {
|
||||
const backend = makeScopedBackend("wss://tenant-a.example");
|
||||
await submitProjectAgentMessage({
|
||||
agent: { pubkey: AGENT_PUBKEY, isManaged: false, isActive: true },
|
||||
conversation: { channel: EXISTING_DM, opener: OPENER },
|
||||
content: "follow-up in the opener's second",
|
||||
mentionPubkeys: [AGENT_PUBKEY],
|
||||
relayScope: "wss://tenant-a.example",
|
||||
startAgent: async () => {},
|
||||
openDm: () => {
|
||||
throw new Error("an existing conversation must reuse its channel");
|
||||
},
|
||||
send: backend.send,
|
||||
});
|
||||
|
||||
const request = backend.state.sends[0].request;
|
||||
assert.equal(request.channelId, EXISTING_DM.id);
|
||||
assert.equal(request.parentEventId, OPENER.eventId);
|
||||
|
||||
// Carl's exact-head probe: a same-second follow-up whose random id lands on
|
||||
// the rejected side of the id tiebreak (`e… > d…`). As an unreferenced root
|
||||
// it would vanish; as the reply the submit path now sends, it is admitted.
|
||||
const rejectedSideId = `e${"0".repeat(63)}`;
|
||||
const asUnreferencedRoot = {
|
||||
created_at: OPENER.createdAt,
|
||||
id: rejectedSideId,
|
||||
tags: [],
|
||||
};
|
||||
assert.equal(
|
||||
isAtOrAfterConversationOpener(asUnreferencedRoot, OPENER),
|
||||
false,
|
||||
);
|
||||
const asSentReply = {
|
||||
created_at: OPENER.createdAt,
|
||||
id: rejectedSideId,
|
||||
tags: [["e", OPENER.eventId, "", "reply"]],
|
||||
};
|
||||
assert.equal(isAtOrAfterConversationOpener(asSentReply, OPENER), true);
|
||||
});
|
||||
|
||||
@@ -118,3 +118,75 @@ export function mergeProjectAgentConversationEvents<
|
||||
).values(),
|
||||
].sort((left, right) => left.created_at - right.created_at);
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs the Projects agent submit sequence with every relay side effect bound
|
||||
* to the tenant scope the caller captured before the first await.
|
||||
*
|
||||
* The sequence suspends twice (managed-agent startup, DM open) and a
|
||||
* community switch during either suspension does not cancel this callback —
|
||||
* remounting only removes the UI. Binding is therefore delegated to the
|
||||
* scoped APIs themselves: `openDm` and `send` receive `expectedRelayUrl`
|
||||
* and the backing commands fail closed when the active community no longer
|
||||
* matches, so a stale callback can neither open a DM in the new tenant nor
|
||||
* publish the captured tenant's context to it. This function never re-reads
|
||||
* the active scope after capture — doing so would race the very switch it
|
||||
* guards against.
|
||||
*
|
||||
* Follow-up sends carry `parentEventId = opener.eventId`: a follow-up signed
|
||||
* in the opener's own second gets a random event id, and roughly half of
|
||||
* those sort on the rejected side of the same-second id tiebreak in
|
||||
* `isAtOrAfterConversationOpener`. The causal reply reference — which the
|
||||
* comparator always admits — is what keeps an immediate follow-up visible;
|
||||
* id luck cannot.
|
||||
*/
|
||||
export async function submitProjectAgentMessage<Ch extends { id: string }>({
|
||||
agent,
|
||||
conversation,
|
||||
content,
|
||||
mentionPubkeys,
|
||||
mediaTags,
|
||||
relayScope,
|
||||
startAgent,
|
||||
openDm,
|
||||
send,
|
||||
}: {
|
||||
agent: { pubkey: string; isManaged: boolean; isActive: boolean };
|
||||
conversation: { channel: Ch; opener: ProjectsConversationOpener } | null;
|
||||
content: string;
|
||||
mentionPubkeys: string[];
|
||||
mediaTags?: string[][];
|
||||
/** Community relay captured before the first await; null when the
|
||||
* community has no relay identity (no tenant boundary to protect). */
|
||||
relayScope: string | null;
|
||||
startAgent: (agentPubkey: string) => Promise<unknown>;
|
||||
openDm: (input: {
|
||||
pubkeys: string[];
|
||||
expectedRelayUrl?: string;
|
||||
}) => Promise<Ch>;
|
||||
send: (request: {
|
||||
channelId: string;
|
||||
content: string;
|
||||
mentionPubkeys: string[];
|
||||
mediaTags?: string[][];
|
||||
parentEventId?: string;
|
||||
expectedRelayUrl?: string;
|
||||
}) => Promise<{ eventId: string; createdAt: number }>;
|
||||
}): Promise<{ channel: Ch; sent: { eventId: string; createdAt: number } }> {
|
||||
const expectedRelayUrl = relayScope ?? undefined;
|
||||
if (agent.isManaged && !agent.isActive) {
|
||||
await startAgent(agent.pubkey);
|
||||
}
|
||||
const channel =
|
||||
conversation?.channel ??
|
||||
(await openDm({ pubkeys: [agent.pubkey], expectedRelayUrl }));
|
||||
const sent = await send({
|
||||
channelId: channel.id,
|
||||
content,
|
||||
mentionPubkeys,
|
||||
mediaTags,
|
||||
parentEventId: conversation?.opener.eventId,
|
||||
expectedRelayUrl,
|
||||
});
|
||||
return { channel, sent };
|
||||
}
|
||||
|
||||
@@ -9,7 +9,10 @@ import { normalizeRelayUrl } from "@/features/communities/communityStorage";
|
||||
import { useCommunities } from "@/features/communities/useCommunities";
|
||||
import type { ProjectDetailAgentContext } from "@/features/projects/lib/projectDetailAgentContext";
|
||||
import { projectDetailAgentContextBlock } from "@/features/projects/lib/projectDetailAgentContext";
|
||||
import { restoreProjectsAgentConversation } from "@/features/projects/lib/projectAgentConversation";
|
||||
import {
|
||||
restoreProjectsAgentConversation,
|
||||
submitProjectAgentMessage,
|
||||
} from "@/features/projects/lib/projectAgentConversation";
|
||||
import {
|
||||
clearStoredProjectsAgentConversation,
|
||||
type ProjectsConversationOpener,
|
||||
@@ -20,7 +23,7 @@ import {
|
||||
import { MessageComposer } from "@/features/messages/ui/MessageComposer";
|
||||
import { useProfileQuery, useUsersBatchQuery } from "@/features/profile/hooks";
|
||||
import { useIdentityQuery } from "@/shared/api/hooks";
|
||||
import { sendChannelMessage } from "@/shared/api/tauri";
|
||||
import { sendChannelMessage } from "@/shared/api/tauriMessages";
|
||||
import type { Channel } from "@/shared/api/types";
|
||||
import { normalizePubkey } from "@/shared/lib/pubkey";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
@@ -126,21 +129,37 @@ export function ProjectAgentChatPanel({
|
||||
if (!trimmed || !selectedAgent || isSending) return;
|
||||
setIsSending(true);
|
||||
try {
|
||||
if (selectedAgent.isManaged && !selectedAgent.isActive) {
|
||||
await startAgentMutation.mutateAsync(selectedAgent.pubkey);
|
||||
}
|
||||
const channel =
|
||||
conversation?.channel ??
|
||||
(await openDmMutation.mutateAsync({
|
||||
pubkeys: [selectedAgent.pubkey],
|
||||
}));
|
||||
const sent = await sendChannelMessage(
|
||||
channel.id,
|
||||
`${trimmed}${contextPayload}`,
|
||||
undefined,
|
||||
// The awaits below suspend across a possible community switch;
|
||||
// `submitProjectAgentMessage` binds every relay side effect to the
|
||||
// scope captured here (fail closed), and threads follow-ups onto the
|
||||
// opener so a same-second follow-up cannot be hidden by id ordering.
|
||||
const { channel, sent } = await submitProjectAgentMessage({
|
||||
agent: selectedAgent,
|
||||
conversation,
|
||||
content: `${trimmed}${contextPayload}`,
|
||||
mentionPubkeys: [
|
||||
...new Set([...mentionPubkeys, selectedAgent.pubkey]),
|
||||
],
|
||||
mediaTags,
|
||||
[...new Set([...mentionPubkeys, selectedAgent.pubkey])],
|
||||
);
|
||||
relayScope,
|
||||
startAgent: (agentPubkey) =>
|
||||
startAgentMutation.mutateAsync(agentPubkey),
|
||||
openDm: (input) => openDmMutation.mutateAsync(input),
|
||||
send: (request) =>
|
||||
sendChannelMessage(
|
||||
request.channelId,
|
||||
request.content,
|
||||
request.parentEventId,
|
||||
request.mediaTags,
|
||||
request.mentionPubkeys,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
request.expectedRelayUrl,
|
||||
),
|
||||
});
|
||||
if (!conversation) {
|
||||
// Anchor the conversation to the exact accepted opener event: a
|
||||
// bare timestamp cannot isolate it from unrelated same-second DM
|
||||
@@ -176,6 +195,7 @@ export function ProjectAgentChatPanel({
|
||||
conversation,
|
||||
isSending,
|
||||
openDmMutation,
|
||||
relayScope,
|
||||
selectedAgent,
|
||||
startAgentMutation,
|
||||
storageScope,
|
||||
|
||||
@@ -21,6 +21,8 @@ import {
|
||||
} from "@/features/agents/lib/agentAutocompleteEligibility";
|
||||
import { isManagedAgentActive } from "@/features/agents/lib/managedAgentControlActions";
|
||||
import { useChannelsQuery, useOpenDmMutation } from "@/features/channels/hooks";
|
||||
import { normalizeRelayUrl } from "@/features/communities/communityStorage";
|
||||
import { useCommunities } from "@/features/communities/useCommunities";
|
||||
import {
|
||||
useChannelMessagesQuery,
|
||||
useChannelSubscription,
|
||||
@@ -48,6 +50,7 @@ import {
|
||||
isAtOrAfterConversationOpener,
|
||||
mergeProjectAgentConversationEvents,
|
||||
restoreProjectsAgentConversation,
|
||||
submitProjectAgentMessage,
|
||||
} from "@/features/projects/lib/projectAgentConversation";
|
||||
import {
|
||||
clearStoredProjectsAgentConversation,
|
||||
@@ -57,7 +60,7 @@ import {
|
||||
writeStoredProjectsAgentConversation,
|
||||
} from "@/features/projects/lib/projectAgentConversationStorage";
|
||||
import { useIdentityQuery } from "@/shared/api/hooks";
|
||||
import { sendChannelMessage } from "@/shared/api/tauri";
|
||||
import { sendChannelMessage } from "@/shared/api/tauriMessages";
|
||||
import type { Channel } from "@/shared/api/types";
|
||||
import {
|
||||
KIND_STREAM_MESSAGE,
|
||||
@@ -385,6 +388,14 @@ export function ProjectsAgentPromptPage({
|
||||
const channelsQuery = useChannelsQuery();
|
||||
const openDmMutation = useOpenDmMutation();
|
||||
const startAgentMutation = useStartManagedAgentMutation();
|
||||
const { activeCommunity } = useCommunities();
|
||||
// Tenant scope for the submit sequence below: captured per render, so the
|
||||
// value the callback closes over is the community that was active when the
|
||||
// user pressed Ask — the backing commands fail closed if it changes while
|
||||
// the callback is suspended.
|
||||
const relayScope = activeCommunity?.relayUrl
|
||||
? normalizeRelayUrl(activeCommunity.relayUrl)
|
||||
: null;
|
||||
|
||||
const candidatePubkeys = React.useMemo(
|
||||
() => candidates.map((candidate) => candidate.pubkey),
|
||||
@@ -468,25 +479,39 @@ export function ProjectsAgentPromptPage({
|
||||
|
||||
setIsSending(true);
|
||||
try {
|
||||
if (selectedAgent.isManaged && !selectedAgent.isActive) {
|
||||
await startAgentMutation.mutateAsync(selectedAgent.pubkey);
|
||||
}
|
||||
const channel =
|
||||
conversation?.channel ??
|
||||
(await openDmMutation.mutateAsync({
|
||||
pubkeys: [selectedAgent.pubkey],
|
||||
}));
|
||||
// Repo context rides only on the conversation opener.
|
||||
const content = conversation
|
||||
? trimmed
|
||||
: `${trimmed}${repoContextPayload}`;
|
||||
const sent = await sendChannelMessage(
|
||||
channel.id,
|
||||
// The awaits inside suspend across a possible community switch;
|
||||
// `submitProjectAgentMessage` binds every relay side effect to the
|
||||
// scope captured at render (fail closed) and threads follow-ups onto
|
||||
// the opener so a same-second follow-up cannot be hidden by id
|
||||
// ordering.
|
||||
const { channel, sent } = await submitProjectAgentMessage({
|
||||
agent: selectedAgent,
|
||||
conversation,
|
||||
content,
|
||||
undefined,
|
||||
undefined,
|
||||
[selectedAgent.pubkey],
|
||||
);
|
||||
mentionPubkeys: [selectedAgent.pubkey],
|
||||
relayScope,
|
||||
startAgent: (agentPubkey) =>
|
||||
startAgentMutation.mutateAsync(agentPubkey),
|
||||
openDm: (input) => openDmMutation.mutateAsync(input),
|
||||
send: (request) =>
|
||||
sendChannelMessage(
|
||||
request.channelId,
|
||||
request.content,
|
||||
request.parentEventId,
|
||||
undefined,
|
||||
request.mentionPubkeys,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
request.expectedRelayUrl,
|
||||
),
|
||||
});
|
||||
if (!conversation) {
|
||||
// Anchor the conversation to the exact accepted opener event: a bare
|
||||
// timestamp cannot isolate it from unrelated same-second DM history.
|
||||
@@ -521,6 +546,7 @@ export function ProjectsAgentPromptPage({
|
||||
conversation,
|
||||
isSending,
|
||||
openDmMutation,
|
||||
relayScope,
|
||||
repoContextPayload,
|
||||
richText.clearContent,
|
||||
richText.getMarkdown,
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
fromRawInstallRuntimeResult,
|
||||
type RawInstallRuntimeResult,
|
||||
} from "@/shared/api/installTypes";
|
||||
import type { RawSendChannelMessageResult } from "@/shared/api/tauriMessageTypes";
|
||||
import type {
|
||||
AddChannelMembersInput,
|
||||
AddChannelMembersResult,
|
||||
@@ -26,7 +25,6 @@ import type {
|
||||
RelayEvent,
|
||||
SearchMessagesInput,
|
||||
SearchMessagesResponse,
|
||||
SendChannelMessageResult,
|
||||
SetCanvasInput,
|
||||
SetCanvasResult,
|
||||
ThreadCursor,
|
||||
@@ -44,6 +42,7 @@ import type {
|
||||
} from "@/shared/api/types";
|
||||
|
||||
export * from "@/shared/api/tauriChannels";
|
||||
export { sendChannelMessage } from "@/shared/api/tauriMessages";
|
||||
|
||||
type RawPresenceLookup = Record<string, PresenceStatus>;
|
||||
|
||||
@@ -533,42 +532,6 @@ export async function getThreadReplies(
|
||||
};
|
||||
}
|
||||
|
||||
export async function sendChannelMessage(
|
||||
channelId: string,
|
||||
content: string,
|
||||
parentEventId?: string | null,
|
||||
mediaTags?: string[][],
|
||||
mentionPubkeys?: string[],
|
||||
kind?: number,
|
||||
emojiTags?: string[][],
|
||||
mentionTags?: string[][],
|
||||
linkPreviewTags?: string[][],
|
||||
sentFromThreadTag?: string[],
|
||||
): Promise<SendChannelMessageResult> {
|
||||
const response = await invokeTauri<RawSendChannelMessageResult>(
|
||||
"send_channel_message",
|
||||
{
|
||||
channelId,
|
||||
content,
|
||||
parentEventId,
|
||||
mediaTags: mediaTags ?? null,
|
||||
emojiTags: emojiTags ?? null,
|
||||
mentionTags: mentionTags ?? null,
|
||||
linkPreviewTags,
|
||||
sentFromThreadTag: sentFromThreadTag ?? null,
|
||||
mentionPubkeys: mentionPubkeys ?? null,
|
||||
kind: kind ?? null,
|
||||
},
|
||||
);
|
||||
return {
|
||||
eventId: response.event_id,
|
||||
parentEventId: response.parent_event_id,
|
||||
rootEventId: response.root_event_id,
|
||||
depth: response.depth,
|
||||
createdAt: response.created_at,
|
||||
};
|
||||
}
|
||||
|
||||
export type BlobDescriptor = {
|
||||
url: string;
|
||||
sha256: string;
|
||||
|
||||
@@ -6,7 +6,6 @@ import type {
|
||||
ChannelPageCursor,
|
||||
ChannelType,
|
||||
CreateChannelInput,
|
||||
OpenDmInput,
|
||||
SetChannelPurposeInput,
|
||||
SetChannelTopicInput,
|
||||
UpdateChannelInput,
|
||||
@@ -156,6 +155,17 @@ export async function ensureStarterChannels(): Promise<Channel[]> {
|
||||
);
|
||||
}
|
||||
|
||||
export type OpenDmInput = {
|
||||
pubkeys: string[];
|
||||
/**
|
||||
* Tenant scope captured by the caller before its first await (community
|
||||
* relay URL). The backend fails closed when the active community no longer
|
||||
* matches, so a suspended callback can never open a DM in the wrong
|
||||
* community. Omit for callers without a tenant boundary.
|
||||
*/
|
||||
expectedRelayUrl?: string;
|
||||
};
|
||||
|
||||
export async function openDm(input: OpenDmInput): Promise<Channel> {
|
||||
return fromRawChannel(await invokeTauri<RawChannel>("open_dm", input));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { invokeTauri } from "@/shared/api/tauri";
|
||||
import type { RawSendChannelMessageResult } from "@/shared/api/tauriMessageTypes";
|
||||
import type { SendChannelMessageResult } from "@/shared/api/types";
|
||||
|
||||
export async function sendChannelMessage(
|
||||
channelId: string,
|
||||
content: string,
|
||||
parentEventId?: string | null,
|
||||
mediaTags?: string[][],
|
||||
mentionPubkeys?: string[],
|
||||
kind?: number,
|
||||
emojiTags?: string[][],
|
||||
mentionTags?: string[][],
|
||||
linkPreviewTags?: string[][],
|
||||
sentFromThreadTag?: string[],
|
||||
expectedRelayUrl?: string,
|
||||
): Promise<SendChannelMessageResult> {
|
||||
const response = await invokeTauri<RawSendChannelMessageResult>(
|
||||
"send_channel_message",
|
||||
{
|
||||
channelId,
|
||||
content,
|
||||
parentEventId,
|
||||
mediaTags: mediaTags ?? null,
|
||||
emojiTags: emojiTags ?? null,
|
||||
mentionTags: mentionTags ?? null,
|
||||
linkPreviewTags,
|
||||
sentFromThreadTag: sentFromThreadTag ?? null,
|
||||
mentionPubkeys: mentionPubkeys ?? null,
|
||||
kind: kind ?? null,
|
||||
// Tenant scope captured by the caller before its first await; the
|
||||
// backend fails closed when the active community no longer matches.
|
||||
expectedRelayUrl: expectedRelayUrl ?? null,
|
||||
},
|
||||
);
|
||||
return {
|
||||
eventId: response.event_id,
|
||||
parentEventId: response.parent_event_id,
|
||||
rootEventId: response.root_event_id,
|
||||
depth: response.depth,
|
||||
createdAt: response.created_at,
|
||||
};
|
||||
}
|
||||
@@ -50,10 +50,6 @@ export type CreateChannelInput = {
|
||||
ttlSeconds?: number;
|
||||
};
|
||||
|
||||
export type OpenDmInput = {
|
||||
pubkeys: string[];
|
||||
};
|
||||
|
||||
export type UpdateChannelInput = {
|
||||
channelId: string;
|
||||
name?: string;
|
||||
|
||||
@@ -3786,6 +3786,51 @@ function getRelayWsUrl(config: E2eConfig | undefined): string {
|
||||
return config?.relayWsUrl ?? DEFAULT_RELAY_WS_URL;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirror of the backend's `assert_expected_relay_scope`: a caller-captured
|
||||
* tenant scope must still match the active community when the command runs.
|
||||
* The mock's "active relay" is the active community's relayUrl in
|
||||
* localStorage (specs switch communities by rewriting it), falling back to
|
||||
* the configured mock relay. Lets specs drive the mid-flight community
|
||||
* switch with `openDmDelayMs` / `sendMessageDelayMs` and prove the send
|
||||
* fails closed.
|
||||
*/
|
||||
function assertExpectedRelayScope(
|
||||
expectedRelayUrl: string | null | undefined,
|
||||
config: E2eConfig | undefined,
|
||||
): void {
|
||||
const expected = expectedRelayUrl?.trim();
|
||||
if (!expected) return;
|
||||
let active: string | null = null;
|
||||
try {
|
||||
const activeId = window.localStorage.getItem("buzz-active-community-id");
|
||||
const communities = JSON.parse(
|
||||
window.localStorage.getItem("buzz-communities") ?? "[]",
|
||||
) as { id: string; relayUrl: string }[];
|
||||
active =
|
||||
communities.find((community) => community.id === activeId)?.relayUrl ??
|
||||
null;
|
||||
} catch {
|
||||
active = null;
|
||||
}
|
||||
if (
|
||||
normalizeMockRelayUrl(active ?? getRelayWsUrl(config)) !==
|
||||
normalizeMockRelayUrl(expected)
|
||||
) {
|
||||
throw new Error(
|
||||
"active community changed before the message was submitted; not sent",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Same ws(s) normalization the app applies to community relay URLs. */
|
||||
function normalizeMockRelayUrl(url: string): string {
|
||||
if (!url.startsWith("ws://") && !url.startsWith("wss://")) {
|
||||
return `wss://${url}`;
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
function getIdentity(config: E2eConfig | undefined): TestIdentity | undefined {
|
||||
if (!isRelayMode(config)) {
|
||||
return undefined;
|
||||
@@ -6492,6 +6537,7 @@ async function handleCreateChannel(
|
||||
async function handleOpenDm(
|
||||
args: {
|
||||
pubkeys: string[];
|
||||
expectedRelayUrl?: string | null;
|
||||
},
|
||||
config: E2eConfig | undefined,
|
||||
) {
|
||||
@@ -6499,6 +6545,9 @@ async function handleOpenDm(
|
||||
if (delayMs > 0) {
|
||||
await new Promise((resolve) => window.setTimeout(resolve, delayMs));
|
||||
}
|
||||
// After the injected delay, like the real command's post-await check: a
|
||||
// caller-captured tenant scope must still match the active community.
|
||||
assertExpectedRelayScope(args.expectedRelayUrl, config);
|
||||
|
||||
const normalizedPubkeys = normalizeParticipantPubkeys(args.pubkeys);
|
||||
if (normalizedPubkeys.length === 0) {
|
||||
@@ -9160,6 +9209,7 @@ async function handleSendChannelMessage(
|
||||
linkPreviewTags?: string[][] | null;
|
||||
sentFromThreadTag?: string[] | null;
|
||||
suppressLinkPreviews?: boolean;
|
||||
expectedRelayUrl?: string | null;
|
||||
},
|
||||
config: E2eConfig | undefined,
|
||||
): Promise<RawSendChannelMessageResponse> {
|
||||
@@ -9170,6 +9220,9 @@ async function handleSendChannelMessage(
|
||||
window.setTimeout(resolve, sendMessageDelayMs),
|
||||
);
|
||||
}
|
||||
// After the injected delay, like the real command's post-await check: a
|
||||
// caller-captured tenant scope must still match the active community.
|
||||
assertExpectedRelayScope(args.expectedRelayUrl, config);
|
||||
|
||||
// Mirror the WebSocket send path's failure injection so specs that route
|
||||
// the first message through the acknowledged HTTP transport still exercise
|
||||
|
||||
Reference in New Issue
Block a user