Files
buzz/desktop/src/shared/api/tauriChannels.ts
Wintermute a86f9e2d85 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>
2026-08-17 00:44:54 -04:00

275 lines
7.9 KiB
TypeScript

import type {
Channel,
ChannelDetail,
ChannelMember,
ChannelMessagesPageResponse,
ChannelPageCursor,
ChannelType,
CreateChannelInput,
SetChannelPurposeInput,
SetChannelTopicInput,
UpdateChannelInput,
} from "@/shared/api/types";
import { invokeTauri } from "@/shared/api/tauri";
export type RawChannel = {
id: string;
name: string;
channel_type: ChannelType;
visibility: "open" | "private";
description: string;
topic: string | null;
purpose: string | null;
member_count: number;
member_pubkeys: string[];
last_message_at: string | null;
archived_at: string | null;
participants: string[];
participant_pubkeys: string[];
is_member?: boolean;
ttl_seconds: number | null;
ttl_deadline: string | null;
};
/**
* Response payload for the `get_channels` Tauri command.
*
* When `channels` is `null`, the caller's `knownHash` matched the relay
* snapshot and the expensive channel list was not re-serialized across IPC.
* `lastMessages` is always present so the caller can update sidebar
* timestamps without a full channel-list re-render.
*/
export type GetChannelsPayload = {
hash: string;
/** Full channel list, or `null` on a not-modified (hash-match) response. */
channels: Channel[] | null;
/** Map of channel id → ISO-8601 timestamp of its most recent message. */
lastMessages: Record<string, string>;
};
type RawChannelDetail = RawChannel & {
created_by: string;
created_at: string;
updated_at: string;
topic_set_by: string | null;
topic_set_at: string | null;
purpose_set_by: string | null;
purpose_set_at: string | null;
topic_required: boolean;
max_members: number | null;
nip29_group_id: string | null;
};
type RawChannelMember = {
pubkey: string;
role: ChannelMember["role"];
is_agent?: boolean;
joined_at: string;
display_name: string | null;
};
type RawChannelMembersResponse = {
members: RawChannelMember[];
next_cursor: string | null;
};
export function fromRawChannel(channel: RawChannel): Channel {
return {
id: channel.id,
name: channel.name,
channelType: channel.channel_type,
visibility: channel.visibility,
description: channel.description,
topic: channel.topic,
purpose: channel.purpose,
memberCount: channel.member_count,
memberPubkeys: channel.member_pubkeys ?? [],
lastMessageAt: channel.last_message_at,
archivedAt: channel.archived_at,
participants: channel.participants,
participantPubkeys: channel.participant_pubkeys,
isMember: channel.is_member ?? true,
ttlSeconds: channel.ttl_seconds,
ttlDeadline: channel.ttl_deadline,
};
}
export function fromRawChannelDetail(channel: RawChannelDetail): ChannelDetail {
return {
...fromRawChannel(channel),
createdBy: channel.created_by,
createdAt: channel.created_at,
updatedAt: channel.updated_at,
topicSetBy: channel.topic_set_by,
topicSetAt: channel.topic_set_at,
purposeSetBy: channel.purpose_set_by,
purposeSetAt: channel.purpose_set_at,
topicRequired: channel.topic_required,
maxMembers: channel.max_members,
nip29GroupId: channel.nip29_group_id,
};
}
function fromRawChannelMember(member: RawChannelMember): ChannelMember {
return {
pubkey: member.pubkey,
role: member.role,
isAgent: member.is_agent ?? false,
joinedAt: member.joined_at,
displayName: member.display_name,
};
}
/**
* Fetch the channel list from the backend.
*
* Pass `knownHash` from a previous response to enable the not-modified
* short-circuit: when the relay snapshot is unchanged, `channels` in the
* returned payload will be `null` so the multi-MB list is not deserialized.
* Pass `null` to always request the full list.
*/
export async function getChannels(
knownHash: string | null,
): Promise<GetChannelsPayload> {
const raw = await invokeTauri<{
hash: string;
channels: RawChannel[] | null;
last_messages: Record<string, string>;
}>("get_channels", { knownHash });
return {
hash: raw.hash,
channels: raw.channels !== null ? raw.channels.map(fromRawChannel) : null,
lastMessages: raw.last_messages,
};
}
export async function createChannel(
input: CreateChannelInput,
): Promise<Channel> {
return fromRawChannel(await invokeTauri<RawChannel>("create_channel", input));
}
export async function ensureStarterChannels(): Promise<Channel[]> {
return (await invokeTauri<RawChannel[]>("ensure_starter_channels")).map(
fromRawChannel,
);
}
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));
}
export async function hideDm(channelId: string): Promise<void> {
await invokeTauri<void>("hide_dm", { channelId });
}
export async function getChannelDetails(
channelId: string,
): Promise<ChannelDetail> {
const detail = await invokeTauri<RawChannelDetail>("get_channel_details", {
channelId,
});
return fromRawChannelDetail(detail);
}
export async function updateChannel(
input: UpdateChannelInput,
): Promise<ChannelDetail> {
const channel = await invokeTauri<RawChannelDetail>("update_channel", {
input,
});
return fromRawChannelDetail(channel);
}
export async function setChannelTopic(
input: SetChannelTopicInput,
): Promise<void> {
await invokeTauri("set_channel_topic", input);
}
export async function setChannelPurpose(
input: SetChannelPurposeInput,
): Promise<void> {
await invokeTauri("set_channel_purpose", input);
}
export async function archiveChannel(channelId: string): Promise<void> {
await invokeTauri("archive_channel", { channelId });
}
export async function unarchiveChannel(channelId: string): Promise<void> {
await invokeTauri("unarchive_channel", { channelId });
}
export async function deleteChannel(channelId: string): Promise<void> {
await invokeTauri("delete_channel", { channelId });
}
type RawChannelMessagesPageResponse = {
events: ChannelMessagesPageResponse["events"];
next_cursor: { created_at: number; event_id: string } | null;
};
/**
* Fetch one keyset page of top-level channel history strictly older than a
* cursor, via the bridge composite `(createdAt, eventId)` cursor.
*
* The desktop timeline pages history over WS `REQ` with a bare `until`
* (`createdAt`) cursor, which cannot advance past a `createdAt` second denser
* than one page. This is the escape hatch: `beforeId` is the id of the oldest
* event already loaded at `before`, and the relay returns strictly-older rows
* (`created_at < before OR (created_at = before AND id > beforeId)`). Pass the
* returned `nextCursor` back to page further; `nextCursor` is null once a short
* page proves history is exhausted.
*/
export async function getChannelMessagesBefore(
channelId: string,
cursor: ChannelPageCursor,
limit?: number,
): Promise<ChannelMessagesPageResponse> {
const response = await invokeTauri<RawChannelMessagesPageResponse>(
"get_channel_messages_before",
{
channelId,
before: cursor.createdAt,
beforeId: cursor.eventId,
limit: limit ?? null,
},
);
return {
events: response.events,
nextCursor: response.next_cursor
? {
createdAt: response.next_cursor.created_at,
eventId: response.next_cursor.event_id,
}
: null,
};
}
export async function getChannelMembers(
channelId: string,
): Promise<ChannelMember[]> {
const response = await invokeTauri<RawChannelMembersResponse>(
"get_channel_members",
{ channelId },
);
return response.members.map(fromRawChannelMember);
}
export async function joinChannel(channelId: string): Promise<void> {
await invokeTauri<void>("join_channel", { channelId });
}