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
@@ -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 |
|
||||
|
||||
|
||||
@@ -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(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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}`)
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user