Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion docs/channels.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
28 changes: 24 additions & 4 deletions src/bundled/channels/MemberAdministration.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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/ });
Expand Down Expand Up @@ -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();
},
);
Expand Down Expand Up @@ -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();
},
);
19 changes: 9 additions & 10 deletions src/bundled/channels/MemberAdministration.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -203,16 +203,15 @@ export function MemberRow({
{permitted && !locked && (
<>
<MenuSeparator />
{role !== "bot" &&
editableMemberRoles
// Guest assignment is hidden until its permission contract is settled.
.filter((next) => next !== role && next !== "guest")
.map((next) => (
<MenuItem key={next} onClick={() => choose(next)}>
Make {next}
</MenuItem>
))}
{role !== "bot" && <MenuSeparator />}
{editableMemberRoles
// Guest assignment is hidden until its permission contract is settled.
.filter((next) => next !== role && next !== "guest")
.map((next) => (
<MenuItem key={next} onClick={() => choose(next)}>
Make {next}
</MenuItem>
))}
<MenuSeparator />
<MenuItem tone="danger" onClick={() => choose("remove")}>
Remove from channel
</MenuItem>
Expand Down
3 changes: 1 addition & 2 deletions src/features/channel-members/administration-protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
26 changes: 26 additions & 0 deletions src/features/channel-members/administration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand All @@ -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) => {
Expand Down
Loading