Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 84 additions & 0 deletions desktop/src-tauri/src/commands/channels.rs
Original file line number Diff line number Diff line change
Expand Up @@ -465,6 +465,36 @@ fn parse_channel_uuid(channel_id: &str) -> Result<uuid::Uuid, String> {
uuid::Uuid::parse_str(channel_id).map_err(|_| format!("invalid channel UUID: {channel_id}"))
}

fn build_external_agent_auth_tag(
owner_keys: &nostr::Keys,
members: &ChannelMembersResponse,
agent_pubkey: &str,
) -> Result<String, String> {
let owner_pubkey = owner_keys.public_key().to_hex();
let owner_is_channel_owner = members
.members
.iter()
.any(|member| member.pubkey.eq_ignore_ascii_case(&owner_pubkey) && member.role == "owner");
if !owner_is_channel_owner {
return Err("only a channel owner can authorize an external agent".to_string());
}

let agent = nostr::PublicKey::from_hex(agent_pubkey)
.map_err(|e| format!("invalid agent pubkey: {e}"))?;
let normalized_agent_pubkey = agent.to_hex();
let agent_is_bot_member = members.members.iter().any(|member| {
member.pubkey.eq_ignore_ascii_case(&normalized_agent_pubkey)
&& member.role == "bot"
&& member.is_agent
});
if !agent_is_bot_member {
return Err("the external agent must be a bot member of this channel".to_string());
}

buzz_sdk_pkg::nip_oa::compute_auth_tag(owner_keys, &agent, "")
.map_err(|e| format!("failed to authorize external agent: {e}"))
}

fn normalize_channel_name(name: &str) -> String {
name.trim().to_ascii_lowercase()
}
Expand Down Expand Up @@ -848,6 +878,60 @@ pub async fn change_channel_member_role(
Ok(())
}

/// Create an owner-reviewed NIP-OA credential for an existing external bot.
///
/// The backend repeats the UI policy checks before signing: the current
/// identity must be a channel owner, the target must be an agent with the bot
/// role in that channel, and a valid target profile must not already name an
/// owner. The credential is returned to the caller but never persisted by
/// Desktop.
#[tauri::command]
pub async fn authorize_external_agent(
channel_id: String,
agent_pubkey: String,
state: State<'_, AppState>,
) -> Result<String, String> {
let channel_uuid = parse_channel_uuid(&channel_id)?;
let normalized_channel_id = channel_uuid.to_string();
let agent = nostr::PublicKey::from_hex(&agent_pubkey)
.map_err(|e| format!("invalid agent pubkey: {e}"))?;
let normalized_agent_pubkey = agent.to_hex();

let member_events = query_relay(
&state,
&[serde_json::json!({
"kinds": [39002],
"#d": [normalized_channel_id],
"limit": 1
})],
)
.await?;
let members = member_events
.first()
.map(nostr_convert::channel_members_from_event)
.transpose()?
.ok_or_else(|| "channel members not found".to_string())?;

let profile_events = query_relay(
&state,
&[serde_json::json!({
"kinds": [0],
"authors": [normalized_agent_pubkey],
"limit": 1
})],
)
.await?;
if profile_events
.first()
.is_some_and(nostr_convert::profile_has_valid_oa_owner)
{
return Err("this external agent already has a valid owner authorization".to_string());
}

let owner_keys = state.signing_keys()?;
build_external_agent_auth_tag(&owner_keys, &members, &agent.to_hex())
}

#[tauri::command]
pub async fn join_channel(channel_id: String, state: State<'_, AppState>) -> Result<(), String> {
let uuid = parse_channel_uuid(&channel_id)?;
Expand Down
67 changes: 67 additions & 0 deletions desktop/src-tauri/src/commands/channels_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,73 @@ const PK_A: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
const PK_B: &str = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
const PK_C: &str = "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc";

fn member(pubkey: String, role: &str, is_agent: bool) -> crate::models::ChannelMemberInfo {
crate::models::ChannelMemberInfo {
pubkey,
role: role.to_string(),
is_agent,
joined_at: None,
display_name: None,
}
}

fn members(rows: Vec<crate::models::ChannelMemberInfo>) -> ChannelMembersResponse {
ChannelMembersResponse {
members: rows,
next_cursor: None,
}
}

#[test]
fn external_agent_auth_requires_owner_and_bot_membership() {
let owner = Keys::generate();
let agent = Keys::generate();
let channel_members = members(vec![
member(owner.public_key().to_hex(), "owner", false),
member(agent.public_key().to_hex(), "bot", true),
]);

let auth_tag =
build_external_agent_auth_tag(&owner, &channel_members, &agent.public_key().to_hex())
.expect("channel owner should authorize bot member");
let recovered_owner = buzz_sdk_pkg::nip_oa::verify_auth_tag(&auth_tag, &agent.public_key())
.expect("authorization must verify");

assert_eq!(recovered_owner, owner.public_key());
}

#[test]
fn external_agent_auth_rejects_non_owner_signer() {
let signer = Keys::generate();
let agent = Keys::generate();
let channel_members = members(vec![
member(signer.public_key().to_hex(), "admin", false),
member(agent.public_key().to_hex(), "bot", true),
]);

let error =
build_external_agent_auth_tag(&signer, &channel_members, &agent.public_key().to_hex())
.expect_err("admin must not mint owner authorization");

assert!(error.contains("only a channel owner"));
}

#[test]
fn external_agent_auth_rejects_non_bot_target() {
let owner = Keys::generate();
let target = Keys::generate();
let channel_members = members(vec![
member(owner.public_key().to_hex(), "owner", false),
member(target.public_key().to_hex(), "member", false),
]);

let error =
build_external_agent_auth_tag(&owner, &channel_members, &target.public_key().to_hex())
.expect_err("human member must not receive an agent authorization");

assert!(error.contains("must be a bot member"));
}

#[test]
fn directory_cursor_keeps_same_second_tiebreaker() {
let timestamp = Timestamp::from(1_700_000_000);
Expand Down
1 change: 1 addition & 0 deletions desktop/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -744,6 +744,7 @@ pub fn run() {
add_channel_members,
remove_channel_member,
change_channel_member_role,
authorize_external_agent,
join_channel,
leave_channel,
get_canvas,
Expand Down
15 changes: 15 additions & 0 deletions desktop/src/features/channels/api/externalAgentAuthorization.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { invoke as tauriInvoke } from "@tauri-apps/api/core";

export async function authorizeExternalAgent(
channelId: string,
agentPubkey: string,
): Promise<string> {
try {
return await tauriInvoke<string>("authorize_external_agent", {
channelId,
agentPubkey,
});
} catch (error) {
throw error instanceof Error ? error : new Error(String(error));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/shared/ui/alert-dialog";
import { Button } from "@/shared/ui/button";

type ExternalAgentAuthorizationDialogProps = {
agentLabel: string;
error: unknown;
isPending: boolean;
onConfirm: () => void;
onOpenChange: (open: boolean) => void;
open: boolean;
};

export function ExternalAgentAuthorizationDialog({
agentLabel,
error,
isPending,
onConfirm,
onOpenChange,
open,
}: ExternalAgentAuthorizationDialogProps) {
return (
<AlertDialog onOpenChange={onOpenChange} open={open}>
<AlertDialogContent data-testid="external-agent-authorization-dialog">
<AlertDialogHeader>
<AlertDialogTitle>Authorize external agent?</AlertDialogTitle>
<AlertDialogDescription>
Only continue if you control and trust {agentLabel}. Buzz will sign
an owner authorization that lets this agent use your relay
membership and identifies it as managed by you. Your private key
stays on this device; only the resulting authorization is copied.
</AlertDialogDescription>
</AlertDialogHeader>
{error instanceof Error ? (
<p className="text-sm text-destructive">{error.message}</p>
) : null}
<AlertDialogFooter>
<AlertDialogCancel asChild>
<Button disabled={isPending} type="button" variant="outline">
Cancel
</Button>
</AlertDialogCancel>
<AlertDialogAction asChild>
<Button
data-testid="external-agent-authorization-confirm"
disabled={isPending}
onClick={(event) => {
event.preventDefault();
onConfirm();
}}
type="button"
>
{isPending ? "Authorizing..." : "Authorize and copy"}
</Button>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}
28 changes: 27 additions & 1 deletion desktop/src/features/channels/ui/MembersSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,8 @@ import {
managedAgentPairAction,
} from "@/features/agents/managedAgentRuntimeStatus";
import { EditRespondToDialog } from "./EditRespondToDialog";
import { ExternalAgentAuthorizationDialog } from "./ExternalAgentAuthorizationDialog";
import { useExternalAgentAuthorization } from "./useExternalAgentAuthorization";
import { useMembersSidebarActions } from "./useMembersSidebarActions";
import { useMembersSidebarModeration } from "./useMembersSidebarModeration";
const MEMBER_ADD_RESULT_LIMIT = 50;
Expand Down Expand Up @@ -520,6 +522,7 @@ export function MembersSidebar({

const [editRespondToAgent, setEditRespondToAgent] =
React.useState<ManagedAgent | null>(null);
const externalAgentAuthorization = useExternalAgentAuthorization(channelId);

React.useEffect(() => {
if (!open) {
Expand Down Expand Up @@ -598,6 +601,13 @@ export function MembersSidebar({
const managedAgent = memberIsBot
? managedAgentByPubkey.get(normalizePubkey(member.pubkey))
: undefined;
const canAuthorizeExternalAgent =
selfMember?.role === "owner" &&
memberIsBot &&
!managedAgent &&
memberProfilesQuery.isSuccess &&
!memberProfile?.ownerPubkey &&
member.pubkey !== currentPubkey;
const managedAgentRuntime =
memberIsBot && relayUrl
? findManagedAgentRuntime(
Expand All @@ -616,13 +626,15 @@ export function MembersSidebar({
return (
<div className="content-visibility-auto" key={member.pubkey}>
<MembersSidebarMemberCard
canAuthorizeExternalAgent={canAuthorizeExternalAgent}
canChangeRole={canManageMembers && member.pubkey !== currentPubkey}
canModerate={canModerate && member.pubkey !== currentPubkey}
canRemoveMember={canRemoveMember(member)}
isActionPending={
isActionPending ||
changeRoleMutation.isPending ||
isModerationPending
isModerationPending ||
externalAgentAuthorization.isPending
}
isArchived={isArchived}
managedAgent={managedAgent}
Expand All @@ -636,6 +648,12 @@ export function MembersSidebar({
moderationState={moderationStateByPubkey.get(
normalizePubkey(member.pubkey),
)}
onAuthorizeExternalAgent={(targetMember) => {
externalAgentAuthorization.open({
label: formatMemberName(targetMember, currentPubkey),
member: targetMember,
});
}}
onBan={onBan}
onChangeRole={(m, role) => {
void changeRoleMutation.mutateAsync({ pubkey: m.pubkey, role });
Expand Down Expand Up @@ -871,6 +889,14 @@ export function MembersSidebar({
}}
open={editRespondToAgent !== null}
/>
<ExternalAgentAuthorizationDialog
agentLabel={externalAgentAuthorization.target?.label ?? "this agent"}
error={externalAgentAuthorization.error}
isPending={externalAgentAuthorization.isPending}
onConfirm={externalAgentAuthorization.onConfirm}
onOpenChange={externalAgentAuthorization.onOpenChange}
open={externalAgentAuthorization.target !== null}
/>
</>
);
}
Expand Down
Loading