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
34 changes: 22 additions & 12 deletions browser-host/channel-commands.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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"];
Expand Down Expand Up @@ -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;
}
31 changes: 31 additions & 0 deletions browser-host/channel-commands.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
36 changes: 31 additions & 5 deletions browser-host/relay-broker-api.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
9 changes: 9 additions & 0 deletions docs/solo/sharing.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
60 changes: 59 additions & 1 deletion src/bundled/channels/OutboxStatus.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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", () => {
Expand Down Expand Up @@ -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 })),
Expand Down
51 changes: 38 additions & 13 deletions src/bundled/channels/OutboxStatus.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -39,7 +48,7 @@ export function OutboxStatus({
<li key={item.event.id}>
<span>
{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)}{" "}
·{" "}
{
Expand All @@ -54,7 +63,10 @@ export function OutboxStatus({
</span>{" "}
{!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")) && (

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Optional: a kept batch can stay in Outbox for good. Repro: on an older relay only the first person is added, and the roster read afterwards fails, so the batch is kept as accepted. Then the viewer loses add access, or the channel or a named person is archived. From then on Retry always fails, and Remove from outbox fails with “Confirm this addition in Members before removing it”, but Members has no way to confirm a batch. A single add can already get stuck like this. What's new is that this row offers a Retry that can never succeed, the Remove message points somewhere that can't help, and the batch keeps using a pending slot. Suggested fix: allow Remove for a batch once the viewer can no longer add to the channel, or once Retry finds nobody it names who can still be added. At minimum, give batches their own Remove message.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Morgarita, Morgan's AI agent: agreed this is worth fixing. We're leaving it to a follow-up so this PR's approved scope stays put.

<Button
type="button"
onClick={async () => {
Expand All @@ -67,23 +79,36 @@ export function OutboxStatus({
const channelId = item.event.tags.find(
([tag]) => tag === "h",
)?.[1];
const pubkey = item.event.tags.find(
([tag]) => tag === "p",
)?.[1];
if (!channelId || !pubkey)
const pubkeys = item.event.tags.flatMap(
([tag, value]) =>
tag === "p" && value ? [value] : [],
);
if (!channelId || !pubkeys.length)
throw new Error(
"Reopen Members to check this addition.",
);
// A stale explicit role must never overwrite a newer admission.
// A guest addition is retried as one, so it can finish
// its own receipt and never sends a writing role.
// A stale explicit role must never overwrite a newer
Comment thread
morgmart marked this conversation as resolved.
Resolved
// admission, so each person is checked again. A guest
// addition is retried as one, so it never sends a
// writing role. A single addition finishes only its own
// receipt. A batch is never resumed, so each person it
// names is added again; it clears once all are settled.
const guest = item.event.tags.some(
([tag, value]) => tag === "role" && value === "guest",
);
await memberAdditions.add(channelId, pubkey, {
...(guest ? { role: "guest" } : {}),
retry: true,
});
const batch = isBatchAddition(item);
const results = await Promise.allSettled(
pubkeys.map((pubkey) =>
memberAdditions.add(channelId, pubkey, {
...(guest ? { role: "guest" as const } : {}),
...(batch ? {} : { retry: true as const }),
}),
),
);
const failed = results.find(
(result) => result.status === "rejected",
);
if (failed) throw failed.reason;
} else outbox.retry(item.event.id);
setError(undefined);
} catch (reason) {
Expand Down
Loading
Loading