diff --git a/browser-host/channel-commands.mjs b/browser-host/channel-commands.mjs
index 973f874f1e..1748cce9ca 100644
--- a/browser-host/channel-commands.mjs
+++ b/browser-host/channel-commands.mjs
@@ -22,7 +22,7 @@ export function validChannelCommand(event) {
if (clientId.length !== 2 || !uuid.test(clientId[1])) return false;
tags = tags.slice(0, -1);
}
- const [h, p] = tags;
+ const [h] = tags;
if (h?.length !== 2 || h[0] !== "h" || !uuid.test(h[1])) return false;
if (event.kind === 9007) {
const expected = ["h", "name", "visibility", "channel_type"];
@@ -85,19 +85,29 @@ export function validChannelCommand(event) {
String(Number(ttlValue)) === ttlValue))
);
}
- // An invitation may name the participant role, or the read-only guest role
- // Bestie joins private channels with. It never names a role above member.
- if (event.kind === 9000)
+ if (event.kind === 9000) {
+ // block/buzz#8575: up to 100 distinct `p` tags. An invitation may name
+ // the participant role, or the read-only guest role Bestie joins private
+ // channels with. Several people may share the Bot role a single agent
+ // enrollment already signs; one agent keeps that exact enrollment shape.
+ // Never an elevation.
+ const role = tags.at(-1)?.[0] === "role" ? tags.at(-1) : undefined;
+ const people = tags.slice(1, role ? -1 : undefined);
return (
event.content === "" &&
- (tags.length === 2 ||
- (tags.length === 3 &&
- tags[2].length === 2 &&
- tags[2][0] === "role" &&
- (tags[2][1] === "member" || tags[2][1] === "guest"))) &&
- p?.length === 2 &&
- p[0] === "p" &&
- /^[0-9a-f]{64}$/.test(p[1])
+ people.length >= 1 &&
+ people.length <= 100 &&
+ people.every(
+ (tag) =>
+ tag.length === 2 && tag[0] === "p" && /^[0-9a-f]{64}$/.test(tag[1]),
+ ) &&
+ new Set(people.map(([, key]) => key)).size === people.length &&
+ (!role ||
+ (role.length === 2 &&
+ (role[1] === "member" ||
+ role[1] === "guest" ||
+ (role[1] === "bot" && people.length > 1))))
);
+ }
return false;
}
diff --git a/browser-host/channel-commands.test.mjs b/browser-host/channel-commands.test.mjs
index f9f6993a0d..806cd2daa8 100644
--- a/browser-host/channel-commands.test.mjs
+++ b/browser-host/channel-commands.test.mjs
@@ -131,6 +131,37 @@ it("accepts one trailing outbox identifier for invitations and rejects ambiguous
expect(validChannelCommand(invite(tags))).toBe(false);
});
+it("signs a batch invitation of up to 100 distinct people without elevating roles", () => {
+ const invite = (tags) => ({ kind: 9000, created_at: 1, content: "", tags });
+ const h = ["h", id],
+ client = ["client-id", id];
+ const people = (count) =>
+ Array.from({ length: count }, (_, index) => [
+ "p",
+ index.toString(16).padStart(64, "0"),
+ ]);
+ for (const role of [
+ [],
+ [["role", "member"]],
+ [["role", "guest"]],
+ [["role", "bot"]],
+ ])
+ expect(
+ validChannelCommand(invite([h, ...people(100), ...role, client])),
+ ).toBe(true);
+ for (const tags of [
+ [h, ...people(101)],
+ [h, ...people(2), people(1)[0]],
+ [h, ...people(2), ["role", "admin"]],
+ [h, ...people(2), ["role", "owner"]],
+ [h, ["role", "member"]],
+ [h, people(1)[0], ["role", "bot"]],
+ [h, people(2)[0], ["role", "member"], people(2)[1]],
+ [h, ["p", "a".repeat(64), "admin"], ...people(1)],
+ ])
+ expect(validChannelCommand(invite(tags))).toBe(false);
+});
+
it("allows bounded ordinary stream creation and rejects malformed TTL", () => {
const create = {
kind: 9007,
diff --git a/browser-host/relay-broker-api.test.mjs b/browser-host/relay-broker-api.test.mjs
index 1d833c28c2..706dd7324a 100644
--- a/browser-host/relay-broker-api.test.mjs
+++ b/browser-host/relay-broker-api.test.mjs
@@ -1806,14 +1806,40 @@ test("member and guest additions work without channel creation, while role eleva
expect(h.publications[2].tags).toEqual(
expect.arrayContaining([["role", "guest"]]),
);
- for (const role of ["owner", "admin"]) {
- const denied = await h.post("sign", {
+ // block/buzz#8575: one addition may name several people.
+ const people = [
+ ["p", "b".repeat(64)],
+ ["p", "c".repeat(64)],
+ ];
+ for (const role of [
+ [],
+ [["role", "member"]],
+ [["role", "guest"]],
+ [["role", "bot"]],
+ ]) {
+ const batchId = owner.outbox.send({
kind: 9000,
content: "",
- created_at: 1700000000,
- tags: [...tags, ["role", role]],
+ tags: [...tags, ...people, ...role],
});
- expect(denied.status).toBe(400);
+ await vi.waitFor(() =>
+ expect(
+ owner.local.snapshot().find((row) => row.event.id === batchId)
+ ?.delivery,
+ ).toBe("accepted"),
+ );
+ }
+ expect(h.publications).toHaveLength(7);
+ for (const role of ["owner", "admin"]) {
+ for (const named of [tags, [...tags, ...people]]) {
+ const denied = await h.post("sign", {
+ kind: 9000,
+ content: "",
+ created_at: 1700000000,
+ tags: [...named, ["role", role]],
+ });
+ expect(denied.status).toBe(400);
+ }
}
} finally {
owner?.dispose();
diff --git a/docs/solo/sharing.md b/docs/solo/sharing.md
index da0f6725e9..f55e8d1a23 100644
--- a/docs/solo/sharing.md
+++ b/docs/solo/sharing.md
@@ -65,6 +65,15 @@ grant fails, the dialog shows the error and **Retry share**; a retry reads the
channel again and grants only whoever is still missing. Grants already confirmed
stay. With no channel selected, **Done** closes without writes.
+People added together (a share, a team, mentioned nonmembers or brought agents)
+go in one kind 9000 per role, at most 100 each, and the roster is read once after.
+A relay without batch support adds only the first person an event names, so the
+roster decides who was added; anyone else, or anyone with an earlier addition
+still pending, gets the single addition, which reports their own failure. A batch
+is never resent, and stays in Outbox until everyone it names is in the roster or
+has their own addition; its **Retry** checks each person again. See the
+relay's kind 9000 entry in block/buzz `NOSTR.md`.
+
The sidebar's Shared mark is status only: it uses the verified current roster and
cached non-agent evidence for an additional human. Unknown profiles remain
unclassified; absence of the indicator is **not** a privacy promise. Clicking the
diff --git a/src/bundled/channels/OutboxStatus.test.tsx b/src/bundled/channels/OutboxStatus.test.tsx
index 04c17b89fa..e5f59f188b 100644
--- a/src/bundled/channels/OutboxStatus.test.tsx
+++ b/src/bundled/channels/OutboxStatus.test.tsx
@@ -44,11 +44,13 @@ const invitation = {
function show(item: OutgoingEvent) {
const retry = vi.fn();
const add = vi.fn(async () => {});
+ const acknowledge = vi.fn(async () => {});
const items = [item];
const outbox = {
snapshot: () => items,
subscribe: () => () => {},
retry,
+ acknowledge,
dismiss: vi.fn(),
} as unknown as Outbox;
render(
@@ -59,7 +61,7 @@ function show(item: OutgoingEvent) {
/>,
);
fireEvent.click(screen.getByText("Outbox · 1 items"));
- return { retry, add };
+ return { retry, add, acknowledge };
}
it("does not offer generic retry for guarded invitations from either profile or composer", () => {
@@ -177,6 +179,62 @@ it("retries a guest addition as a guest", async () => {
);
});
+it("retries a guest batch per person as guests, each as a new addition", async () => {
+ const { add } = show({
+ ...invitation,
+ event: {
+ ...invitation.event,
+ tags: [
+ ["h", "channel"],
+ ["p", "one"],
+ ["p", "two"],
+ ["role", "guest"],
+ ],
+ },
+ recovery: { key: "member-add:batch:operation", value: "1" },
+ });
+ fireEvent.click(screen.getByRole("button", { name: "Retry" }));
+ await waitFor(() =>
+ expect(add.mock.calls).toEqual([
+ ["channel", "one", { role: "guest" }],
+ ["channel", "two", { role: "guest" }],
+ ]),
+ );
+});
+
+it.each(["failed", "accepted"] as const)(
+ "retries a %s batch addition per person, never resending it",
+ async (delivery) => {
+ const { retry, add, acknowledge } = show({
+ ...invitation,
+ delivery,
+ event: {
+ ...invitation.event,
+ tags: [
+ ["h", "channel"],
+ ["p", "one"],
+ ["p", "two"],
+ ["role", "member"],
+ ],
+ },
+ recovery: { key: "member-add:batch:operation", value: "1" },
+ });
+ expect(screen.getByText(/Add 2 people/)).toBeVisible();
+ add.mockImplementation(async (...args: unknown[]) => {
+ if (args[1] === "two") throw new Error("Could not add two");
+ });
+ fireEvent.click(screen.getByRole("button", { name: "Retry" }));
+ expect(await screen.findByText(/Could not add two/)).toBeVisible();
+ expect(add.mock.calls).toEqual([
+ ["channel", "one", {}],
+ ["channel", "two", {}],
+ ]);
+ // Additions settle the batch once everyone is; Retry never clears it.
+ expect(acknowledge).not.toHaveBeenCalled();
+ expect(retry).not.toHaveBeenCalled();
+ },
+);
+
it.each(
(["failed", "unknown", "admitted"] as const).flatMap((failure) =>
[false, true].map((restart) => ({ failure, restart })),
diff --git a/src/bundled/channels/OutboxStatus.tsx b/src/bundled/channels/OutboxStatus.tsx
index ce75595e4e..18cd71354a 100644
--- a/src/bundled/channels/OutboxStatus.tsx
+++ b/src/bundled/channels/OutboxStatus.tsx
@@ -6,6 +6,15 @@ import { RelayTimings } from "./RelayTimings";
import { PRODUCT_FEEDBACK_KIND } from "../../features/relay/product-feedback";
import type { Outbox } from "../../features/relay/outbox";
import type { RelaySession } from "../../features/relay/session";
+import { isBatchAddition } from "../../features/channel-members/members";
+
+/** A batch names everyone its Retry re-adds. */
+function additionLabel(tags: readonly (readonly string[])[]) {
+ const people = tags.filter(([tag]) => tag === "p");
+ return people.length > 1
+ ? `Add ${people.length} people`
+ : `Add agent ${people[0]?.[1]?.slice(0, 12) ?? ""}`;
+}
/** Delivery belongs to the session, including messages from other pages or a previous launch. */
export function OutboxStatus({
@@ -39,7 +48,7 @@ export function OutboxStatus({
{item.event.kind === 9000
- ? `Add agent ${item.event.tags.find(([tag]) => tag === "p")?.[1]?.slice(0, 12) ?? ""}`
+ ? additionLabel(item.event.tags)
: item.event.content.slice(0, 100)}{" "}
·{" "}
{
@@ -54,7 +63,10 @@ export function OutboxStatus({
{" "}
{!isWorkflowOperation(item.event) &&
!item.guarded &&
- (item.delivery === "failed" || item.delivery === "unknown") && (
+ (item.delivery === "failed" ||
+ item.delivery === "unknown" ||
+ // A kept batch still names someone unsettled.
+ (isBatchAddition(item) && item.delivery !== "sending")) && (