fix(channels): restrict private-channel invitations (#4612)

This change requires an active owner or administrator for third-party
additions to private channels. The relay validator and transactional
database authority enforce the same rule, including removed-member
reactivation and role-change paths.

Idempotent self-target behavior remains available, while ordinary
members can no longer extend private-channel access to another identity.

## Testing

- `git diff --check
origin/main...codex/security-private-channel-invite-authority`
- Rebased onto `origin/main` at `5c98932`
- Full CI pending

Originating Buzz thread:
`buzz://message?channel=3928fe05-df61-4b5d-b9c7-d623b9b10ea1&id=3c6c02312f763fbe0d2bfc33a6c1a362f91d0354f3d18b039cf7a0558c1439d1`

---------

Signed-off-by: Jordan Mecom <jm@squareup.com>
Signed-off-by: Eli Foster <efoster@squareup.com>
Co-authored-by: Eli Foster <efoster@squareup.com>
This commit is contained in:
Jordan Mecom
2026-08-05 10:47:00 -07:00
committed by GitHub
co-authored by Eli Foster
parent ad538bfb1e
commit efe1893dd3
19 changed files with 858 additions and 190 deletions
+1 -1
View File
@@ -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 |
+10 -4
View File
@@ -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(),
));
}
}
+19 -19
View File
@@ -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
+74 -4
View File
@@ -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]
+28
View File
@@ -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();
@@ -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,
);
});
@@ -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.";
@@ -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}
/>
</label>
{showPrivateAddDeniedNotice ? (
<p
className="pt-2 text-sm text-muted-foreground"
data-testid="members-sidebar-add-denied"
>
{PRIVATE_CHANNEL_ADD_DENIED_MESSAGE}
</p>
) : null}
</DialogHeader>
<div className="max-h-[calc(100vh-12rem)] overflow-y-auto pb-6">
@@ -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>;
}): 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;
}
@@ -1007,15 +1007,7 @@ function MessageComposerImpl({
</div>
</footer>
<NonMemberMentionDialog
error={mentionSendFlow.nonMemberPromptError}
isInvitePending={mentionSendFlow.isInvitePending}
names={mentionSendFlow.pendingNonMemberNames}
onDismiss={mentionSendFlow.dismissNonMemberPrompt}
onDoNothing={mentionSendFlow.sendWithoutInviting}
onInvite={mentionSendFlow.inviteNonMembers}
open={mentionSendFlow.pendingNonMemberSend !== null}
/>
<NonMemberMentionDialog {...mentionSendFlow.nonMemberPromptProps} />
{linkEditor.card}
{linkEditor.dialog}
@@ -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({
</AlertDialogTitle>
<AlertDialogDescription>
{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.`}
</AlertDialogDescription>
</AlertDialogHeader>
{error ? (
@@ -59,16 +66,18 @@ export function NonMemberMentionDialog({
type="button"
variant="outline"
>
Do nothing
</Button>
<Button
disabled={isInvitePending}
onClick={onInvite}
size="sm"
type="button"
>
{isInvitePending ? "Inviting..." : "Invite"}
{canInvite ? "Do nothing" : "Send anyway"}
</Button>
{canInvite ? (
<Button
disabled={isInvitePending}
onClick={onInvite}
size="sm"
type="button"
>
{isInvitePending ? "Inviting..." : "Invite"}
</Button>
) : null}
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
@@ -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,
};
}
+41 -4
View File
@@ -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}`)
+16
View File
@@ -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}) {
@@ -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<String, String> 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 = <String, String>{};
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() {
@@ -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<String>.from(
selectedMentions.map((candidate) => candidate.pubkey.toLowerCase()),
).toList();
final nonMemberAgentPubkeys = <String>[];
final nonMemberHumans = <MentionCandidate>[];
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 = <String>{};
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 = <List<String>>[];
var inviteHumanPubkeys = const <String>[];
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<void> 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<void> 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;
@@ -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<String> 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<String> notAdded;
final List<String> 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<void> _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<String> agentPubkeys,
required List<String> 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 = <String>[];
final errors = <String>[];
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<String> agentPubkeys;
final List<MentionCandidate> 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<MentionCandidate> 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 = <String>[];
final humans = <MentionCandidate>[];
final seen = <String>{};
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<String> pubkeys;
final List<List<String>> referenceTags = [];
List<String> _invitedHumanPubkeys = const [];
_OutgoingMentions(List<MentionCandidate> selectedMentions)
: pubkeys = LinkedHashSet<String>.from(
selectedMentions.map((candidate) => candidate.pubkey.toLowerCase()),
).toList();
void demote(Iterable<String> 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<MentionCandidate> 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<void> 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(' '))),
);
}
}
}
@@ -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);
});
});
}
@@ -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 = <Map<String, dynamic>>[];
var didSend = false;
List<String> sentMentionPubkeys = const <String>[];
List<List<String>> sentMediaTags = const <List<String>>[];
await tester.pumpWidget(
_buildComposeBar(
uploadService: _testUploadService(signer.nsec),
currentPubkey: signer.public,
// Plain member of a private channel: the relay rejects any add, so
// the composer must not attempt one — and must still send.
members: [
ChannelMember(
pubkey: signer.public,
role: 'member',
joinedAt: DateTime(2024),
),
],
relayAgents: [_testAgent(agentPubkey)],
channels: [
_makeCurrentChannel(visibility: 'private'),
_makeSharedMemberChannel(),
],
onSend:
(
content,
mentionPubkeys, {
mediaTags = const <List<String>>[],
}) async {
didSend = true;
sentMentionPubkeys = mentionPubkeys;
sentMediaTags = mediaTags;
},
),
);
final container = ProviderScope.containerOf(
tester.element(find.byType(ComposeBar)),
);
final session = container.read(relaySessionProvider.notifier);
final socket = _RecordingRelaySocket(
publishedEvents,
session.debugHandleSocketMessageForTest,
);
session.debugAttachSocketForTest(socket);
await _expandComposer(tester);
await tester.enterText(find.byType(TextField), '@hel');
await tester.pumpAndSettle();
await tester.tap(find.text('Helper Bot'));
await tester.pumpAndSettle();
await tester.enterText(find.byType(TextField), 'hello @Helper Bot');
await tester.tap(find.byIcon(LucideIcons.arrowUp));
await tester.pumpAndSettle();
expect(didSend, isTrue);
expect(
publishedEvents.where((event) => event['kind'] == 9000),
isEmpty,
);
// The un-added agent is demoted from p-tag to a reference mention.
expect(sentMentionPubkeys, isEmpty);
expect(
sentMediaTags,
contains(orderedEquals(['mention', agentPubkey])),
);
expect(find.text(privateChannelAddDeniedMessage), findsOneWidget);
},
);
testWidgets('adds a sanitized animated PNG attachment', (tester) async {
final keychain = nostr.Keys.generate();
final nsec = keychain.nsec;
@@ -3489,12 +3564,15 @@ List<({String text, TextStyle style})> _flattenStyledTextSpans(
return result;
}
Channel _makeCurrentChannel({String channelType = 'stream'}) {
Channel _makeCurrentChannel({
String channelType = 'stream',
String visibility = 'open',
}) {
return Channel(
id: 'channel-1',
name: 'current',
channelType: channelType,
visibility: 'open',
visibility: visibility,
description: '',
createdBy: 'pubkey123',
createdAt: DateTime(2024),