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
+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,
};
}