mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
co-authored by
Eli Foster
parent
ad538bfb1e
commit
efe1893dd3
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user