diff --git a/docs/channels.md b/docs/channels.md index cbc285b5b..7e51cc127 100644 --- a/docs/channels.md +++ b/docs/channels.md @@ -817,9 +817,11 @@ another non-owner member: | Target role | Change role | Remove from this channel | | --- | --- | --- | | Admin / Member / Guest | Admin / Member, excluding the current role | Yes | -| Bot | No conversion | Yes | +| Bot | Admin / Member, after explicit confirmation | Yes | | Owner, self, unknown or inconsistent | No | No | +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 deliberately omits **Make guest** while Guest's permission contract is unsettled: the inspected relay message path does not enforce the role's documented read-only meaning, while Git push policy does distinguish Guest from Member. diff --git a/src/bundled/channels/MemberAdministration.test.tsx b/src/bundled/channels/MemberAdministration.test.tsx index 557127fb0..b35c7ffcc 100644 --- a/src/bundled/channels/MemberAdministration.test.tsx +++ b/src/bundled/channels/MemberAdministration.test.tsx @@ -605,7 +605,7 @@ it("changes an existing Guest to Member only after explicit confirmation", async expect(t.publish).toHaveBeenCalledOnce(); }); it.each([false, true])( - "groups Bot by identity while retaining its accessible role and removal-only policy (Agent: %s)", + "groups Bot by identity while retaining its accessible role and explicit role controls (Agent: %s)", async (agent) => { const t = await setup("owner", "bot", true, undefined, agent); const row = screen.getByRole("button", { name: /Open profile for Morgan/ }); @@ -637,9 +637,8 @@ it.each([false, true])( expect( await screen.findByRole("menuitem", { name: "Remove from channel" }), ).toBeVisible(); - expect( - screen.queryByRole("menuitem", { name: /Make / }), - ).not.toBeInTheDocument(); + expect(screen.getByRole("menuitem", { name: "Make admin" })).toBeVisible(); + expect(screen.getByRole("menuitem", { name: "Make member" })).toBeVisible(); expect(t.publish).not.toHaveBeenCalled(); }, ); @@ -1377,3 +1376,24 @@ it("returns to All when a refreshed role group disappears and resets on reopen", await screen.findByText("Morgan"); expect(filter()).toHaveTextContent("All"); }); + +it.each(["owner", "admin"])( + "lets %s explicitly promote a bot while preserving verified agent identity", + async (actor) => { + const t = await setup(actor, "bot", true, undefined, true); + const dialog = await t.choose("Make admin"); + expect(dialog).toHaveTextContent("from bot to admin"); + expect(t.publish).not.toHaveBeenCalled(); + await t.user.click( + within(dialog).getByRole("button", { name: "Make admin" }), + ); + await screen.findByText("Member change confirmed."); + const row = screen.getByRole("button", { name: /Open profile for Morgan/ }); + expect(row.closest("section")).toHaveAttribute("aria-label", "Admins"); + expect( + row.closest("li")?.querySelector('[data-avatar-shape="squircle"]'), + ).toBeInTheDocument(); + expect(row).toHaveAccessibleName(/, admin$/); + expect(t.publish).toHaveBeenCalledOnce(); + }, +); diff --git a/src/bundled/channels/MemberAdministration.tsx b/src/bundled/channels/MemberAdministration.tsx index 2d428a251..320c04f29 100644 --- a/src/bundled/channels/MemberAdministration.tsx +++ b/src/bundled/channels/MemberAdministration.tsx @@ -203,16 +203,15 @@ export function MemberRow({ {permitted && !locked && ( <> - {role !== "bot" && - editableMemberRoles - // Guest assignment is hidden until its permission contract is settled. - .filter((next) => next !== role && next !== "guest") - .map((next) => ( - choose(next)}> - Make {next} - - ))} - {role !== "bot" && } + {editableMemberRoles + // Guest assignment is hidden until its permission contract is settled. + .filter((next) => next !== role && next !== "guest") + .map((next) => ( + choose(next)}> + Make {next} + + ))} + choose("remove")}> Remove from channel diff --git a/src/features/channel-members/administration-protocol.ts b/src/features/channel-members/administration-protocol.ts index 4bccad190..045149822 100644 --- a/src/features/channel-members/administration-protocol.ts +++ b/src/features/channel-members/administration-protocol.ts @@ -120,8 +120,7 @@ export function authorizeMemberChange( !canManageMember(state, viewer, change.pubkey) || state.roles[change.pubkey] !== change.expectedRole || (change.role !== "remove" && - (change.expectedRole === "bot" || - change.expectedRole === change.role || + (change.expectedRole === change.role || !editableMemberRoles.includes(change.role))) ) throw new Error( diff --git a/src/features/channel-members/administration.test.ts b/src/features/channel-members/administration.test.ts index 4f3266631..f2c7b3650 100644 --- a/src/features/channel-members/administration.test.ts +++ b/src/features/channel-members/administration.test.ts @@ -131,6 +131,23 @@ it.each(["admin", "member", "guest", "remove"] as const)( ]); }, ); +it.each(["owner", "admin"])( + "allows %s to explicitly promote a bot to admin with fresh role confirmation", + async (actor) => { + const h = harness(actor, "bot"); + await h.owner.capability.run(id, { ...change, expectedRole: "bot" }); + expect(h.publish).toHaveBeenCalledOnce(); + expect(h.publish.mock.calls[0]?.[0].tags).toEqual([ + ["h", id], + ["p", target], + ["role", "admin"], + ]); + expect(h.owner.capability.snapshot(id).operation?.status).toBe("confirmed"); + expect(h.owner.capability.snapshot(id).authority.roles[target]).toBe( + "admin", + ); + }, +); it.each(["owner", "bot", "unknown"])( "never coerces %s target roles", async (role) => { @@ -154,6 +171,15 @@ it("allows removing a bot without changing its role or invoking agent deletion", ], }); }); +it.each(["member", "guest", "bot"])( + "rejects explicit bot promotion by a %s viewer", + async (actor) => { + const h = harness(actor, "bot"); + await h.owner.capability.run(id, { ...change, expectedRole: "bot" }); + expect(h.sign).not.toHaveBeenCalled(); + expect(h.publish).not.toHaveBeenCalled(); + }, +); it.each(["member", "guest", "bot"])( "never grants administration to %s viewers", async (role) => {