From 2e863af8f3cc183161ce158c7eb566fbfd9e98fd Mon Sep 17 00:00:00 2001 From: Josiah Peedikayil <174502694+jpeedikayil@users.noreply.github.com> Date: Tue, 3 Nov 2026 16:45:14 -0800 Subject: [PATCH] fix(members): preserve recorded agent classification Read optional agent markers from the verified roster independently of permission roles. Keep unknown identities in neutral member groups and preserve existing action restrictions and owner-read limits. Co-authored-by: Codex Signed-off-by: Josiah Peedikayil <174502694+jpeedikayil@users.noreply.github.com> --- docs/buzz-help.md | 13 +- docs/channels.md | 11 + docs/solo/sharing.md | 14 +- .../channels/ChannelGeneralAccess.test.tsx | 6 +- .../ChannelMembersDialog.solo.test.tsx | 20 +- .../channels/ChannelMembersDialog.test.tsx | 410 ++++++++++++++---- src/bundled/channels/ChannelMembersDialog.tsx | 83 ++-- .../channels/MemberAdministration.test.tsx | 24 +- .../administration-protocol.ts | 15 + .../channel-members/administration.test.ts | 87 ++++ src/features/channel-members/members.test.ts | 8 +- src/features/channel-members/members.ts | 2 +- src/features/solo/ConvertToChannel.test.tsx | 4 +- src/features/solo/labels.ts | 4 +- tests/browser/channel-members-focus.spec.mjs | 21 +- tests/browser/channel-members-scale.spec.mjs | 6 +- tests/browser/channel-members.spec.mjs | 2 +- tests/browser/profiles.spec.mjs | 2 +- tests/browser/solo-chats.spec.mjs | 2 +- tests/browser/solo-legacy-invitation.spec.mjs | 4 +- tests/browser/solo-public-access.spec.mjs | 6 +- tests/browser/solo-sharing.spec.mjs | 6 +- tests/browser/solo-workspace.spec.mjs | 2 +- 23 files changed, 567 insertions(+), 185 deletions(-) diff --git a/docs/buzz-help.md b/docs/buzz-help.md index b68485f9b0..6b685273fb 100644 --- a/docs/buzz-help.md +++ b/docs/buzz-help.md @@ -141,7 +141,10 @@ that agent in Buzz; they do not notify the external app through this connection. lets them read; **Private** limits access to people added to the channel. Changing this choice does not change existing members' roles. Ordinary participants have no role caption; Owner, Admin, and Viewing only mark - exceptions. Use a person's actions menu to change their role when permitted. + exceptions. Use a member's actions menu to change their role when permitted. + **Agents** includes identities known to be agents, independently of channel + role. **Members** and **Others** do not certify that an identity is human. + Editing a profile cannot hide the relay's recorded agent classification. - **Message search** treats the last word you type as the start of a word, so it searches only once that word has at least three characters, even with `from:` or `in:`. A shorter last word shows how many characters to type, not @@ -183,7 +186,7 @@ that agent in Buzz; they do not notify the external app through this connection. **Public (existing)**. This is not protocol enforcement. View-only visitors cannot post; existing members keep their roles. Returning to Private warns about retained viewers unless verified roles show no Guests, including while - roles load or fail to load; remove them in People to end their access. Public + roles load or fail to load; remove them in Members to end their access. Public (existing) → Private also warns that joined members retain their access. Public (existing) → View only confirms that existing members keep their access. Agents the owner adds through @@ -191,17 +194,17 @@ that agent in Buzz; they do not notify the external app through this connection. including with a Guest admission default. Conversion to a channel offers Private, View only and Public, starts from the chat's current access, and saves the selected policy. Choosing Private during conversion also warns that people - who joined keep their access until removed in People. Opening a private chat to the community warns that its full + who joined keep their access until removed in Members. Opening a private chat to the community warns that its full history and Canvas, including workspace folders and agent instructions, become readable. View only retains Guest admission. Copying its link alone does not grant access. Check the intended people and destination before changing access. If an older saved invitation granted - view-only access, remove that person in People and invite them again for + view-only access, remove that person in Members and invite them again for participation. For an older unresolved invitation, use **Retry** in Outbox or add the person again if the receipt is already Seen. After its 15-minute delivery window, Buzz verifies the original command and current membership before allowing a fresh participation invitation; failed checks keep the original. If the original invitation was applied and the person was later removed, Retry - clears the old row without re-adding them. Use **Add** in People to invite them again. + clears the old row without re-adding them. Use **Add** to invite them again. - **Open links in your browser**: on desktop, ⌘-click (Ctrl-click on Windows and Linux) an HTTP(S) link in a message to open it in your default browser instead of a side panel. You can also right-click the link and choose diff --git a/docs/channels.md b/docs/channels.md index f7712e05a6..253105c3be 100644 --- a/docs/channels.md +++ b/docs/channels.md @@ -1091,6 +1091,17 @@ another non-owner member: | Bot | Admin / Guest, after explicit confirmation; Participating is already selected | Yes | | Owner, self, unknown or inconsistent | No | No | +The selected relay-signed roster may carry two-field `agent` tags for active +members with a recorded community agent owner. These are positive identity evidence, +not ownership attribution or permission grants. Members combines them with locally +known agents and profile hints. Channel roles alone do not establish agent identity. +Absent evidence uses neutral **Members**/**Others** groups rather than asserting +human identity. Supporting relays refresh existing rosters when ownership is first +recorded. Older or failed-to-refresh snapshots lack traits until republished; +Refresh reads the latest published roster. Trusted +classification changes move affected keys between retained groups; optional name +and profile-hint enrichment preserves the current large-roster page and focus. + Agent identity is independent of channel role. Explicitly promoting a Bot to Admin grants authority to that agent’s own public key and preserves its verified agent identity; the human owner’s roles do not confer authority. The menu offers **Viewing only** through a deliberate **Set to viewing only** diff --git a/docs/solo/sharing.md b/docs/solo/sharing.md index da0f6725e9..6f3dcc1480 100644 --- a/docs/solo/sharing.md +++ b/docs/solo/sharing.md @@ -39,13 +39,13 @@ owner's selectable agents are offered too and join as Bot, even with a Guest admission default, without starting a run. Composer mentions use the same grant. Where the viewer cannot add agents, a note says to mention an agent in the chat instead. -Current access uses the channel groups (**People**, **Agents**) with Owner and +Current access uses the channel groups (**Members**, **Agents**) with Owner and Admin tags, and each row has the channel members' actions menu, so the chat owner can remove someone. The verified roster shows loading or failure, never treating a failed read as an empty audience. Invitations use the same groups and controls as channels: **Your agents** first, -then **People**, **Other agents** and **Your other agents**, omitting empty groups. +then **Others**, **Other agents** and **Your other agents**, omitting empty groups. Solo still excludes agents the viewer cannot add directly. Each group uses shared name, relationship and usage ranking, begins with four rows, and expands with its own **Show more**. Only the viewer's client agents are suggested without typing, @@ -76,14 +76,14 @@ relay's 15-minute intake window. After expiry, Retry fences local delivery and checks the original event ID with a fresh strong read. Verified absence can resolve it. If the command is recorded, a fresh signed roster reconciles its result. If that person has since been removed, Outbox **Retry** durably clears -the old row without re-inviting them. An explicit **Add** from People still +the old row without re-inviting them. An explicit **Add** still re-invites them in one action, including when the old receipt remains. Add finishes its fresh membership check before atomically retiring the original and saving a new explicit Member invitation. Closing or restarting keeps either original recovery or the replacement receipt. Failed reads or storage, active delivery, and an echo contradicting negative readback keep recovery. This uses the existing Outbox/member/share actions, with no background retry or new control. If signed roles confirm that the earlier invite -admitted a Guest, remove that person in People and invite them again. Recovery +admitted a Guest, remove that person in Members and invite them again. Recovery never silently promotes an existing Guest. A definitively failed invitation can also be replaced after explicitly dismissing it in Outbox. @@ -100,7 +100,7 @@ explains that people already in the chat keep their access. Moving from View onl to Private warns that joined viewers remain members unless a verified role snapshot establishes that no Guests exist; loading or failed verification keeps the warning. Public (existing) → Private also warns that joined members retain -their access until removed in People. One signed kind-9002 edit sets +their access until removed in Members. One signed kind-9002 edit sets `visibility=open` and `join_role=guest` together, through the existing `channelDetails` save, conflict checks and exact readback. @@ -121,7 +121,7 @@ Solo label, conversation ID and private workspace filing. Changing back to Private stops nonmembers from reading; it does not remove members or change their roles. Guests who joined through the CLI, MCP or another -client while the chat was View only keep read access until removed in People. +client while the chat was View only keep read access until removed in Members. Reading without joining creates no membership to retain. Neither transition changes invitations, creates a share message or adds notifications. Unconfirmed publication uses **Check save status**, without automatically resending. @@ -171,7 +171,7 @@ reading of its full history and Canvas, including workspace folders and agent instructions. View only saves public visibility with Guest admission; Public saves Member admission; Private keeps reading restricted to members and retains the current admission default. Choosing Private for an open chat warns that joined -people retain access until removed in People, using the same warning as General +people retain access until removed in Members, using the same warning as General access. A selected share channel is never granted by conversion. Cancel writes nothing and returns focus to the retained initiating control. diff --git a/src/bundled/channels/ChannelGeneralAccess.test.tsx b/src/bundled/channels/ChannelGeneralAccess.test.tsx index 717ef30c8f..e5ebfb8043 100644 --- a/src/bundled/channels/ChannelGeneralAccess.test.tsx +++ b/src/bundled/channels/ChannelGeneralAccess.test.tsx @@ -256,7 +256,7 @@ it.each(["private", "view-only", "legacy-public"])( expect(h.save).not.toHaveBeenCalled(); expect(screen.getByRole("alertdialog")).toHaveAccessibleDescription( policy === "view-only" - ? "Only chat members will have access. People who joined while it was View only are members and keep reading until you remove them in People." + ? "Only chat members will have access. People who joined while it was View only are members and keep reading until you remove them in Members." : `Everyone in this community will be able to read this chat’s full history and Canvas, including its workspace folders and agent instructions.${policy === "legacy-public" ? " People already in the chat keep their access." : ""}`, ); h.load.mockResolvedValue(next); @@ -289,7 +289,7 @@ it.each([ joinRole: "member", option: "Private", expected: - "Only chat members will have access. People who already joined remain members and keep their access until you remove them in People.", + "Only chat members will have access. People who already joined remain members and keep their access until you remove them in Members.", }, ] as const)( "explains $visibility/$joinRole → $option with existing members", @@ -510,7 +510,7 @@ it.each([true, false])( const user = userEvent.setup(); await select(user, "General access", "Private"); expect(screen.getByRole("alertdialog")).toHaveAccessibleDescription( - "Only chat members will have access. People who already joined remain members and keep their access until you remove them in People.", + "Only chat members will have access. People who already joined remain members and keep their access until you remove them in Members.", ); await user.click(screen.getByRole("button", { name: "Cancel" })); expect(h.save).not.toHaveBeenCalled(); diff --git a/src/bundled/channels/ChannelMembersDialog.solo.test.tsx b/src/bundled/channels/ChannelMembersDialog.solo.test.tsx index 8f7787d90f..c913d96f71 100644 --- a/src/bundled/channels/ChannelMembersDialog.solo.test.tsx +++ b/src/bundled/channels/ChannelMembersDialog.solo.test.tsx @@ -561,7 +561,7 @@ it.each(["guest", "member"] as const)( await user.click(audience); await user.click(await screen.findByRole("option", { name: "Private" })); expect(screen.getByRole("alertdialog")).toHaveAccessibleDescription( - `Only chat members will have access.${role === "guest" ? " People who joined while it was View only are members and keep reading until you remove them in People." : ""}`, + `Only chat members will have access.${role === "guest" ? " People who joined while it was View only are members and keep reading until you remove them in Members." : ""}`, ); expect(t.published).toEqual([]); await user.click(screen.getByRole("button", { name: "Change access" })); @@ -622,7 +622,7 @@ it.each(["guest", "member"] as const)( await user.click(audience); await user.click(await screen.findByRole("option", { name: "Private" })); expect(screen.getByRole("alertdialog")).toHaveAccessibleDescription( - "Only chat members will have access. People who joined while it was View only are members and keep reading until you remove them in People.", + "Only chat members will have access. People who joined while it was View only are members and keep reading until you remove them in Members.", ); await act(async () => { reject(new Error("Roles unavailable")); @@ -632,7 +632,7 @@ it.each(["guest", "member"] as const)( "error", ); expect(screen.getByRole("alertdialog")).toHaveAccessibleDescription( - "Only chat members will have access. People who joined while it was View only are members and keep reading until you remove them in People.", + "Only chat members will have access. People who joined while it was View only are members and keep reading until you remove them in Members.", ); await user.keyboard("{Escape}"); expect(t.published).toEqual([]); @@ -686,7 +686,7 @@ it("adds a person at once under the chat's share rule, by Add or Enter", async ( expect(await within(dialog).findByText("Aria is in the chat.")).toBeVisible(); await user.clear(search(dialog)); expect( - await within(dialog).findByRole("region", { name: "People" }), + await within(dialog).findByRole("region", { name: "Members" }), ).toHaveTextContent("Aria"); await user.type(search(dialog), "Dev"); await user.click( @@ -853,7 +853,7 @@ it("shows loading and failed current access with refresh, never an empty roster" expect(refresh).not.toHaveAttribute("aria-disabled", "true"), ); await user.click(refresh); - await within(access).findByRole("region", { name: "People" }); + await within(access).findByRole("region", { name: "Members" }); // General access has its own failed metadata read and explicit recovery. const recovery = within(access).getByRole("group", { name: "General access recovery", @@ -908,7 +908,7 @@ it("groups agents and people by role while only verified additional humans mark const agents = await within(dialog).findByRole("region", { name: "Agents" }); await within(agents).findByText("Helper Bot"); expect(within(agents).getByRole("heading")).toHaveTextContent("Agents · 1"); - const people = within(dialog).getByRole("region", { name: "People" }); + const people = within(dialog).getByRole("region", { name: "Members" }); expect(people).toHaveTextContent("(you)"); expect(people).not.toHaveTextContent("Helper Bot"); @@ -919,7 +919,7 @@ it("groups agents and people by role while only verified additional humans mark await waitFor(() => expect( within( - within(dialog).getByRole("region", { name: "People" }), + within(dialog).getByRole("region", { name: "Members" }), ).getAllByRole("listitem"), ).toHaveLength(2), ); @@ -934,7 +934,7 @@ it("groups agents and people by role while only verified additional humans mark await user.click(screen.getByRole("button", { name: "Access" })); dialog = screen.getByRole("dialog", { name: "Share access" }); expect( - await within(dialog).findByRole("region", { name: "People" }), + await within(dialog).findByRole("region", { name: "Members" }), ).toHaveTextContent("Guest Human"); expect(t.publish).not.toHaveBeenCalled(); @@ -982,7 +982,7 @@ it("offers matching people and channels as labelled results without public keys" await user.click(screen.getByRole("button", { name: "Access" })); const dialog = screen.getByRole("dialog", { name: "Share access" }); expect( - await within(dialog).findByRole("region", { name: "People" }), + await within(dialog).findByRole("region", { name: "Members" }), ).toHaveTextContent("Aria"); await user.type(search(dialog), "Planning"); const people = await within(dialog).findByRole("region", { @@ -1059,7 +1059,7 @@ it("lets the chat owner remove someone with access from the row menu", async () t.mount(); await user.click(screen.getByRole("button", { name: "Access" })); const dialog = screen.getByRole("dialog", { name: "Share access" }); - const people = await within(dialog).findByRole("region", { name: "People" }); + const people = await within(dialog).findByRole("region", { name: "Members" }); await user.click( await within(people).findByRole("button", { name: "Actions for Aria" }), ); diff --git a/src/bundled/channels/ChannelMembersDialog.test.tsx b/src/bundled/channels/ChannelMembersDialog.test.tsx index 569c516729..9aa63ae148 100644 --- a/src/bundled/channels/ChannelMembersDialog.test.tsx +++ b/src/bundled/channels/ChannelMembersDialog.test.tsx @@ -100,11 +100,13 @@ async function setup( navigation?: { canOpenLink(target: string): boolean; onOpenLink(target: string, returnFocus?: HTMLElement): boolean; + onOpenConversation?(channelId: string): boolean; }, agents?: Agents, options: { archivedAgent?: boolean; extraMembers?: number; + agentRoster?: boolean; memberProfiles?: RelayEvent[]; pendingMemberNames?: Promise; channelCreation?: boolean; @@ -208,8 +210,28 @@ async function setup( await options.pendingMemberNames; if (filters.some((filter) => filter.kinds?.includes(39002))) await rosterRead; - return [roster(relay, id, members, clock), ...stableEvents].filter( - (event) => filters.some((filter) => matchesEvent(event, filter)), + const memberEvents = options.agentRoster + ? [ + signed(relay, { + kind: 39001, + created_at: clock, + content: "", + tags: [["d", id]], + }), + signed(relay, { + kind: 39002, + created_at: clock, + content: "", + tags: [ + ["d", id], + ...members.map((key) => ["p", key, "", "member"]), + ...members.map((key) => ["agent", key]), + ], + }), + ] + : [roster(relay, id, members, clock)]; + return [...memberEvents, ...stableEvents].filter((event) => + filters.some((filter) => matchesEvent(event, filter)), ); }); const readAgentLibrary = vi.fn( @@ -234,6 +256,9 @@ async function setup( ? { searchProfiles: options.searchProfiles } : {}), readAgentLibrary, + ...(navigation?.onOpenConversation + ? { openDirectMessage: async () => id } + : {}), writer: { kinds: [ 9, @@ -412,7 +437,7 @@ it.each(["Saving access…", "Searching…"])( await vi.waitFor(() => expect(nameReads()).toBe(beforeRefresh + 1)); expect(memberData()).toHaveAttribute("data-refreshing", "true"); expect( - within(screen.getByRole("region", { name: "People" })).getByRole( + within(screen.getByRole("region", { name: "Members" })).getByRole( "list", ), ).toHaveAttribute("aria-busy", "true"); @@ -612,7 +637,7 @@ it.each([false, true])( await settled(); const roster = () => screen.getByRole("region", { - name: localAgent ? "Agents" : "People", + name: localAgent ? "Agents" : "Members", }); expect( within(roster()).getByText(localAgent ? "Fixture agent" : "Morgan"), @@ -768,12 +793,12 @@ it("distinguishes invitation and roster People regions while retaining the capti ); await t.user.type(screen.getByRole("searchbox"), "Morgan"); const invitations = await screen.findByRole("region", { - name: "People not in this channel", + name: "Others not in this channel", }); - const roster = screen.getByRole("region", { name: "People" }); + const roster = screen.getByRole("region", { name: "Members" }); expect(within(roster).getByText(nameText("Morgan"))).toBeVisible(); expect( - within(invitations).getByRole("heading", { name: "People" }), + within(invitations).getByRole("heading", { name: "Others" }), ).toBeVisible(); expect( within(invitations).getByRole("button", { name: /^Add Morgan Guest/ }), @@ -809,7 +834,7 @@ it("orders matches by client agents, people, other agents, then your non-client const rows = within(results).getAllByRole("button", { name: /^Add / }); const sections = [ "Your agents", - "People not in this channel", + "Others not in this channel", "Other agents", "Your other agents", ]; @@ -883,7 +908,7 @@ async function relationshipInvitations() { within( within( screen.getByRole("region", { name: "Not in this channel" }), - ).getByRole("region", { name: "People not in this channel" }), + ).getByRole("region", { name: "Others not in this channel" }), ) .getAllByRole("button", { name: /^Add / }) .map((row) => row.getAttribute("aria-label")); @@ -1147,7 +1172,7 @@ it("starts every invitation group at four and expands only the selected group", ); const groups = [ "Your agents", - "People not in this channel", + "Others not in this channel", "Other agents", "Your other agents", ]; @@ -1171,22 +1196,22 @@ it("starts every invitation group at four and expands only the selected group", screen .getByRole("region", { name: "Not in this channel" }) .querySelector("[data-highlighted]"), - ).toContainElement(rows("People not in this channel")[0] ?? null); + ).toContainElement(rows("Others not in this channel")[0] ?? null); await t.user.click( within( - screen.getByRole("region", { name: "People not in this channel" }), + screen.getByRole("region", { name: "Others not in this channel" }), ).getByRole("button", { name: "Show more", }), ); - expect(rows("People not in this channel")).toHaveLength(5); + expect(rows("Others not in this channel")).toHaveLength(5); expect( within( - screen.getByRole("region", { name: "People not in this channel" }), + screen.getByRole("region", { name: "Others not in this channel" }), ).queryByRole("button", { name: "Show more" }), ).toBeNull(); for (const name of groups.filter( - (name) => name !== "People not in this channel", + (name) => name !== "Others not in this channel", )) expect(rows(name)).toHaveLength(4); expect(t.publish).not.toHaveBeenCalled(); @@ -1644,7 +1669,7 @@ it("refresh restarts the current directory query from page one instead of mixing await t.user.click(more); const people = within( screen.getByRole("region", { name: "Not in this channel" }), - ).getByRole("region", { name: "People not in this channel" }); + ).getByRole("region", { name: "Others not in this channel" }); await screen.findByText(nameText("Morgan stale")); expect(within(people).getAllByRole("button", { name: /^Add / })).toHaveLength( 31, @@ -1824,8 +1849,8 @@ it("refreshes membership even when verified role metadata is unavailable", async await vi.waitFor(() => expect(memberData()).toHaveAttribute("data-refreshing", "false"), ); - expect(screen.getByRole("region", { name: "People" })).toHaveTextContent( - "People · 2", + expect(screen.getByRole("region", { name: "Members" })).toHaveTextContent( + "Members · 2", ); expect( t.session.memberAdministration.snapshot( @@ -2355,12 +2380,12 @@ it("expands local and relay groups independently without losing paged matches", await pagedInvitations(step, 65); expect(rows()).toHaveLength(8); expect(groupRows("Your agents")).toHaveLength(4); - expect(groupRows("People not in this channel")).toHaveLength(4); + expect(groupRows("Others not in this channel")).toHaveLength(4); for (let index = 0; index < 16; index++) await moreIn("Your agents"); expect(groupRows("Your agents")).toHaveLength(66); - expect(groupRows("People not in this channel")).toHaveLength(4); + expect(groupRows("Others not in this channel")).toHaveLength(4); for (let index = 0; index < 7; index++) - await moreIn("People not in this channel"); + await moreIn("Others not in this channel"); expect(rows()).toHaveLength(95); expect(pages()).toEqual([1]); await more(); @@ -2389,11 +2414,11 @@ it("reveals fetched relay pages while local-only rows keep their own limit", asy expect( screen.getByRole("button", { name: /^Add Helper final/ }), ).toBeVisible(); - expect(groupRows("People not in this channel")).toHaveLength(30); + expect(groupRows("Others not in this channel")).toHaveLength(30); expect(groupRows("Your agents")).toHaveLength(4); await moreIn("Your agents"); expect(groupRows("Your agents")).toHaveLength(8); - expect(groupRows("People not in this channel")).toHaveLength(30); + expect(groupRows("Others not in this channel")).toHaveLength(30); expect(pages()).toEqual([1, 2]); expect(t.publish).not.toHaveBeenCalled(); }); @@ -2450,7 +2475,7 @@ it("resets every invitation group's expansion on refresh or query change", async } = await pagedInvitations(step, 31); for (let index = 0; index < 7; index++) await moreIn("Your agents"); for (let index = 0; index < 7; index++) - await moreIn("People not in this channel"); + await moreIn("Others not in this channel"); await more(); await idle(); expect( @@ -2464,7 +2489,7 @@ it("resets every invitation group's expansion on refresh or query change", async expect(pages()).toEqual([1, 2, 1]); await moreIn("Your agents"); expect(groupRows("Your agents")).toHaveLength(8); - expect(groupRows("People not in this channel")).toHaveLength(4); + expect(groupRows("Others not in this channel")).toHaveLength(4); await search("Helper local"); await idle(); expect(rows()).toHaveLength(8); @@ -2719,7 +2744,7 @@ it("lets the Solo owner add an agent without sending a message", async () => { expect(notice).toHaveClass("sr-only"); expect(input).toHaveValue(""); expect(input).toHaveFocus(); - expect(screen.getByRole("heading", { name: "People · 1" })).toBeVisible(); + expect(screen.getByRole("heading", { name: "Members · 1" })).toBeVisible(); expect(screen.getByRole("heading", { name: "Agents · 1" })).toBeVisible(); expect( screen.queryByRole("button", { name: /Add Fixture agent/ }), @@ -2833,13 +2858,13 @@ it("gives Solo the channel invitation groups, ranking and independent four-row e ).toBe(false); const input = screen.getByRole("searchbox"); await t.user.type(input, "Helper"); - await screen.findByRole("region", { name: "People not in this chat" }); + await screen.findByRole("region", { name: "Others not in this chat" }); const invitations = screen.getByRole("region", { name: "Not in this chat" }); expect( within(invitations) .getAllByRole("region") .map((region) => region.getAttribute("aria-label")), - ).toEqual(["Your agents", "People not in this chat", "Your other agents"]); + ).toEqual(["Your agents", "Others not in this chat", "Your other agents"]); expect(rows("Your agents")).toHaveLength(4); expect(rows("Your other agents")).toHaveLength(4); expect(rows("Your other agents")[0]).toHaveAccessibleName( @@ -2901,7 +2926,7 @@ it("reveals Solo directory continuations in their group and resets expansion on }); await t.user.type(screen.getByRole("searchbox"), "Helper"); const peopleGroup = await screen.findByRole("region", { - name: "People not in this chat", + name: "Others not in this chat", }); const rows = () => within(peopleGroup).getAllByRole("button", { name: /^Add / }); @@ -3410,7 +3435,7 @@ it.each([ ).toHaveAttribute("data-refreshing", "false"), ); expect(screen.queryByRole("alert")).not.toBeInTheDocument(); - expect(screen.getByRole("region", { name: "People" })).toHaveTextContent( + expect(screen.getByRole("region", { name: "Members" })).toHaveTextContent( "Channel owner", ); expect( @@ -3423,58 +3448,64 @@ it.each([ }, ); -it("pages a large roster without waiting for optional names or exceeding the profile request budget", async () => { - let release!: () => void; - const pending = new Promise((resolve) => { - release = resolve; - }); - try { - const t = await setup("stream", false, false, undefined, undefined, { - extraMembers: 1024, - pendingMemberNames: pending, - }); - const list = screen.getByRole("region", { - name: "Member list", - }); - expect(within(list).getAllByRole("listitem")).toHaveLength(50); - expect(screen.queryByText("Loading members…")).not.toBeInTheDocument(); - const pages = within(list).getByRole("navigation", { - name: "People member pages", - }); - expect( - within(pages).getByRole("button", { name: "Previous" }), - ).toBeDisabled(); - await t.user.click(within(pages).getByRole("button", { name: "Next" })); - expect(within(pages).getByText("2 of 21")).toBeVisible(); - expect(within(list).getAllByRole("listitem")).toHaveLength(50); - await act(async () => release()); - await vi.waitFor(() => - expect(memberData()).toHaveAttribute("data-refreshing", "false"), - ); - const batches = t.query.mock.calls - .flatMap(([filters]) => filters) - .filter((filter) => filter.kinds?.includes(0) && filter.authors?.length); - expect(batches.length).toBeGreaterThan(1); - expect(batches.every((filter) => (filter.authors?.length ?? 0) <= 50)).toBe( - true, - ); - expect( - screen.queryByText(/Some names could not load/), - ).not.toBeInTheDocument(); - fireEvent.change(screen.getByRole("searchbox"), { - target: { value: (11023).toString(16).padStart(64, "0") }, +it.each([false, true])( + "pages a large roster without waiting for optional names or exceeding the profile request budget (relay agents: %s)", + async (agentRoster) => { + let release!: () => void; + const pending = new Promise((resolve) => { + release = resolve; }); - await vi.waitFor(() => - expect(within(list).getAllByRole("listitem")).toHaveLength(1), - ); - await t.user.clear(screen.getByRole("searchbox")); - await vi.waitFor(() => - expect(within(list).getByText("1 of 21")).toBeVisible(), - ); - } finally { - await act(async () => release()); - } -}); + try { + const t = await setup("stream", false, false, undefined, undefined, { + extraMembers: 1024, + agentRoster, + pendingMemberNames: pending, + }); + const list = screen.getByRole("region", { + name: "Member list", + }); + expect(within(list).getAllByRole("listitem")).toHaveLength(50); + expect(screen.queryByText("Loading members…")).not.toBeInTheDocument(); + const pages = within(list).getByRole("navigation", { + name: `${agentRoster ? "Agents" : "Members"} member pages`, + }); + expect( + within(pages).getByRole("button", { name: "Previous" }), + ).toBeDisabled(); + await t.user.click(within(pages).getByRole("button", { name: "Next" })); + expect(within(pages).getByText("2 of 21")).toBeVisible(); + expect(within(list).getAllByRole("listitem")).toHaveLength(50); + await act(async () => release()); + await vi.waitFor(() => + expect(memberData()).toHaveAttribute("data-refreshing", "false"), + ); + const batches = t.query.mock.calls + .flatMap(([filters]) => filters) + .filter( + (filter) => filter.kinds?.includes(0) && filter.authors?.length, + ); + expect(batches.length).toBeGreaterThan(1); + expect( + batches.every((filter) => (filter.authors?.length ?? 0) <= 50), + ).toBe(true); + expect( + screen.queryByText(/Some names could not load|Some agent managers/), + ).not.toBeInTheDocument(); + fireEvent.change(screen.getByRole("searchbox"), { + target: { value: (11023).toString(16).padStart(64, "0") }, + }); + await vi.waitFor(() => + expect(within(list).getAllByRole("listitem")).toHaveLength(1), + ); + await t.user.clear(screen.getByRole("searchbox")); + await vi.waitFor(() => + expect(within(list).getByText("1 of 21")).toBeVisible(), + ); + } finally { + await act(async () => release()); + } + }, +); it("keeps the current member page through live removals and clamps a shortened roster", async () => { const t = await setup("stream", false, false, undefined, undefined, { @@ -3485,7 +3516,7 @@ it("keeps the current member page through live removals and clamps a shortened r (i + 10000).toString(16).padStart(64, "0"), ); const pages = () => - screen.getByRole("navigation", { name: "People member pages" }); + screen.getByRole("navigation", { name: "Members member pages" }); const rows = () => within(screen.getByRole("region", { name: "Member list" })) .getAllByRole("listitem") @@ -3574,7 +3605,7 @@ it("loads another member page after a previous page fails", async () => { return original(filters); }); const next = within( - screen.getByRole("navigation", { name: "People member pages" }), + screen.getByRole("navigation", { name: "Members member pages" }), ).getByRole("button", { name: "Next" }); await t.user.click(next); await screen.findByText(/Some names could not load/); @@ -3593,6 +3624,213 @@ it("loads another member page after a previous page fails", async () => { ).not.toBeInTheDocument(); }); +it.each([ + [0, "agent"], + [60, "agent"], + [0, "bot"], +] as const)( + "classifies a roster with %s other members using %s evidence", + async (extraMembers, evidence) => { + const agent = keypair(); + let head = profile(agent, { name: "Relay agent" }); + const t = await setup( + "stream", + false, + false, + { + canOpenLink: () => true, + onOpenLink: () => true, + onOpenConversation: () => true, + }, + undefined, + { extraMembers, memberProfiles: [head] }, + ); + await vi.waitFor(() => + expect(memberData()).toHaveAttribute("data-refreshing", "false"), + ); + expect(screen.queryByRole("region", { name: "Agents" })).toBeNull(); + const original = t.query.getMockImplementation(); + if (!original) throw new Error("Missing query fixture"); + const id = "11111111-1111-4111-8111-111111111111"; + const keys = t.session.channels.get?.(id)?.members ?? []; + let marked = true; + let clock = 1800000000; + t.query.mockImplementation(async (filters) => { + const events = (await original(filters)).filter( + (event) => ![0, 39001, 39002].includes(event.kind), + ); + return [ + ...events, + head, + signed(t.relay, { + kind: 39001, + created_at: clock, + content: "", + tags: [["d", id]], + }), + signed(t.relay, { + kind: 39002, + created_at: clock, + content: "", + tags: [ + ["d", id], + ...keys.map((key) => [ + "p", + key, + "", + marked && evidence === "bot" && key === agent.pubkey + ? "bot" + : "member", + ]), + ...(marked && evidence === "agent" + ? [["agent", agent.pubkey]] + : []), + ], + }), + ].filter((event) => + filters.some((filter) => matchesEvent(event, filter)), + ); + }); + await act(async () => t.session.memberAdministration.refresh(id)); + const group = await screen.findByRole("region", { + name: evidence === "agent" ? "Agents" : "Members", + }); + const button = within(group).getByRole("button", { + name: /Open profile for Relay agent/, + }); + expect(button).toBeVisible(); + expect(t.session.directMessages.available).toBe(true); + if (evidence === "agent") { + expect(button).toHaveAccessibleName(/\), agent(?:,|$)/); + expect(within(group).getAllByRole("listitem")).toHaveLength(1); + } else { + expect( + t.session.memberAdministration.snapshot(id).authority.roles[ + agent.pubkey + ], + ).toBe("bot"); + expect(screen.queryByRole("region", { name: "Agents" })).toBeNull(); + expect(button).not.toHaveAccessibleName(/\), agent(?:,|$)/); + } + expect( + screen.queryByRole("button", { name: /Open owner profile/ }), + ).toBeNull(); + button.focus(); + await t.user.keyboard("{Shift>}{F10}{/Shift}"); + expect( + screen.queryByRole("menuitem", { + name: /Remove|Send message|View owner/, + }), + ).toBeNull(); + await t.user.keyboard("{Escape}"); + head = signed(agent, { + kind: 0, + created_at: ++clock, + tags: [], + content: JSON.stringify({ + name: "Relay agent", + is_agent: false, + bot: false, + }), + }); + await act(async () => + t.session.read([{ kinds: [0], authors: [agent.pubkey], limit: 1 }], { + fresh: true, + }), + ); + expect( + t.session.profiles.snapshot().get(agent.pubkey)?.isAgent, + ).toBeFalsy(); + expect( + within(group).getByRole("button", { + name: /Open profile for Relay agent/, + }), + ).toBeVisible(); + marked = false; + clock++; + await act(async () => t.session.memberAdministration.refresh(id)); + expect(screen.queryByRole("region", { name: "Agents" })).toBeNull(); + expect(screen.getByRole("region", { name: "Members" })).toBeVisible(); + expect(t.publish).not.toHaveBeenCalled(); + }, +); + +it("keeps an agent page and focus stable when only its evidence changes", async () => { + const memberProfiles = Array.from({ length: 125 }, (_, i) => + profile(keypair(), { + name: `Agent ${String(i).padStart(3, "0")}`, + is_agent: true, + }), + ); + const t = await setup( + "stream", + false, + false, + { + canOpenLink: () => true, + onOpenLink: () => true, + }, + undefined, + { memberProfiles }, + ); + await act(async () => + t.session.profiles.ensure(memberProfiles.map((event) => event.pubkey)), + ); + await t.user.click(screen.getByRole("button", { name: "Close" })); + await t.user.click(screen.getByRole("button", { name: "Access" })); + const agents = await screen.findByRole("region", { name: "Agents" }); + await t.user.click( + within( + screen.getByRole("navigation", { name: "Agents member pages" }), + ).getByRole("button", { name: "Next" }), + ); + const rows = within(agents).getAllByRole("listitem"); + const first = rows[0]; + if (!first) throw new Error("Missing agent row"); + const focused = within(first).getByRole("button", { name: /Open profile/ }); + focused.focus(); + const original = t.query.getMockImplementation(); + const target = memberProfiles[0]?.pubkey; + if (!original || !target) throw new Error("Missing fixture"); + const id = "11111111-1111-4111-8111-111111111111"; + const keys = t.session.channels.get?.(id)?.members ?? []; + let clock = 1800000000; + for (const marked of [true, false]) { + clock++; + t.query.mockImplementation(async (filters) => + [ + ...(await original(filters)).filter( + (event) => ![39001, 39002].includes(event.kind), + ), + signed(t.relay, { + kind: 39001, + created_at: clock, + content: "", + tags: [["d", id]], + }), + signed(t.relay, { + kind: 39002, + created_at: clock, + content: "", + tags: [ + ["d", id], + ...keys.map((key) => ["p", key, "", "member"]), + ...(marked ? [["agent", target]] : []), + ], + }), + ].filter((event) => + filters.some((filter) => matchesEvent(event, filter)), + ), + ); + await act(async () => t.session.memberAdministration.refresh(id)); + expect( + t.session.memberAdministration.snapshot(id).authority.agentPubkeys, + ).toEqual(marked ? [target] : []); + expect(within(agents).getAllByRole("listitem")).toEqual(rows); + expect(focused).toHaveFocus(); + } +}); + it("keeps the visible member page and focus stable while names arrive", async () => { let release!: () => void; const pending = new Promise((resolve) => { @@ -3611,7 +3849,7 @@ it("keeps the visible member page and focus stable while names arrive", async () }); const list = screen.getByRole("region", { name: "Member list" }); const pages = within(list).getByRole("navigation", { - name: "People member pages", + name: "Members member pages", }); await t.user.click(within(pages).getByRole("button", { name: "Next" })); const rows = within(list).getAllByRole("listitem"); @@ -3656,7 +3894,7 @@ it("keeps named members in order through live roster changes", async () => { pendingMemberNames: pending, }); const pages = screen.getByRole("navigation", { - name: "People member pages", + name: "Members member pages", }); await t.user.click(within(pages).getByRole("button", { name: "Next" })); await act(async () => release()); @@ -3742,7 +3980,7 @@ it("retains visible and unrelated profiles when opening a roster above directory .every((row) => /Member |Carl/.test(row.textContent ?? "")), ).toBe(true); const pages = within(list).getByRole("navigation", { - name: "People member pages", + name: "Members member pages", }); await t.user.click(within(pages).getByRole("button", { name: "Next" })); await vi.waitFor(() => diff --git a/src/bundled/channels/ChannelMembersDialog.tsx b/src/bundled/channels/ChannelMembersDialog.tsx index 1b169435b2..99d8ea5824 100644 --- a/src/bundled/channels/ChannelMembersDialog.tsx +++ b/src/bundled/channels/ChannelMembersDialog.tsx @@ -861,6 +861,19 @@ export function ChannelMembersDialog({ new Map(agentChoices.identities.map((agent) => [agent.pubkey, agent])), [agentChoices.identities], ); + // Channel role and editable profile hints cannot erase positive agent evidence. + const trustedAgents = useMemo( + () => + new Set([ + ...known.keys(), + ...(administration.authority.agentPubkeys ?? []), + ]), + [known, administration.authority], + ); + const agentIdentity = useCallback( + (key: string) => trustedAgents.has(key) || !!profiles.get(key)?.isAgent, + [trustedAgents, profiles], + ); const search = useMemberSearch( session, query, @@ -926,10 +939,9 @@ export function ChannelMembersDialog({ (a, b) => memberLabel(a).localeCompare(memberLabel(b)) || a.localeCompare(b), ); - const currentGroups = ["People", "Agents"].map((name) => { + const currentGroups = ["Members", "Agents"].map((name) => { const keys = currentMembers.filter((key) => { - const group = - known.has(key) || profiles.get(key)?.isAgent ? "Agents" : "People"; + const group = agentIdentity(key) ? "Agents" : "Members"; return group === name; }); return { @@ -941,7 +953,12 @@ export function ChannelMembersDialog({ // Optional profile updates change labels, not the identities being browsed. // Reopening or refreshing takes a new paging order. Live roster changes // retain surviving identities and append new members in their current order. + const trustedKey = currentMembers + .filter((key) => trustedAgents.has(key)) + .sort() + .join(":"); const [memberOrder, setMemberOrder] = useState({ + trustedKey, session, channelId, memberKey, @@ -954,26 +971,42 @@ export function ChannelMembersDialog({ memberOrder.channelId !== channelId || memberOrder.refresh !== refresh || memberOrder.initialLoading !== initialLoading; - const previousMembers = new Set( - memberOrder.groups.flatMap((group) => group.keys), + const previousGroups = new Map( + memberOrder.groups.flatMap((group) => + group.keys.map((key) => [key, group.name] as const), + ), ); + const previouslyTrusted = new Set(memberOrder.trustedKey.split(":")); + const moved = new Set( + currentMembers.filter( + (key) => + previouslyTrusted.has(key) !== trustedAgents.has(key) && + previousGroups.get(key) !== (agentIdentity(key) ? "Agents" : "Members"), + ), + ); + const orderChanged = + memberOrder.memberKey !== memberKey || + memberOrder.trustedKey !== trustedKey; const orderedGroups = resetOrder || !largeRoster ? currentGroups - : memberOrder.memberKey !== memberKey + : orderChanged ? memberOrder.groups.map((group) => { const keys = [ - ...group.keys.filter((key) => members.has(key)), + ...group.keys.filter( + (key) => members.has(key) && !moved.has(key), + ), ...( currentGroups.find((current) => current.name === group.name) ?.keys ?? [] - ).filter((key) => !previousMembers.has(key)), + ).filter((key) => !previousGroups.has(key) || moved.has(key)), ]; return { ...group, keys, count: keys.length }; }) : memberOrder.groups; - if (resetOrder || memberOrder.memberKey !== memberKey) + if (resetOrder || orderChanged) setMemberOrder({ + trustedKey, session, channelId, memberKey, @@ -1117,11 +1150,7 @@ export function ChannelMembersDialog({ agentChoices.selectable.map((agent) => agent.pubkey), ); const agentCandidate = (person: { pubkey: string; isAgent?: boolean }) => - !!( - person.isAgent || - known.has(person.pubkey) || - profiles.get(person.pubkey)?.isAgent - ); + !!(person.isAgent || agentIdentity(person.pubkey)); const candidateGroup = (person: { pubkey: string; isAgent?: boolean }) => known.get(person.pubkey)?.managed ? 0 @@ -1155,7 +1184,7 @@ export function ChannelMembersDialog({ available.length > 0)); const invitationGroups = [ "Your agents", - "People", + "Others", "Other agents", "Your other agents", ].map((name, index) => { @@ -1221,6 +1250,8 @@ export function ChannelMembersDialog({ const shareChannels = offering && solo && text ? share.channels : []; // Keep roster ownership observed while filtering; retiring the view clears // valid hints and forces every retained agent row through recovery renders. + // Relay markers carry no public owner; they must not expand ownership reads + // beyond the existing inventory and page-loaded profile demand. const agentKeys = [ ...new Set([ ...currentMembers.filter( @@ -1446,7 +1477,7 @@ export function ChannelMembersDialog({ }; const searchAgents = search.people - .filter((person) => person.isAgent) + .filter(agentCandidate) .map((person) => person.pubkey) .sort() .join(":"); @@ -1461,8 +1492,7 @@ export function ChannelMembersDialog({ !session.directMessages.available || !session.viewer || key === session.viewer || - known.has(key) || - profiles.get(key)?.isAgent || + agentIdentity(key) || searchAgents.split(":").includes(key) || administration.authority.roles[key] === "bot" ) @@ -1501,8 +1531,7 @@ export function ChannelMembersDialog({ session, onOpenConversation, close, - profiles, - known, + agentIdentity, searchAgents, administration.authority.roles, ], @@ -1529,7 +1558,7 @@ export function ChannelMembersDialog({ agent?: boolean, ) => { const role = administration.authority.roles[key]; - const isAgent = !!(agent || known.has(key) || profiles.get(key)?.isAgent); + const isAgent = !!(agent || agentIdentity(key)); const owner = isAgent ? ownership.owners.get(key) : undefined; const target = profileTarget(key); const ownerTarget = owner ? profileTarget(owner) : undefined; @@ -1611,9 +1640,7 @@ export function ChannelMembersDialog({ /> ); }; - const selectedAgent = - selection && - (known.has(selection.pubkey) || profiles.get(selection.pubkey)?.isAgent); + const selectedAgent = selection && agentIdentity(selection.pubkey); const removalTarget = selectedAgent ? "agent" : "member"; const selectedPicture = selection && profiles.get(selection.pubkey)?.picture; const selectedName = @@ -1631,9 +1658,7 @@ export function ChannelMembersDialog({ fallback={share.names(key)} src={avatarMedia(profiles.get(key)?.picture, session.media)} size="small" - shape={ - known.has(key) || profiles.get(key)?.isAgent ? "squircle" : "circle" - } + shape={agentIdentity(key) ? "squircle" : "circle"} /> {share.names(key)} @@ -2154,8 +2179,8 @@ export function ChannelMembersDialog({
{ @@ -721,7 +721,7 @@ it("presents verified roles, protects owners/self, and confirms a separate delib await expectProfileOnly(t.user, "Carl"); await expectProfileOnly(t.user, "Owner"); const row = required(screen.getByText("Morgan").closest("li")); - expect(row.closest("section")).toHaveAttribute("aria-label", "People"); + expect(row.closest("section")).toHaveAttribute("aria-label", "Members"); let dialog = await t.choose("Set as admin"); await vi.waitFor(() => expect( @@ -753,7 +753,7 @@ it("presents verified roles, protects owners/self, and confirms a separate delib screen .getByRole("button", { name: /Open profile for Morgan/ }) .closest("section"), - ).toHaveAttribute("aria-label", "People"); + ).toHaveAttribute("aria-label", "Members"); expect(t.publish).toHaveBeenCalledOnce(); }); it.each(["cancel", "close", "escape", "outside"])( @@ -920,7 +920,7 @@ it("changes an existing Guest to Member only after explicit confirmation", async screen .getByRole("button", { name: /Open profile for Morgan/ }) .closest("section"), - ).toHaveAttribute("aria-label", "People"); + ).toHaveAttribute("aria-label", "Members"); expect(t.publish).toHaveBeenCalledOnce(); }); it.each([false, true])( @@ -942,7 +942,7 @@ it.each([false, true])( expect( within(required(row.closest("li"))).queryByText("Agent"), ).not.toBeInTheDocument(); - expect(row.closest("section")).toHaveAttribute("aria-label", "People"); + expect(row.closest("section")).toHaveAttribute("aria-label", "Members"); expect(row).toHaveAccessibleName(/, bot/); } expect( @@ -1064,7 +1064,7 @@ it("keeps unconfirmed roles and session recovery across close/reopen without rep screen .getByRole("button", { name: /Open profile for Morgan/ }) .closest("section"), - ).toHaveAttribute("aria-label", "People"); + ).toHaveAttribute("aria-label", "Members"); await expectProfileOnly(t.user, "Morgan"); await t.user.click(screen.getByRole("button", { name: "Close" })); await vi.waitFor(() => @@ -1220,7 +1220,7 @@ it.each([true, false])( "applies live roster and role reads without writes (writer: %s)", async (supported) => { const t = await setup("owner", "member", supported); - const section = screen.getByRole("region", { name: "People" }); + const section = screen.getByRole("region", { name: "Members" }); expect( screen.queryByRole("button", { name: "Refresh member data" }), ).toBeNull(); @@ -1358,7 +1358,7 @@ it.each([false, true])( } expect(targetRow().closest("section")).toHaveAttribute( "aria-label", - "People", + "Members", ); expect(t.publish).not.toHaveBeenCalled(); }, @@ -1452,7 +1452,7 @@ it("holds duplicate refreshes, retains the roster on read failure, and recovers screen .getByRole("button", { name: /Open profile for Morgan/ }) .closest("section"), - ).toHaveAttribute("aria-label", "People"); + ).toHaveAttribute("aria-label", "Members"); const recovery = deferred(); t.query.mockImplementation(async (filters) => { await recovery.promise; @@ -1848,7 +1848,7 @@ it("filters by present groups and resets to All on any search input", async () = await screen.findByRole("menuitemradio", { name: /^All/ }); expect( screen.getAllByRole("menuitemradio").map((item) => item.textContent), - ).toEqual(["All · 3", "People · 2", "Agents · 1"]); + ).toEqual(["All · 3", "Members · 2", "Agents · 1"]); expect( screen.getByRole("menuitemradio", { name: "All · 3" }), ).toHaveAttribute("aria-checked", "true"); @@ -1893,7 +1893,7 @@ it("hides the identity filter when everyone is a person, including owners", asyn expect( screen.queryByRole("button", { name: /^Filter people and agents:/ }), ).not.toBeInTheDocument(); - expect(screen.getByRole("heading", { name: "People · 3" })).toBeVisible(); + expect(screen.getByRole("heading", { name: "Members · 3" })).toBeVisible(); }); it("keeps agent owners in the Agents filter", async () => { diff --git a/src/features/channel-members/administration-protocol.ts b/src/features/channel-members/administration-protocol.ts index 5fe8bf6a68..8a5f370cc7 100644 --- a/src/features/channel-members/administration-protocol.ts +++ b/src/features/channel-members/administration-protocol.ts @@ -20,6 +20,8 @@ export type MemberChange = Readonly<{ }>; export type MemberAuthority = Readonly<{ roles: Readonly>; + /** Positive relay evidence; absence does not establish a human identity. */ + agentPubkeys?: readonly string[]; canManage: boolean; canRemove: boolean; canRemoveOwnedAgent: boolean; @@ -98,6 +100,19 @@ export function memberAuthority( const solo = !!soloLabels(channelIdentity(metadata).labels); return Object.freeze({ roles: Object.freeze(roles), + agentPubkeys: Object.freeze([ + ...new Set( + roster.tags.flatMap(([tag, key, ...rest]) => + tag === "agent" && + key && + PUBKEY.test(key) && + key in roles && + !rest.length + ? [key] + : [], + ), + ), + ]), canManage: activeChannel && !solo && elevated.has(viewer), canRemove: activeChannel && elevated.has(viewer), canRemoveOwnedAgent: diff --git a/src/features/channel-members/administration.test.ts b/src/features/channel-members/administration.test.ts index b492a82b59..a3247d340f 100644 --- a/src/features/channel-members/administration.test.ts +++ b/src/features/channel-members/administration.test.ts @@ -16,6 +16,7 @@ import { } from "./administration"; import { authorizeMemberChange, + canRemoveMember, memberAuthority, validateMemberAdministrationTemplate, type MemberChange, @@ -506,6 +507,92 @@ it("does not fabricate Member for missing or unknown role hints", () => { "unknown", ); }); +it.each(["member", "bot", "admin", "owner", "guest"])( + "reads positive agent evidence independently of the %s role", + (role) => { + const h = harness("member", role); + const before = memberAuthority(h.events(), id, viewer, author); + const events = h + .events() + .map((event) => + event.kind === 39002 + ? h.record(39002, [...event.tags, ["agent", target]]) + : event, + ); + const authority = memberAuthority(events, id, viewer, author); + expect(authority.agentPubkeys).toEqual([target]); + expect({ ...authority, agentPubkeys: before.agentPubkeys }).toEqual(before); + expect(canRemoveMember(authority, viewer, target)).toBe(false); + }, +); + +it("accepts agent traits only for members of the selected relay roster", () => { + const h = harness(); + const roster = required(h.events()[2]); + const invalid = [ + ["agent"], + ["agent", target, "extra"], + ["agent", target.toUpperCase()], + ["agent", "bad-key"], + ["agent", author], // Not a member. + ]; + const events = [ + ...h.events().slice(0, 2), + h.record(39002, [...roster.tags, ...invalid]), + ]; + for (const foreign of [ + h.record(39002, [ + ["d", otherId], + ["p", target], + ["agent", target], + ]), + finalizeEvent( + { ...roster, tags: [...roster.tags, ["agent", target]] }, + key, + ), + ]) + expect(() => + memberAuthority([...events, foreign], id, viewer, author), + ).toThrow(); + expect( + memberAuthority(events, id, viewer, author).agentPubkeys ?? [], + ).toEqual([]); + events.push( + h.record(39002, [...roster.tags, ["agent", target], ["agent", target]]), + ); + expect(memberAuthority(events, id, viewer, author).agentPubkeys).toEqual([ + target, + ]); + events.push(h.record(39002, roster.tags)); + expect( + memberAuthority(events, id, viewer, author).agentPubkeys ?? [], + ).toEqual([]); +}); + +it("does not restore agent evidence after authority is cleared and refresh fails", async () => { + const h = harness(); + h.setEvents( + h + .events() + .map((event) => + event.kind === 39002 + ? h.record(39002, [...event.tags, ["agent", target]]) + : event, + ), + ); + await h.owner.capability.refresh(id); + expect(h.owner.capability.snapshot(id).authority.agentPubkeys).toEqual([ + target, + ]); + h.owner.clear(); + h.read.mockRejectedValueOnce(new Error("Offline")); + await h.owner.capability.refresh(id); + expect(h.owner.capability.snapshot(id).status).toBe("error"); + expect(h.owner.capability.snapshot(id).authority.agentPubkeys ?? []).toEqual( + [], + ); +}); + it.each(["actor", "target", "removed", "access"])( "rechecks %s after signing, before publishing", async (what) => { diff --git a/src/features/channel-members/members.test.ts b/src/features/channel-members/members.test.ts index e97368f3a9..f878d31ae3 100644 --- a/src/features/channel-members/members.test.ts +++ b/src/features/channel-members/members.test.ts @@ -988,7 +988,7 @@ it("reports a recovered Guest and permits a fresh Member invite only after expli const original = await savedNoRoleInvitation(t); t.confirmPerson("guest"); await expect(t.managedAdd()).rejects.toThrow( - /remove them in People.*invite them again/i, + /remove them in Members.*invite them again/i, ); expect(t.publish).toHaveBeenCalledOnce(); expect(t.records()[0]).toMatchObject({ @@ -997,7 +997,7 @@ it("reports a recovered Guest and permits a fresh Member invite only after expli }); t.restart(); await t.ready(); - await expect(t.managedAdd()).rejects.toThrow(/remove them in People/i); + await expect(t.managedAdd()).rejects.toThrow(/remove them in Members/i); expect(t.publish).toHaveBeenCalledOnce(); t.removePerson(); await t.managedAdd(); @@ -1018,7 +1018,7 @@ it("preserves a legacy invitation while signed roles are unavailable", async () expect(t.records()[0]?.acknowledged).toBeUndefined(); expect(t.publish).toHaveBeenCalledOnce(); t.setRolesAvailable(true); - await expect(t.managedAdd()).rejects.toThrow(/remove them in People/i); + await expect(t.managedAdd()).rejects.toThrow(/remove them in Members/i); expect(t.records()[0]?.acknowledged).toBe(true); }); it("replaces a definitively failed no-role invitation only after explicit Outbox dismissal", async () => { @@ -1292,7 +1292,7 @@ it.each([false, true])( afterInvitationWindow(t); if (recorded) t.recordOriginal(); t.onInvitationRead(() => t.confirmPerson("guest")); - await expect(t.managedAdd()).rejects.toThrow(/remove them in People/); + await expect(t.managedAdd()).rejects.toThrow(/remove them in Members/); expect(t.publish).toHaveBeenCalledOnce(); expect(t.records()[0]?.acknowledged).toBe(true); }, diff --git a/src/features/channel-members/members.ts b/src/features/channel-members/members.ts index b6a950826a..7cea9cef37 100644 --- a/src/features/channel-members/members.ts +++ b/src/features/channel-members/members.ts @@ -294,7 +294,7 @@ export async function addChannelMember( check(); intent.confirmed = true; throw new Error( - "This earlier invitation granted view-only access. To grant participation, remove them in People and invite them again.", + "This earlier invitation granted view-only access. To grant participation, remove them in Members and invite them again.", ); } } diff --git a/src/features/solo/ConvertToChannel.test.tsx b/src/features/solo/ConvertToChannel.test.tsx index ca55546a3d..1581623972 100644 --- a/src/features/solo/ConvertToChannel.test.tsx +++ b/src/features/solo/ConvertToChannel.test.tsx @@ -263,8 +263,8 @@ it.each(policies.flatMap((from) => policies.map((to) => ({ from, to }))))( if (from.visibility === "public" && to.visibility === "private") { expect(screen.getByRole("dialog")).toHaveAccessibleDescription( from.joinRole === "guest" - ? "People who joined while it was View only are members and keep reading until you remove them in People." - : "People who already joined remain members and keep their access until you remove them in People.", + ? "People who joined while it was View only are members and keep reading until you remove them in Members." + : "People who already joined remain members and keep their access until you remove them in Members.", ); } expect(h.publish).not.toHaveBeenCalled(); diff --git a/src/features/solo/labels.ts b/src/features/solo/labels.ts index 5b8923f284..930f3fd95e 100644 --- a/src/features/solo/labels.ts +++ b/src/features/solo/labels.ts @@ -18,9 +18,9 @@ export function soloRetainedAccessWarning( ): string { if (base.visibility !== "public") return ""; if (base.joinRole !== "guest") - return "People who already joined remain members and keep their access until you remove them in People."; + return "People who already joined remain members and keep their access until you remove them in Members."; return mayHaveGuestMembers - ? "People who joined while it was View only are members and keep reading until you remove them in People." + ? "People who joined while it was View only are members and keep reading until you remove them in Members." : ""; } diff --git a/tests/browser/channel-members-focus.spec.mjs b/tests/browser/channel-members-focus.spec.mjs index febfe76f3a..f57d07e51c 100644 --- a/tests/browser/channel-members-focus.spec.mjs +++ b/tests/browser/channel-members-focus.spec.mjs @@ -345,7 +345,10 @@ test("member identity geometry and separate addition focus", async ({ expect(field.y - header.y - header.height).toBeCloseTo(12, 0); await expect(add).toHaveCSS("font-size", "12px"); expect((await add.boundingBox()).height).toBe(24); - const members = dialog.getByRole("region", { name: "People", exact: true }); + const members = dialog.getByRole("region", { + name: "Members", + exact: true, + }); const others = dialog.getByRole("region", { name: "Not in this channel", exact: true, @@ -358,10 +361,10 @@ test("member identity geometry and separate addition focus", async ({ await expect( dialog .getByRole("region", { - name: "People not in this channel", + name: "Others not in this channel", exact: true, }) - .getByRole("heading", { name: "People", exact: true }), + .getByRole("heading", { name: "Others", exact: true }), ).toHaveCSS("position", "sticky"); // With no roster matches, search results start at the top without an orphan divider. await expect(others).toHaveCSS("border-top-width", "0px"); @@ -919,7 +922,7 @@ test("member identity filter sits inside search and preserves modal focus", asyn ); // Reopening after every choice must retain both width and right edge, // including each identity group and its selection mark. - for (const name of ["People · 1", "All · 61", "Agents · 60"]) { + for (const name of ["Members · 1", "All · 61", "Agents · 60"]) { await page.getByRole("menuitemradio", { name, exact: true }).click(); await expect(popup).toHaveCount(0); await filter.click(); @@ -940,7 +943,7 @@ test("member identity filter sits inside search and preserves modal focus", asyn "Filter people and agents: Agents", ); await expect( - dialog.getByRole("region", { name: "People", exact: true }), + dialog.getByRole("region", { name: "Members", exact: true }), ).toHaveCount(0); await search.fill("Agent 12"); await expect(dialog.getByRole("listitem")).toHaveCount(1); @@ -1182,7 +1185,7 @@ for (const rejected of [false, true]) { } else { await expect( members - .getByRole("region", { name: "People", exact: true }) + .getByRole("region", { name: "Members", exact: true }) .getByRole("button", { name: /Open profile for Morgan/ }), ).toHaveCount(0); await expect(page.locator(".buzz-toast")).toHaveCount(0); @@ -1216,7 +1219,7 @@ test("member helper aligns with content and Owner stays beside a person's name", name: "Manage members", exact: true, }); - const heading = dialog.getByRole("heading", { name: /^People ·/ }); + const heading = dialog.getByRole("heading", { name: /^Members ·/ }); if (visitor) { // A visitor cannot read the roster, so no empty People group renders. const helper = dialog.getByText( @@ -1322,7 +1325,7 @@ test("Solo adds an agent with pointer or Enter and retains the search focus", as await search.fill("Morgan"); await expect(add).toBeEnabled(); await expect( - dialog.getByRole("region", { name: "People", exact: true }), + dialog.getByRole("region", { name: "Members", exact: true }), ).toHaveCount(0); await expect(dialog.locator("mark")).toHaveCount(0); const neutralFill = await add.locator("../..").evaluate((row) => { @@ -1354,7 +1357,7 @@ test("Solo adds an agent with pointer or Enter and retains the search focus", as ).toHaveClass("sr-only"); await expect(search).toHaveValue(""); await expect( - dialog.getByRole("region", { name: "People", exact: true }), + dialog.getByRole("region", { name: "Members", exact: true }), ).toContainText("Carl"); await expect(search).toBeFocused(); await expect( diff --git a/tests/browser/channel-members-scale.spec.mjs b/tests/browser/channel-members-scale.spec.mjs index 2110d88808..cdc0804e9e 100644 --- a/tests/browser/channel-members-scale.spec.mjs +++ b/tests/browser/channel-members-scale.spec.mjs @@ -32,14 +32,14 @@ test("large channel members stays usable while browsing and searching the roster await expect(dialog.getByText("Loading members…")).toHaveCount(0); await expect(list.getByRole("listitem").first()).toBeVisible(); await expect( - list.getByRole("heading", { name: "People · 2526" }), + list.getByRole("heading", { name: "Members · 2526" }), ).toBeVisible(); expect(await list.getByRole("listitem").count()).toBeLessThan(100); - const pages = list.getByRole("navigation", { name: "People member pages" }); + const pages = list.getByRole("navigation", { name: "Members member pages" }); await pages.getByRole("button", { name: "Next" }).click(); await expect(pages.getByText("2 of 51")).toBeVisible(); await expect( - list.getByRole("heading", { name: "People · 2526" }), + list.getByRole("heading", { name: "Members · 2526" }), ).toBeFocused(); await expect(list.getByRole("listitem").first()).toBeInViewport(); await pages.getByRole("button", { name: "Previous" }).click(); diff --git a/tests/browser/channel-members.spec.mjs b/tests/browser/channel-members.spec.mjs index 263154dd65..b705da08f6 100644 --- a/tests/browser/channel-members.spec.mjs +++ b/tests/browser/channel-members.spec.mjs @@ -39,7 +39,7 @@ test("channel members opens from the header, fits each viewport, and returns key }); await expect(dialog).toBeVisible(); await expect(dialog.getByRole("searchbox")).toBeFocused(); - await expect(dialog.getByText(/People ·/)).toBeVisible(); + await expect(dialog.getByText(/Members ·/)).toBeVisible(); await expect(dialog.getByText("Loading members…")).toHaveCount(0); // Finish the entrance before measuring; content changes must not recenter it. await dialog.evaluate(async (node) => { diff --git a/tests/browser/profiles.spec.mjs b/tests/browser/profiles.spec.mjs index 5398bbd037..aaea102a58 100644 --- a/tests/browser/profiles.spec.mjs +++ b/tests/browser/profiles.spec.mjs @@ -778,7 +778,7 @@ test("member rows hand off to Profiles without trapping or losing keyboard focus // The popup owns modal semantics; the active step owns the visible card. const surface = dialog.locator(".buzz-dialog-step:not([inert])"); const body = dialog.locator(".buzz-dialog-body"); - const group = dialog.getByRole("region", { name: "People", exact: true }); + const group = dialog.getByRole("region", { name: "Members", exact: true }); const list = dialog.getByRole("region", { name: "Member list", exact: true }); const search = dialog.getByRole("searchbox"); // Browser-only: one flexing list owns scrolling; title and search diff --git a/tests/browser/solo-chats.spec.mjs b/tests/browser/solo-chats.spec.mjs index da97dcb701..9f5a1f29e9 100644 --- a/tests/browser/solo-chats.spec.mjs +++ b/tests/browser/solo-chats.spec.mjs @@ -824,7 +824,7 @@ test("View-only Solo conversion preserves access and restores focus after its ch await access.click(); await page.getByRole("option", { name: "Private", exact: true }).click(); const retainedWarning = - "People who joined while it was View only are members and keep reading until you remove them in People."; + "People who joined while it was View only are members and keep reading until you remove them in Members."; await expect(dialog).toHaveAccessibleDescription(retainedWarning); await access.scrollIntoViewIfNeeded(); await access.click({ trial: true }); diff --git a/tests/browser/solo-legacy-invitation.spec.mjs b/tests/browser/solo-legacy-invitation.spec.mjs index cbe5448426..107a76f9eb 100644 --- a/tests/browser/solo-legacy-invitation.spec.mjs +++ b/tests/browser/solo-legacy-invitation.spec.mjs @@ -224,7 +224,7 @@ test("Outbox Retry reconciles a recorded then removed Solo invitation without re await expect(summary).toHaveText("Outbox · 0 items"); expect(publications).toHaveLength(0); - // Only an explicit People Add may re-invite the removed person. + // Only an explicit Add may re-invite the removed person. await page.goto( `${app.origin}/#buzz=${encodeURIComponent( JSON.stringify({ @@ -267,7 +267,7 @@ test("Outbox Retry reconciles a recorded then removed Solo invitation without re .toBe(true); await expect( access - .getByRole("region", { name: "People", exact: true }) + .getByRole("region", { name: "Members", exact: true }) .getByRole("button", { name: "Actions for Aria", exact: true }), ).toBeVisible(); await page.reload(); diff --git a/tests/browser/solo-public-access.spec.mjs b/tests/browser/solo-public-access.spec.mjs index 3f3594056f..91b8b1b9a1 100644 --- a/tests/browser/solo-public-access.spec.mjs +++ b/tests/browser/solo-public-access.spec.mjs @@ -244,7 +244,7 @@ test.describe("owner", () => { await audience.click(); await page.getByRole("option", { name: "Private", exact: true }).click(); await expect(confirm).toHaveAccessibleDescription( - "Only chat members will have access. People who joined while it was View only are members and keep reading until you remove them in People.", + "Only chat members will have access. People who joined while it was View only are members and keep reading until you remove them in Members.", ); await expect( confirm.getByRole("button", { name: "Cancel", exact: true }), @@ -288,7 +288,7 @@ test.describe("owner", () => { await audience.click(); await page.getByRole("option", { name: "Private", exact: true }).click(); await expect(confirm).toHaveAccessibleDescription( - "Only chat members will have access. People who already joined remain members and keep their access until you remove them in People.", + "Only chat members will have access. People who already joined remain members and keep their access until you remove them in Members.", ); await expect( confirm.getByRole("button", { name: "Change access", exact: true }), @@ -430,7 +430,7 @@ test.describe("visitor", () => { ).toHaveAttribute("data-refreshing", "false"); await expect(existingAccess.getByRole("alert")).toHaveCount(0); await expect( - existingAccess.getByRole("region", { name: "People", exact: true }), + existingAccess.getByRole("region", { name: "Members", exact: true }), ).toBeVisible(); await expect( existingAccess.getByRole("region", { name: "General access" }), diff --git a/tests/browser/solo-sharing.spec.mjs b/tests/browser/solo-sharing.spec.mjs index 0c93460072..9ad483fa9b 100644 --- a/tests/browser/solo-sharing.spec.mjs +++ b/tests/browser/solo-sharing.spec.mjs @@ -202,9 +202,9 @@ test("Share dialog keeps actions reachable across viewports and enlarged text an await expect( agents.getByText("Carol Fixture", { exact: true }), ).toBeVisible(); - // People holds the owner, the 14 legacy humans and Alice. - const people = dialog.getByRole("region", { name: "People", exact: true }); - await expect(people.getByRole("heading")).toHaveText("People · 16"); + // Members holds the owner, the 14 legacy humans and Alice. + const people = dialog.getByRole("region", { name: "Members", exact: true }); + await expect(people.getByRole("heading")).toHaveText("Members · 16"); await expect( people.getByText("Existing person 14", { exact: true }), ).toBeAttached(); diff --git a/tests/browser/solo-workspace.spec.mjs b/tests/browser/solo-workspace.spec.mjs index e2fadbb375..5aac309ead 100644 --- a/tests/browser/solo-workspace.spec.mjs +++ b/tests/browser/solo-workspace.spec.mjs @@ -62,7 +62,7 @@ async function showSharedMark(page, app, row) { }); await expect( dialog - .getByRole("region", { name: "People", exact: true }) + .getByRole("region", { name: "Members", exact: true }) .getByText("Shared teammate", { exact: true }), ).toBeVisible(); await dialog.getByRole("button", { name: "Done", exact: true }).click();