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")) && (