diff --git a/VISION.md b/VISION.md
index 900e5a9475..66a106bdeb 100644
--- a/VISION.md
+++ b/VISION.md
@@ -39,7 +39,7 @@ The relay enforces all access control. Channel membership is the only gate.
| Type | Visibility | Join | Create |
|------|-----------|------|--------|
| **Open channels** | Searchable by all members | Self-join | Any member |
-| **Private channels** | Hidden, invite-only | Invited by member | Any member |
+| **Private channels** | Hidden, invite-only | Invited by an owner/admin | Any member |
| **DMs** | Participants only | N/A (up to 9) | Any member |
| **Guests** | Scoped to specific channels | Invited | N/A |
diff --git a/crates/buzz-db/src/channel.rs b/crates/buzz-db/src/channel.rs
index 5508c95cad..9d15fccfc8 100644
--- a/crates/buzz-db/src/channel.rs
+++ b/crates/buzz-db/src/channel.rs
@@ -371,7 +371,9 @@ async fn acquire_channel_membership_lock(
/// Role enforcement:
/// - Open channels: `invited_by` is optional; role is forced to `Member` regardless of
/// what the caller passes — callers cannot self-assign elevated roles.
-/// - Private channels: requires an `invited_by` who is an active owner/admin.
+/// - Private channels: requires an `invited_by` who is an active owner/admin, the channel
+/// creator bootstrapping their own first membership, or the target adding themselves
+/// (idempotent re-add — an active member's *role* still cannot change this way).
/// - Elevated roles (`Owner`, `Admin`) may only be granted by an existing owner/admin,
/// even on open channels.
///
@@ -419,10 +421,14 @@ pub async fn add_member(
DbError::InvalidData(format!("invalid role in database: {inviter_role_str}"))
})?;
- // Any member can invite others, but only owners/admins may grant elevated roles.
- if role.is_elevated() && !inviter_role.is_elevated() {
+ // Only owners/admins may extend private-channel access to another
+ // identity. `inviter == pubkey` keeps a member's own idempotent
+ // re-add working; it is not a role-escalation hole, because the
+ // active-role-change guard below still rejects a self-targeted
+ // promotion from any non-elevated caller.
+ if !inviter_role.is_elevated() && inviter != pubkey {
return Err(DbError::AccessDenied(
- "only owners/admins may grant elevated roles".to_string(),
+ "only owners/admins may add private-channel members".to_string(),
));
}
}
diff --git a/crates/buzz-relay/src/handlers/side_effects.rs b/crates/buzz-relay/src/handlers/side_effects.rs
index 660a55fef3..98f8a9aa84 100644
--- a/crates/buzz-relay/src/handlers/side_effects.rs
+++ b/crates/buzz-relay/src/handlers/side_effects.rs
@@ -355,28 +355,28 @@ pub async fn validate_admin_event(
.iter()
.find(|m| m.pubkey == actor_bytes)
.and_then(|m| m.role.parse().ok());
-
- // PUT_USER: open channels allow any authenticated user; private channels
- // require the actor to be an existing member (any role can invite).
- if channel.visibility == "private" {
- if actor_role.is_none() {
- return Err(anyhow::anyhow!("actor not authorized"));
- }
-
- // Only owners/admins may grant elevated roles.
- if requested_role.is_some_and(|r| r.is_elevated())
- && !actor_role.is_some_and(|r| r.is_elevated())
- {
- return Err(anyhow::anyhow!(
- "only owners/admins may grant elevated roles"
- ));
- }
- }
-
- // Extract target pubkey from p tag
let target_pubkey =
extract_p_tag(event).ok_or_else(|| anyhow::anyhow!("missing p tag"))?;
+ // PUT_USER: open channels allow any authenticated user. Private
+ // channels only let owners/admins add another identity; otherwise
+ // any compromised member could extend access to channel history.
+ //
+ // A self-targeted add skips this check so an idempotent re-add
+ // still works. That is not a way into a private channel: ingest's
+ // `check_channel_membership` rejects a non-member (and a
+ // soft-removed member) before this validator runs, and `add_member`
+ // independently requires the self-inviter to hold an active role.
+ // Self-promotion is caught by the role-change guard below.
+ if channel.visibility == "private"
+ && target_pubkey != actor_bytes
+ && !actor_role.is_some_and(|r| r.is_elevated())
+ {
+ return Err(anyhow::anyhow!(
+ "only owners/admins may add private-channel members"
+ ));
+ }
+
// Changing an ACTIVE existing member's role is privileged in both
// directions, on every visibility. `get_members` filters
// `removed_at IS NULL`, so a soft-removed row is deliberately not an
diff --git a/crates/buzz-test-client/tests/e2e_relay.rs b/crates/buzz-test-client/tests/e2e_relay.rs
index 6f59299ed2..5b9a50b5b1 100644
--- a/crates/buzz-test-client/tests/e2e_relay.rs
+++ b/crates/buzz-test-client/tests/e2e_relay.rs
@@ -2201,6 +2201,10 @@ async fn create_private_channel_ws(client: &mut BuzzTestClient, keys: &Keys) ->
}
/// Submit a kind:9000 PUT_USER event over WebSocket.
+///
+/// `allow_self_tagging` keeps self-targeted adds working: EventBuilder otherwise
+/// drops a `p` tag matching the signer (nostr-0.44.3 builder.rs:435-449) and the
+/// event fails as "missing p tag" instead of exercising the authority check.
async fn add_member_ws(
client: &mut BuzzTestClient,
channel_id: &str,
@@ -2210,6 +2214,7 @@ async fn add_member_ws(
let h_tag = Tag::parse(["h", channel_id]).unwrap();
let p_tag = Tag::parse(["p", target_pubkey_hex]).unwrap();
let event = EventBuilder::new(Kind::Custom(9000), "")
+ .allow_self_tagging()
.tags([h_tag, p_tag])
.sign_with_keys(signer)
.unwrap();
@@ -2219,6 +2224,8 @@ async fn add_member_ws(
}
/// Submit a kind:9000 PUT_USER event with a role tag over WebSocket.
+///
+/// See [`add_member_ws`] for why `allow_self_tagging` is required.
async fn add_member_with_role_ws(
client: &mut BuzzTestClient,
channel_id: &str,
@@ -2230,6 +2237,7 @@ async fn add_member_with_role_ws(
let p_tag = Tag::parse(["p", target_pubkey_hex]).unwrap();
let role_tag = Tag::parse(["role", role]).unwrap();
let event = EventBuilder::new(Kind::Custom(9000), "")
+ .allow_self_tagging()
.tags([h_tag, p_tag, role_tag])
.sign_with_keys(signer)
.unwrap();
@@ -2241,10 +2249,10 @@ async fn add_member_with_role_ws(
(ok.accepted, ok.message)
}
-/// Any member of a private channel can invite another user (Slack model).
+/// Only owners/admins can add another identity to a private channel.
#[tokio::test]
#[ignore]
-async fn test_private_channel_any_member_can_invite() {
+async fn test_private_channel_member_cannot_invite() {
let url = relay_url();
let owner_keys = Keys::generate();
let member_keys = Keys::generate();
@@ -2271,7 +2279,7 @@ async fn test_private_channel_any_member_can_invite() {
.await
.expect("connect as member");
- // Regular member invites a third user — this should succeed.
+ // Regular member tries to invite a third user.
let (accepted, msg) = add_member_ws(
&mut member_client,
&channel_id,
@@ -2279,15 +2287,77 @@ async fn test_private_channel_any_member_can_invite() {
&member_keys,
)
.await;
+ assert!(
+ !accepted,
+ "regular member must not add another private-channel identity: {msg}"
+ );
+ assert!(
+ msg.contains("owners/admins"),
+ "rejection should name the owner/admin requirement, got: {msg}"
+ );
+
+ // The same member re-adding *themselves* stays idempotent — the huddle
+ // bot-add and kind:9021 paths depend on a self-targeted PUT_USER working.
+ let (accepted, msg) = add_member_ws(
+ &mut member_client,
+ &channel_id,
+ &member_keys.public_key().to_hex(),
+ &member_keys,
+ )
+ .await;
assert!(
accepted,
- "regular member should be able to invite to private channel, got: {msg}"
+ "self-targeted re-add must stay idempotent, got: {msg}"
);
owner_client.disconnect().await.expect("disconnect owner");
member_client.disconnect().await.expect("disconnect member");
}
+/// An admin — not just the owner — can still add to a private channel.
+#[tokio::test]
+#[ignore]
+async fn test_private_channel_admin_can_invite() {
+ let url = relay_url();
+ let owner_keys = Keys::generate();
+ let admin_keys = Keys::generate();
+ let invitee_keys = Keys::generate();
+
+ let mut owner_client = BuzzTestClient::connect(&url, &owner_keys)
+ .await
+ .expect("connect as owner");
+ let channel_id = create_private_channel_ws(&mut owner_client, &owner_keys).await;
+
+ let (accepted, msg) = add_member_with_role_ws(
+ &mut owner_client,
+ &channel_id,
+ &admin_keys.public_key().to_hex(),
+ "admin",
+ &owner_keys,
+ )
+ .await;
+ assert!(accepted, "owner should add an admin, got: {msg}");
+
+ let mut admin_client = BuzzTestClient::connect(&url, &admin_keys)
+ .await
+ .expect("connect as admin");
+
+ let (accepted, msg) = add_member_ws(
+ &mut admin_client,
+ &channel_id,
+ &invitee_keys.public_key().to_hex(),
+ &admin_keys,
+ )
+ .await;
+ assert!(
+ accepted,
+ "admin should be able to add to a private channel, got: {msg}"
+ );
+
+ owner_client.disconnect().await.expect("disconnect owner");
+ admin_client.disconnect().await.expect("disconnect admin");
+}
+
/// A non-member cannot invite someone to a private channel.
#[tokio::test]
#[ignore]
diff --git a/desktop/src/features/channels/hooks.ts b/desktop/src/features/channels/hooks.ts
index 8829edab39..51297b915b 100644
--- a/desktop/src/features/channels/hooks.ts
+++ b/desktop/src/features/channels/hooks.ts
@@ -32,7 +32,9 @@ import type {
SetChannelTopicInput,
UpdateChannelInput,
} from "@/shared/api/types";
+import { useIdentityQuery } from "@/shared/api/hooks";
import { useCommunities } from "@/features/communities/useCommunities";
+import { canAddChannelMembers } from "@/features/channels/lib/channelMemberAdmission";
import {
readChannelSnapshot,
writeChannelSnapshot,
@@ -501,6 +503,32 @@ export function useDeleteChannelMutation(channelId: string | null) {
});
}
+/**
+ * Whether the signed-in identity may add *another* identity to this channel,
+ * per {@link canAddChannelMembers}. Both queries are the ones the channel UI
+ * already holds, so this shares their cache rather than fetching again.
+ */
+export function useCanAddChannelMembers(channelId: string | null) {
+ const channelsQuery = useChannelsQuery();
+ const membersQuery = useChannelMembersQuery(channelId);
+ const identityQuery = useIdentityQuery();
+
+ const channel =
+ channelsQuery.data?.find((candidate) => candidate.id === channelId) ?? null;
+ const selfPubkey = identityQuery.data?.pubkey ?? null;
+ const selfRole = selfPubkey
+ ? (membersQuery.data?.find(
+ (member) => member.pubkey.toLowerCase() === selfPubkey.toLowerCase(),
+ )?.role ?? null)
+ : null;
+
+ return canAddChannelMembers({
+ channelType: channel?.channelType,
+ visibility: channel?.visibility,
+ selfRole,
+ });
+}
+
export function useAddChannelMembersMutation(channelId: string | null) {
const queryClient = useQueryClient();
diff --git a/desktop/src/features/channels/lib/channelMemberAdmission.test.mjs b/desktop/src/features/channels/lib/channelMemberAdmission.test.mjs
new file mode 100644
index 0000000000..0af22459f4
--- /dev/null
+++ b/desktop/src/features/channels/lib/channelMemberAdmission.test.mjs
@@ -0,0 +1,79 @@
+import { strict as assert } from "node:assert";
+import test from "node:test";
+
+import { canAddChannelMembers } from "./channelMemberAdmission.ts";
+
+test("open channels accept adds from anyone, member or not", () => {
+ assert.equal(
+ canAddChannelMembers({
+ channelType: "stream",
+ visibility: "open",
+ selfRole: null,
+ }),
+ true,
+ );
+ assert.equal(
+ canAddChannelMembers({
+ channelType: "stream",
+ visibility: "open",
+ selfRole: "member",
+ }),
+ true,
+ );
+});
+
+test("private channels accept adds only from owners/admins", () => {
+ for (const selfRole of ["owner", "admin"]) {
+ assert.equal(
+ canAddChannelMembers({
+ channelType: "stream",
+ visibility: "private",
+ selfRole,
+ }),
+ true,
+ `${selfRole} should be able to add`,
+ );
+ }
+
+ for (const selfRole of ["member", "bot", "guest", null]) {
+ assert.equal(
+ canAddChannelMembers({
+ channelType: "stream",
+ visibility: "private",
+ selfRole,
+ }),
+ false,
+ `${selfRole} must not be able to add`,
+ );
+ }
+});
+
+test("DMs never accept adds, even from an owner", () => {
+ assert.equal(
+ canAddChannelMembers({
+ channelType: "dm",
+ visibility: "private",
+ selfRole: "owner",
+ }),
+ false,
+ );
+ assert.equal(
+ canAddChannelMembers({
+ channelType: "dm",
+ visibility: "open",
+ selfRole: "owner",
+ }),
+ false,
+ );
+});
+
+test("unknown visibility fails closed for non-elevated callers", () => {
+ assert.equal(
+ canAddChannelMembers({ channelType: "stream", selfRole: "member" }),
+ false,
+ );
+ assert.equal(
+ canAddChannelMembers({ channelType: "stream", selfRole: "owner" }),
+ true,
+ );
+});
diff --git a/desktop/src/features/channels/lib/channelMemberAdmission.ts b/desktop/src/features/channels/lib/channelMemberAdmission.ts
new file mode 100644
index 0000000000..b01c6f5216
--- /dev/null
+++ b/desktop/src/features/channels/lib/channelMemberAdmission.ts
@@ -0,0 +1,36 @@
+/**
+ * Client mirror of the relay's kind:9000 authority for adding *another*
+ * identity to a channel (`validate_admin_event` + `buzz_db::channel::add_member`):
+ *
+ * - DMs: nobody — membership is fixed at creation.
+ * - Open channels: anyone, member or not.
+ * - Private channels: owners/admins only. A plain member extending access to
+ * channel history is exactly what the relay now rejects, so the affordance
+ * must not be offered.
+ *
+ * Unknown visibility fails closed — the relay is the authority and a hidden
+ * button is cheaper than an opaque rejection.
+ */
+export function canAddChannelMembers({
+ channelType,
+ visibility,
+ selfRole,
+}: {
+ channelType?: string | null;
+ visibility?: string | null;
+ selfRole?: string | null;
+}): boolean {
+ if (channelType === "dm") {
+ return false;
+ }
+
+ if (visibility === "open") {
+ return true;
+ }
+
+ return selfRole === "owner" || selfRole === "admin";
+}
+
+/** Explains a denied add so the user isn't left guessing at a missing button. */
+export const PRIVATE_CHANNEL_ADD_DENIED_MESSAGE =
+ "Only channel owners and admins can add people to a private channel.";
diff --git a/desktop/src/features/channels/ui/MembersSidebar.tsx b/desktop/src/features/channels/ui/MembersSidebar.tsx
index c6349546a2..5e9e6740c3 100644
--- a/desktop/src/features/channels/ui/MembersSidebar.tsx
+++ b/desktop/src/features/channels/ui/MembersSidebar.tsx
@@ -14,6 +14,10 @@ import {
import { useIsArchivedPredicate } from "@/features/identity-archive/hooks";
import { useClassifiedMembers } from "@/features/channels/lib/useClassifiedMembers";
import { formatMemberName } from "@/features/channels/lib/memberUtils";
+import {
+ canAddChannelMembers,
+ PRIVATE_CHANNEL_ADD_DENIED_MESSAGE,
+} from "@/features/channels/lib/channelMemberAdmission";
import {
useFlattenedUserSearchResults,
useInfiniteUserSearchQuery,
@@ -240,9 +244,18 @@ export function MembersSidebar({
() => new Set(rawMembers.map((member) => normalizePubkey(member.pubkey))),
[rawMembers],
);
- const canAddMembers =
- (selfMember !== null || channel?.visibility === "open") &&
- channel?.channelType !== "dm";
+ const canAddMembers = canAddChannelMembers({
+ channelType: channel?.channelType,
+ visibility: channel?.visibility,
+ selfRole: selfMember?.role,
+ });
+ // Distinguish "you can't add here" from "nothing to add" so a plain member of
+ // a private channel gets the reason instead of a silently missing affordance.
+ const showPrivateAddDeniedNotice =
+ !canAddMembers &&
+ selfMember !== null &&
+ channel?.channelType !== "dm" &&
+ channel?.visibility !== "open";
const userSearchQuery = useInfiniteUserSearchQuery(deferredSearchQuery, {
allowEmpty: false,
enabled:
@@ -723,6 +736,14 @@ export function MembersSidebar({
value={searchQuery}
/>
+ {showPrivateAddDeniedNotice ? (
+
+ {PRIVATE_CHANNEL_ADD_DENIED_MESSAGE}
+
+ ) : null}
diff --git a/desktop/src/features/messages/lib/dmThreadAgentMentionError.ts b/desktop/src/features/messages/lib/dmThreadAgentMentionError.ts
new file mode 100644
index 0000000000..14ba5ec4e8
--- /dev/null
+++ b/desktop/src/features/messages/lib/dmThreadAgentMentionError.ts
@@ -0,0 +1,57 @@
+import { normalizePubkey } from "@/shared/lib/pubkey";
+import type { ChannelType } from "@/shared/api/types";
+
+export const DM_THREAD_AGENT_MENTION_ERROR =
+ "Agents must already be in a DM to be mentioned in its threads. Start a new conversation that includes the agent.";
+export const DM_THREAD_MEMBERS_LOADING_ERROR =
+ "Checking conversation members. Try again in a moment.";
+
+/**
+ * Why a DM thread reply may not mention an agent, or null when it may.
+ *
+ * A DM's participant set is fixed at creation, so a thread reply can only
+ * mention agents already in it — persona mentions (which would create a new
+ * agent) are always refused.
+ */
+export function dmThreadAgentMentionError({
+ trimmed,
+ isThreadReply,
+ channelType,
+ extractMentionPersonas,
+ extractMentionPubkeys,
+ isAgentPubkey,
+ hasResolvedMembers,
+ memberPubkeys,
+}: {
+ trimmed: string;
+ isThreadReply: boolean;
+ channelType: ChannelType | null;
+ extractMentionPersonas: (text: string) => unknown[];
+ extractMentionPubkeys: (text: string) => string[];
+ isAgentPubkey: (pubkey: string) => boolean;
+ hasResolvedMembers: boolean;
+ memberPubkeys: ReadonlySet;
+}): string | null {
+ if (channelType !== "dm" || !isThreadReply) {
+ return null;
+ }
+
+ if (extractMentionPersonas(trimmed).length > 0) {
+ return DM_THREAD_AGENT_MENTION_ERROR;
+ }
+
+ const agentPubkeys = extractMentionPubkeys(trimmed).filter(isAgentPubkey);
+ if (agentPubkeys.length === 0) {
+ return null;
+ }
+
+ if (!hasResolvedMembers) {
+ return DM_THREAD_MEMBERS_LOADING_ERROR;
+ }
+
+ return agentPubkeys.some(
+ (pubkey) => !memberPubkeys.has(normalizePubkey(pubkey)),
+ )
+ ? DM_THREAD_AGENT_MENTION_ERROR
+ : null;
+}
diff --git a/desktop/src/features/messages/ui/MessageComposer.tsx b/desktop/src/features/messages/ui/MessageComposer.tsx
index 6f79daa606..58601f452f 100644
--- a/desktop/src/features/messages/ui/MessageComposer.tsx
+++ b/desktop/src/features/messages/ui/MessageComposer.tsx
@@ -1007,15 +1007,7 @@ function MessageComposerImpl({
-
+
{linkEditor.card}
{linkEditor.dialog}
diff --git a/desktop/src/features/messages/ui/NonMemberMentionDialog.tsx b/desktop/src/features/messages/ui/NonMemberMentionDialog.tsx
index 20f50924fb..c72686a642 100644
--- a/desktop/src/features/messages/ui/NonMemberMentionDialog.tsx
+++ b/desktop/src/features/messages/ui/NonMemberMentionDialog.tsx
@@ -7,8 +7,11 @@ import {
AlertDialogTitle,
} from "@/shared/ui/alert-dialog";
import { Button } from "@/shared/ui/button";
+import { PRIVATE_CHANNEL_ADD_DENIED_MESSAGE } from "@/features/channels/lib/channelMemberAdmission";
type NonMemberMentionDialogProps = {
+ /** False in a private channel the viewer doesn't own/administer. */
+ canInvite: boolean;
error: string | null;
isInvitePending: boolean;
names: string[];
@@ -19,6 +22,7 @@ type NonMemberMentionDialogProps = {
};
export function NonMemberMentionDialog({
+ canInvite,
error,
isInvitePending,
names,
@@ -43,7 +47,10 @@ export function NonMemberMentionDialog({
{names.join(", ")} {names.length === 1 ? "is" : "are"} not in this
- channel. Invite them to the channel, or send without inviting them.
+ channel.{" "}
+ {canInvite
+ ? "Invite them to the channel, or send without inviting them."
+ : `${PRIVATE_CHANNEL_ADD_DENIED_MESSAGE} You can still send without inviting them.`}
{error ? (
@@ -59,16 +66,18 @@ export function NonMemberMentionDialog({
type="button"
variant="outline"
>
- Do nothing
-
-
+ {canInvite ? (
+
+ ) : null}
diff --git a/desktop/src/features/messages/ui/useMentionSendFlow.ts b/desktop/src/features/messages/ui/useMentionSendFlow.ts
index 6ba9f69050..647ad4cbe8 100644
--- a/desktop/src/features/messages/ui/useMentionSendFlow.ts
+++ b/desktop/src/features/messages/ui/useMentionSendFlow.ts
@@ -10,7 +10,12 @@ import {
useStartManagedAgentMutation,
} from "@/features/agents/hooks";
import { resolvePersonaRuntime } from "@/features/agents/lib/resolvePersonaRuntime";
-import { useAddChannelMembersMutation } from "@/features/channels/hooks";
+import {
+ useAddChannelMembersMutation,
+ useCanAddChannelMembers,
+} from "@/features/channels/hooks";
+import { PRIVATE_CHANNEL_ADD_DENIED_MESSAGE } from "@/features/channels/lib/channelMemberAdmission";
+import { dmThreadAgentMentionError } from "@/features/messages/lib/dmThreadAgentMentionError";
import { filterEffectiveExplicitAgentPubkeys } from "@/features/messages/lib/effectiveExplicitAgentPubkeys";
import {
prepareBackgroundMediaUpload,
@@ -87,10 +92,6 @@ type UseMentionSendFlowOptions = {
}) => void;
resolvePostSendContent?: (effectiveExplicitAgentPubkeys: string[]) => string;
};
-const DM_THREAD_AGENT_MENTION_ERROR =
- "Agents must already be in a DM to be mentioned in its threads. Start a new conversation that includes the agent.";
-const DM_THREAD_MEMBERS_LOADING_ERROR =
- "Checking conversation members. Try again in a moment.";
export function useMentionSendFlow({
channelId,
channelLinks,
@@ -136,6 +137,7 @@ export function useMentionSendFlow({
};
}, []);
const addMembersMutation = useAddChannelMembersMutation(channelId);
+ const canInviteNonMembers = useCanAddChannelMembers(channelId);
const attachAgentMutation = useAttachManagedAgentToChannelMutation(channelId);
const createPersonaAgentMutation =
useCreateChannelManagedAgentMutation(channelId);
@@ -684,32 +686,17 @@ export function useMentionSendFlow({
(
trimmed: string,
capturedThreadContext: SendMessageWithMentionFlowInput["capturedThreadContext"],
- ) => {
- if (channelType !== "dm" || capturedThreadContext == null) {
- return null;
- }
-
- if (mentions.extractMentionPersonas(trimmed).length > 0) {
- return DM_THREAD_AGENT_MENTION_ERROR;
- }
-
- const agentPubkeys = mentions
- .extractMentionPubkeys(trimmed)
- .filter(mentions.isAgentPubkey);
- if (agentPubkeys.length === 0) {
- return null;
- }
-
- if (!mentions.hasResolvedMembers) {
- return DM_THREAD_MEMBERS_LOADING_ERROR;
- }
-
- return agentPubkeys.some(
- (pubkey) => !mentions.memberPubkeys.has(normalizePubkey(pubkey)),
- )
- ? DM_THREAD_AGENT_MENTION_ERROR
- : null;
- },
+ ) =>
+ dmThreadAgentMentionError({
+ trimmed,
+ isThreadReply: capturedThreadContext != null,
+ channelType,
+ extractMentionPersonas: mentions.extractMentionPersonas,
+ extractMentionPubkeys: mentions.extractMentionPubkeys,
+ isAgentPubkey: mentions.isAgentPubkey,
+ hasResolvedMembers: mentions.hasResolvedMembers,
+ memberPubkeys: mentions.memberPubkeys,
+ }),
[
channelType,
mentions.extractMentionPersonas,
@@ -889,6 +876,12 @@ export function useMentionSendFlow({
const handleInviteNonMembers = React.useCallback(() => {
if (!pendingNonMemberSend) return;
+ // The dialog hides Invite in this case; this guards the keyboard/programmatic
+ // path so we surface the reason instead of a raw relay rejection.
+ if (!canInviteNonMembers) {
+ setNonMemberPromptError(PRIVATE_CHANNEL_ADD_DENIED_MESSAGE);
+ return;
+ }
const invitedPubkeys = new Set(
pendingNonMemberSend.nonMemberPubkeys.map(normalizePubkey),
@@ -963,6 +956,7 @@ export function useMentionSendFlow({
});
}, [
addMembersMutation,
+ canInviteNonMembers,
completeSend,
getManagedAgentsByPubkey,
mentions.isAgentPubkey,
@@ -975,25 +969,29 @@ export function useMentionSendFlow({
}, []);
return {
- dismissNonMemberPrompt,
- isInvitePending:
- isMentionSendPending ||
- isCompleteSendPending ||
- addMembersMutation.isPending ||
- attachAgentMutation.isPending ||
- createPersonaAgentMutation.isPending ||
- startAgentMutation.isPending,
isPreparingMentionSend:
isMentionSendPending ||
isCompleteSendPending ||
attachAgentMutation.isPending ||
createPersonaAgentMutation.isPending ||
startAgentMutation.isPending,
- nonMemberPromptError,
- pendingNonMemberNames,
- pendingNonMemberSend,
+ /** Spread straight into `NonMemberMentionDialog`. */
+ nonMemberPromptProps: {
+ canInvite: canInviteNonMembers,
+ error: nonMemberPromptError,
+ isInvitePending:
+ isMentionSendPending ||
+ isCompleteSendPending ||
+ addMembersMutation.isPending ||
+ attachAgentMutation.isPending ||
+ createPersonaAgentMutation.isPending ||
+ startAgentMutation.isPending,
+ names: pendingNonMemberNames,
+ onDismiss: dismissNonMemberPrompt,
+ onDoNothing: handleSendWithoutInviting,
+ onInvite: handleInviteNonMembers,
+ open: pendingNonMemberSend !== null,
+ },
sendMessageWithMentionFlow,
- sendWithoutInviting: handleSendWithoutInviting,
- inviteNonMembers: handleInviteNonMembers,
};
}
diff --git a/desktop/tests/e2e/channels.spec.ts b/desktop/tests/e2e/channels.spec.ts
index 0bf65c16da..858f6ac3b9 100644
--- a/desktop/tests/e2e/channels.spec.ts
+++ b/desktop/tests/e2e/channels.spec.ts
@@ -3750,7 +3750,7 @@ test("members sidebar collapses same-persona managed agents", async ({
await expect(page.getByText("Pinky", { exact: true })).toHaveCount(1);
});
-test("private-channel members can add people and managed agents without admin", async ({
+test("private-channel members cannot add people without owner/admin", async ({
page,
}) => {
await installMockBridge(page, {
@@ -3764,14 +3764,51 @@ test("private-channel members can add people and managed agents without admin",
});
await page.goto("/");
// secret-projects is a private (non-DM) channel where the current user is a
- // plain member, not owner/admin. They should still be able to add members
- // and bots — only granting elevated roles is reserved for owners/admins.
+ // plain member. The relay rejects their kind:9000, so the affordance is
+ // withheld and the reason shown instead of failing after the fact.
await openMembersSidebar(page, "secret-projects");
- // The invite card is shown to any member, not just owners/admins.
+ await expect(page.getByTestId("members-sidebar-add-denied")).toBeVisible();
+ // The field stays, but only as a filter over existing members.
+ await expect(
+ page.getByTestId("channel-management-search-users"),
+ ).toHaveAttribute("placeholder", "Search people and agents");
+
+ await page.getByTestId("channel-management-search-users").fill("char");
+ await expect(page.getByText("Not in this channel")).toHaveCount(0);
+ await expect(
+ page.getByTestId(
+ `channel-user-search-result-${TEST_IDENTITIES.charlie.pubkey}`,
+ ),
+ ).toHaveCount(0);
+ await expect(
+ page.getByTestId(`sidebar-member-${TEST_IDENTITIES.charlie.pubkey}`),
+ ).toHaveCount(0);
+});
+
+test("open-channel members can add people and managed agents without admin", async ({
+ page,
+}) => {
+ await installMockBridge(page, {
+ managedAgents: [
+ {
+ pubkey: TEST_IDENTITIES.charlie.pubkey,
+ name: "charlie",
+ status: "stopped",
+ },
+ ],
+ });
+ await page.goto("/");
+ // random is open and the current user is a plain member there, so the
+ // owner/admin requirement must not leak outside private channels.
+ await openMembersSidebar(page, "random");
+
+ // The invite card is shown to any member of an open channel, not just
+ // owners/admins.
await expect(
page.getByTestId("channel-management-search-users"),
).toBeVisible();
+ await expect(page.getByTestId("members-sidebar-add-denied")).toHaveCount(0);
await page.getByTestId("channel-management-search-users").fill("char");
await page
.getByTestId(`channel-user-search-result-${TEST_IDENTITIES.charlie.pubkey}`)
diff --git a/mobile/lib/features/channels/channel.dart b/mobile/lib/features/channels/channel.dart
index 3104e90335..29db1f96c5 100644
--- a/mobile/lib/features/channels/channel.dart
+++ b/mobile/lib/features/channels/channel.dart
@@ -2,6 +2,11 @@ import 'package:flutter/foundation.dart';
const Object _sentinel = Object();
+/// Shown when a private-channel add is refused, so a missing Invite action
+/// reads as a rule rather than a bug.
+const privateChannelAddDeniedMessage =
+ 'Only channel owners and admins can add people to a private channel.';
+
@immutable
class Channel {
final String id;
@@ -77,6 +82,17 @@ class Channel {
bool get isForum => channelType == 'forum';
bool get isDm => channelType == 'dm';
bool get isPrivate => visibility == 'private';
+
+ /// Whether [selfRole] may add *another* identity here, mirroring the relay's
+ /// kind:9000 authority (`validate_admin_event` + `add_member`): DMs never,
+ /// open channels always, private channels owners/admins only. An unknown
+ /// visibility fails closed — the relay is the authority.
+ bool canAddMembers(String? selfRole) {
+ if (isDm) return false;
+ if (visibility == 'open') return true;
+ return selfRole == 'owner' || selfRole == 'admin';
+ }
+
bool get isArchived => archivedAt != null;
String displayLabel({String? currentPubkey}) {
diff --git a/mobile/lib/features/channels/channel_management_provider.dart b/mobile/lib/features/channels/channel_management_provider.dart
index 286a931db1..7f9615d4ab 100644
--- a/mobile/lib/features/channels/channel_management_provider.dart
+++ b/mobile/lib/features/channels/channel_management_provider.dart
@@ -13,6 +13,31 @@ import '../profile/profile_provider.dart';
import 'channel.dart';
import 'channels_provider.dart';
+String _relayErrorMessage(Object error) =>
+ error.toString().replaceFirst('Exception: ', '');
+
+/// Raised when one or more kind:9000 adds were rejected, keyed by pubkey.
+///
+/// Callers surface [message] to the user — a relay rejection here (e.g. a plain
+/// member trying to add someone to a private channel) is a real outcome, not a
+/// crash to swallow.
+@immutable
+class AddMembersException implements Exception {
+ final Map failures;
+
+ const AddMembersException(this.failures);
+
+ String get message => failures.entries
+ .map(
+ (entry) =>
+ '${entry.key.length > 8 ? '${entry.key.substring(0, 8)}…' : entry.key}: ${entry.value}',
+ )
+ .join('; ');
+
+ @override
+ String toString() => 'AddMembersException($message)';
+}
+
@immutable
class ChannelMember {
final String pubkey;
@@ -565,21 +590,34 @@ class ChannelActions {
if (pubkey.trim().isNotEmpty) pubkey.trim().toLowerCase(),
};
_ensureCommunityValid();
+ // Per-pubkey failures are collected rather than thrown on the spot: one
+ // relay rejection must not skip the remaining adds or the invalidation
+ // below, which would leave the members list stale for the adds that landed.
+ final failures = {};
for (final pubkey in normalizedPubkeys) {
+ // Outside the catch: a community switch mid-loop must abort the whole
+ // add, not be recorded as this pubkey's rejection.
_ensureCommunityValid();
- await _signedEventRelay.submit(
- kind: 9000,
- content: '',
- tags: [
- ['h', channelId],
- ['p', pubkey],
- ['role', normalizedRole],
- ],
- );
+ try {
+ await _signedEventRelay.submit(
+ kind: 9000,
+ content: '',
+ tags: [
+ ['h', channelId],
+ ['p', pubkey],
+ ['role', normalizedRole],
+ ],
+ );
+ } catch (error) {
+ failures[pubkey] = _relayErrorMessage(error);
+ }
}
_ensureCommunityValid();
_ref.invalidate(channelMembersProvider(channelId));
_ref.invalidate(channelBotPubkeysProvider(channelId));
+ if (failures.isNotEmpty) {
+ throw AddMembersException(failures);
+ }
}
void _ensureCommunityValid() {
diff --git a/mobile/lib/features/channels/compose_bar/compose_bar_widget.dart b/mobile/lib/features/channels/compose_bar/compose_bar_widget.dart
index 3cac205abc..99af48d44b 100644
--- a/mobile/lib/features/channels/compose_bar/compose_bar_widget.dart
+++ b/mobile/lib/features/channels/compose_bar/compose_bar_widget.dart
@@ -416,79 +416,37 @@ class ComposeBar extends HookConsumerWidget {
for (final entry in mentionMap.value.entries)
if (hasMention(text, entry.key)) entry.value,
];
- final pubkeys = LinkedHashSet.from(
- selectedMentions.map((candidate) => candidate.pubkey.toLowerCase()),
- ).toList();
- final nonMemberAgentPubkeys = [];
- final nonMemberHumans = [];
- if (selectedMentions.isNotEmpty) {
- final currentChannel = (await ref.read(
- channelsProvider.future,
- )).firstWhere((channel) => channel.id == channelId);
- if (!currentChannel.isDm) {
- final memberPubkeys = (await ref.read(
- channelMembersProvider(channelId).future,
- )).map((member) => member.pubkey.toLowerCase()).toSet();
- final seenNonMembers = {};
- for (final candidate in selectedMentions) {
- final pk = candidate.pubkey.toLowerCase();
- if (memberPubkeys.contains(pk)) continue;
- if (!seenNonMembers.add(pk)) continue;
- if (candidate.isAgent) {
- nonMemberAgentPubkeys.add(pk);
- } else {
- nonMemberHumans.add(candidate);
- }
- }
- }
- }
+ final outgoing = _OutgoingMentions(selectedMentions);
+ final scan = await _scanNonMemberMentions(
+ ref,
+ channelId: channelId,
+ selectedMentions: selectedMentions,
+ currentPubkey: currentPubkey,
+ );
// Mentioning humans outside the channel prompts "Invite" / "Do
// nothing" (send without inviting) — mirrors desktop's
// NonMemberMentionDialog. Agents keep the existing silent auto-add.
- var mentionPubkeys = pubkeys;
- final referenceMentionTags = >[];
- var inviteHumanPubkeys = const [];
- if (nonMemberHumans.isNotEmpty) {
+ if (scan.humans.isNotEmpty) {
if (!context.mounted) return;
final choice = await _promptNonMemberMention(
context,
- names: [for (final candidate in nonMemberHumans) candidate.label],
+ names: [for (final candidate in scan.humans) candidate.label],
+ canInvite: scan.canAddMembers,
);
- switch (choice) {
- case null:
- return; // Dismissed — keep the draft, send nothing.
- case _NonMemberMentionChoice.invite:
- inviteHumanPubkeys = [
- for (final candidate in nonMemberHumans)
- candidate.pubkey.toLowerCase(),
- ];
- case _NonMemberMentionChoice.sendWithoutInviting:
- // Strip their p-tags (no channel notification) but keep a
- // `mention` reference tag so their name still renders —
- // mirrors desktop's mergeOutgoingTagsWithReferenceMentions.
- final excluded = {
- for (final candidate in nonMemberHumans)
- candidate.pubkey.toLowerCase(),
- };
- mentionPubkeys = [
- for (final pk in pubkeys)
- if (!excluded.contains(pk)) pk,
- ];
- referenceMentionTags.addAll([
- for (final pk in excluded) ['mention', pk],
- ]);
- }
+ if (choice == null) return; // Dismissed — keep the draft, send nothing.
+ outgoing.resolveHumanChoice(choice, scan.humans);
}
final queuedAttachments = List<_PendingAttachment>.of(attachments.value);
final channelActions = ref.read(channelActionsProvider);
- Future addMentionedNonMembers() => _addMentionedNonMembers(
+ // An add that was refused doesn't block the message: it is reported and
+ // the un-added mentions are demoted to reference tags so the send lands.
+ Future addMentionedNonMembers() => outgoing.addNonMembers(
channelActions,
- channelId: channelId,
- agentPubkeys: nonMemberAgentPubkeys,
- humanPubkeys: inviteHumanPubkeys,
+ scan: scan,
+ messenger: messenger,
);
isSending.value = true;
@@ -503,12 +461,19 @@ class ComposeBar extends HookConsumerWidget {
);
await onSend(
payload.content,
- mentionPubkeys,
- mediaTags: [...payload.mediaTags, ...referenceMentionTags],
+ outgoing.pubkeys,
+ mediaTags: [...payload.mediaTags, ...outgoing.referenceTags],
);
if (context.mounted) clearComposer();
} on StateError {
_reportSendCancelledByCommunitySwitch(messenger);
+ } catch (error) {
+ // send() runs unawaited, so a relay rejection or publish timeout
+ // would otherwise vanish with the composer looking idle. The draft
+ // is kept (clearComposer never ran) so the user can retry.
+ messenger?.showSnackBar(
+ SnackBar(content: Text(_composeSendErrorMessage(error))),
+ );
}
return;
}
@@ -561,8 +526,8 @@ class ComposeBar extends HookConsumerWidget {
if (queueGeneration != uploadGeneration.value) return;
await delivery(
payload.content,
- mentionPubkeys,
- mediaTags: [...payload.mediaTags, ...referenceMentionTags],
+ outgoing.pubkeys,
+ mediaTags: [...payload.mediaTags, ...outgoing.referenceTags],
);
} catch (error) {
if (cancellation.isCancelled) return;
diff --git a/mobile/lib/features/channels/compose_bar/helpers.dart b/mobile/lib/features/channels/compose_bar/helpers.dart
index c630accdff..65d236d1ad 100644
--- a/mobile/lib/features/channels/compose_bar/helpers.dart
+++ b/mobile/lib/features/channels/compose_bar/helpers.dart
@@ -143,11 +143,21 @@ bool hasMention(String text, String name) {
/// cancels the send and keeps the draft.
enum _NonMemberMentionChoice { invite, sendWithoutInviting }
+/// User-facing text for a failed add or send.
+String _composeSendErrorMessage(Object error) {
+ if (error is AddMembersException) return error.message;
+ return error.toString().replaceFirst('Exception: ', '');
+}
+
/// Ask whether to invite mentioned humans who aren't channel members, or
/// send without inviting them. Mirrors desktop's `NonMemberMentionDialog`.
+/// [canInvite] false (a private channel the sender doesn't own/administer)
+/// drops the Invite action — the relay rejects that add, so offering it would
+/// only produce an error.
Future<_NonMemberMentionChoice?> _promptNonMemberMention(
BuildContext context, {
required List names,
+ required bool canInvite,
}) {
final verb = names.length == 1 ? 'is' : 'are';
return showDialog<_NonMemberMentionChoice>(
@@ -155,21 +165,26 @@ Future<_NonMemberMentionChoice?> _promptNonMemberMention(
builder: (dialogContext) => AlertDialog(
title: const Text('Mention people outside this channel?'),
content: Text(
- '${names.join(', ')} $verb not in this channel. Invite them to '
- 'the channel, or send without inviting them.',
+ canInvite
+ ? '${names.join(', ')} $verb not in this channel. Invite them to '
+ 'the channel, or send without inviting them.'
+ : '${names.join(', ')} $verb not in this channel. '
+ '$privateChannelAddDeniedMessage You can still send without '
+ 'inviting them.',
),
actions: [
TextButton(
onPressed: () => Navigator.of(
dialogContext,
).pop(_NonMemberMentionChoice.sendWithoutInviting),
- child: const Text('Do nothing'),
- ),
- TextButton(
- onPressed: () =>
- Navigator.of(dialogContext).pop(_NonMemberMentionChoice.invite),
- child: const Text('Invite'),
+ child: Text(canInvite ? 'Do nothing' : 'Send anyway'),
),
+ if (canInvite)
+ TextButton(
+ onPressed: () =>
+ Navigator.of(dialogContext).pop(_NonMemberMentionChoice.invite),
+ child: const Text('Invite'),
+ ),
],
),
);
@@ -232,27 +247,204 @@ void _reportSendCancelledByCommunitySwitch(ScaffoldMessengerState? messenger) {
);
}
+/// What an add attempt left undone: who is still a non-member, and why.
+@immutable
+class _NonMemberAddOutcome {
+ final List notAdded;
+ final List errors;
+
+ const _NonMemberAddOutcome({required this.notAdded, required this.errors});
+
+ static const empty = _NonMemberAddOutcome(notAdded: [], errors: []);
+}
+
/// Adds mentioned non-members to the channel before a send.
///
/// Agents are added silently with the `bot` role; humans are only passed here
/// after they have been explicitly invited from the mention prompt.
-Future _addMentionedNonMembers(
+///
+/// A rejection is reported, never thrown: the send is fire-and-forget, so an
+/// escaping error would drop the message with nothing shown. [StateError] still
+/// propagates — a community switch must cancel the whole send.
+Future<_NonMemberAddOutcome> _addMentionedNonMembers(
ChannelActions channelActions, {
required String channelId,
required List agentPubkeys,
required List humanPubkeys,
+ required bool canAddMembers,
}) async {
- if (agentPubkeys.isNotEmpty) {
- await channelActions.addMembers(
- channelId: channelId,
- pubkeys: agentPubkeys,
- role: 'bot',
+ final pending = [
+ if (agentPubkeys.isNotEmpty) (agentPubkeys, 'bot'),
+ if (humanPubkeys.isNotEmpty) (humanPubkeys, 'member'),
+ ];
+ if (pending.isEmpty) return _NonMemberAddOutcome.empty;
+
+ // A plain member of a private channel cannot add anyone: skip the doomed
+ // kind:9000 rather than trading it for a relay rejection.
+ if (!canAddMembers) {
+ return _NonMemberAddOutcome(
+ notAdded: [for (final (pubkeys, _) in pending) ...pubkeys],
+ errors: const [privateChannelAddDeniedMessage],
);
}
- if (humanPubkeys.isNotEmpty) {
- await channelActions.addMembers(
- channelId: channelId,
- pubkeys: humanPubkeys,
+
+ final notAdded = [];
+ final errors = [];
+ for (final (pubkeys, role) in pending) {
+ try {
+ await channelActions.addMembers(
+ channelId: channelId,
+ pubkeys: pubkeys,
+ role: role,
+ );
+ } on StateError {
+ rethrow;
+ } catch (error) {
+ notAdded.addAll(
+ error is AddMembersException ? error.failures.keys : pubkeys,
+ );
+ errors.add(_composeSendErrorMessage(error));
+ }
+ }
+ return _NonMemberAddOutcome(notAdded: notAdded, errors: errors);
+}
+
+/// Mentioned identities that aren't in the channel yet, plus whether the sender
+/// is allowed to add them at all.
+@immutable
+class _NonMemberMentionScan {
+ final String channelId;
+ final List agentPubkeys;
+ final List humans;
+ final bool canAddMembers;
+
+ const _NonMemberMentionScan({
+ required this.channelId,
+ required this.agentPubkeys,
+ required this.humans,
+ required this.canAddMembers,
+ });
+}
+
+/// Resolves which mentioned identities are non-members, and whether this
+/// identity may add them (see [Channel.canAddMembers]). DMs are skipped: their
+/// participant set is fixed at creation.
+Future<_NonMemberMentionScan> _scanNonMemberMentions(
+ WidgetRef ref, {
+ required String channelId,
+ required List selectedMentions,
+ required String? currentPubkey,
+}) async {
+ final none = _NonMemberMentionScan(
+ channelId: channelId,
+ agentPubkeys: const [],
+ humans: const [],
+ canAddMembers: true,
+ );
+ if (selectedMentions.isEmpty) return none;
+
+ final channel = (await ref.read(
+ channelsProvider.future,
+ )).firstWhere((candidate) => candidate.id == channelId);
+ if (channel.isDm) return none;
+
+ final members = await ref.read(channelMembersProvider(channelId).future);
+ final memberPubkeys = {
+ for (final member in members) member.pubkey.toLowerCase(),
+ };
+ String? selfRole;
+ if (currentPubkey != null) {
+ final self = currentPubkey.toLowerCase();
+ for (final member in members) {
+ if (member.pubkey.toLowerCase() == self) {
+ selfRole = member.role;
+ break;
+ }
+ }
+ }
+
+ final agentPubkeys = [];
+ final humans = [];
+ final seen = {};
+ for (final candidate in selectedMentions) {
+ final pubkey = candidate.pubkey.toLowerCase();
+ if (memberPubkeys.contains(pubkey) || !seen.add(pubkey)) continue;
+ if (candidate.isAgent) {
+ agentPubkeys.add(pubkey);
+ } else {
+ humans.add(candidate);
+ }
+ }
+
+ return _NonMemberMentionScan(
+ channelId: channelId,
+ agentPubkeys: agentPubkeys,
+ humans: humans,
+ canAddMembers: channel.canAddMembers(selfRole),
+ );
+}
+
+/// The p-tags and `mention` reference tags an outgoing message should carry.
+///
+/// Anyone who ends up *not* added is demoted from a p-tag to a reference tag so
+/// their name still renders without notifying a non-member — mirrors desktop's
+/// `mergeOutgoingTagsWithReferenceMentions`.
+class _OutgoingMentions {
+ List pubkeys;
+ final List> referenceTags = [];
+ List _invitedHumanPubkeys = const [];
+
+ _OutgoingMentions(List selectedMentions)
+ : pubkeys = LinkedHashSet.from(
+ selectedMentions.map((candidate) => candidate.pubkey.toLowerCase()),
+ ).toList();
+
+ void demote(Iterable demoted) {
+ final excluded = {for (final pubkey in demoted) pubkey.toLowerCase()};
+ if (excluded.isEmpty) return;
+ pubkeys = [
+ for (final pubkey in pubkeys)
+ if (!excluded.contains(pubkey)) pubkey,
+ ];
+ referenceTags.addAll([
+ for (final pubkey in excluded) ['mention', pubkey],
+ ]);
+ }
+
+ /// Applies the mention prompt's outcome: invite them, or send without.
+ void resolveHumanChoice(
+ _NonMemberMentionChoice choice,
+ List humans,
+ ) {
+ final humanPubkeys = [
+ for (final candidate in humans) candidate.pubkey.toLowerCase(),
+ ];
+ switch (choice) {
+ case _NonMemberMentionChoice.invite:
+ _invitedHumanPubkeys = humanPubkeys;
+ case _NonMemberMentionChoice.sendWithoutInviting:
+ demote(humanPubkeys);
+ }
+ }
+
+ /// Adds the scanned non-members, demoting and reporting whatever didn't land.
+ Future addNonMembers(
+ ChannelActions channelActions, {
+ required _NonMemberMentionScan scan,
+ required ScaffoldMessengerState? messenger,
+ }) async {
+ final outcome = await _addMentionedNonMembers(
+ channelActions,
+ channelId: scan.channelId,
+ agentPubkeys: scan.agentPubkeys,
+ humanPubkeys: _invitedHumanPubkeys,
+ canAddMembers: scan.canAddMembers,
);
+ demote(outcome.notAdded);
+ if (outcome.errors.isNotEmpty) {
+ messenger?.showSnackBar(
+ SnackBar(content: Text(outcome.errors.join(' '))),
+ );
+ }
}
}
diff --git a/mobile/test/features/channels/channel_test.dart b/mobile/test/features/channels/channel_test.dart
index 9f36760861..9673eda28e 100644
--- a/mobile/test/features/channels/channel_test.dart
+++ b/mobile/test/features/channels/channel_test.dart
@@ -195,4 +195,50 @@ void main() {
expect(updated.archivedAt, newDate);
});
});
+
+ group('Channel.canAddMembers', () {
+ Channel make({required String channelType, required String visibility}) =>
+ Channel(
+ id: '1',
+ name: 'c',
+ channelType: channelType,
+ visibility: visibility,
+ description: '',
+ createdBy: 'x',
+ createdAt: DateTime(2025),
+ memberCount: 2,
+ );
+
+ test('open channels accept adds from anyone', () {
+ final channel = make(channelType: 'stream', visibility: 'open');
+ expect(channel.canAddMembers(null), isTrue);
+ expect(channel.canAddMembers('member'), isTrue);
+ });
+
+ test('private channels accept adds only from owners/admins', () {
+ final channel = make(channelType: 'stream', visibility: 'private');
+ expect(channel.canAddMembers('owner'), isTrue);
+ expect(channel.canAddMembers('admin'), isTrue);
+ expect(channel.canAddMembers('member'), isFalse);
+ expect(channel.canAddMembers('bot'), isFalse);
+ expect(channel.canAddMembers(null), isFalse);
+ });
+
+ test('DMs never accept adds', () {
+ expect(
+ make(channelType: 'dm', visibility: 'open').canAddMembers('owner'),
+ isFalse,
+ );
+ expect(
+ make(channelType: 'dm', visibility: 'private').canAddMembers('owner'),
+ isFalse,
+ );
+ });
+
+ test('unknown visibility fails closed for non-elevated callers', () {
+ final channel = make(channelType: 'stream', visibility: 'mystery');
+ expect(channel.canAddMembers('member'), isFalse);
+ expect(channel.canAddMembers('owner'), isTrue);
+ });
+ });
}
diff --git a/mobile/test/features/channels/compose_bar_test.dart b/mobile/test/features/channels/compose_bar_test.dart
index 11bf306825..d58555f45a 100644
--- a/mobile/test/features/channels/compose_bar_test.dart
+++ b/mobile/test/features/channels/compose_bar_test.dart
@@ -3138,6 +3138,81 @@ void main() {
expect(publishedEvents.where((event) => event['kind'] == 9000), isEmpty);
});
+ testWidgets(
+ 'skips the agent add in a private channel when not owner/admin',
+ (tester) async {
+ final agentPubkey = 'a' * 64;
+ final signer = nostr.Keys.generate();
+ final publishedEvents =