diff --git a/dev/relay-broker-api.test.mjs b/dev/relay-broker-api.test.mjs index bb577631e..c720aaf95 100644 --- a/dev/relay-broker-api.test.mjs +++ b/dev/relay-broker-api.test.mjs @@ -1007,6 +1007,24 @@ test.each([undefined, "22222222-2222-4222-8222-222222222222"])( ?.delivery, ).toBe("accepted"), ); + const temporaryCreationId = owner.outbox.send({ + kind: 9007, + content: "", + tags: [ + ["h", "33333333-3333-4333-8333-333333333333"], + ["name", "Standup"], + ["visibility", "open"], + ["channel_type", "stream"], + ["ttl", "604800"], + ], + }); + await vi.waitFor(() => + expect( + owner.local + .snapshot() + .find((row) => row.event.id === temporaryCreationId)?.delivery, + ).toBe("accepted"), + ); const invitationId = owner.outbox.send({ kind: 9000, content: "", @@ -1036,7 +1054,7 @@ test.each([undefined, "22222222-2222-4222-8222-222222222222"])( ).toBe("accepted"), ); expect(h.publications.map((event) => event.kind)).toEqual([ - 9007, 9000, 9, + 9007, 9007, 9000, 9, ]); const denied = await h.post("sign", { kind: 9050, @@ -1052,3 +1070,29 @@ test.each([undefined, "22222222-2222-4222-8222-222222222222"])( } }, ); + +test.each(["sign", "publish"])( + "%s rejects truncated channel creation tags as a client error", + async (route) => { + const h = await harness(success, { channelCreation: true }); + try { + const required = [ + ["h", "11111111-1111-4111-8111-111111111111"], + ["name", "Work"], + ["visibility", "open"], + ["channel_type", "stream"], + ]; + for (let length = 0; length < required.length; length++) { + const response = await h.post(route, { + kind: 9007, + created_at: 1700000000, + content: "", + tags: required.slice(0, length), + }); + expect(response.status).toBe(400); + } + } finally { + await h.close(); + } + }, +); diff --git a/dev/relay-broker.mjs b/dev/relay-broker.mjs index 6451dd0be..083643022 100644 --- a/dev/relay-broker.mjs +++ b/dev/relay-broker.mjs @@ -1,4 +1,4 @@ -import { validSessionCommand } from "./session-commands.mjs"; +import { validChannelCommand } from "./session-commands.mjs"; import { SocketRequestError } from "../src/features/relay/socket-requests.ts"; import { validateWorkflowEvent, @@ -1171,11 +1171,11 @@ export function relayBrokerPlugin({ const authority = await getAuthority(relay); if ( !enrollment && - !(authority.channelCreation && validSessionCommand(filters)) + !(authority.channelCreation && validChannelCommand(filters)) ) return json(res, 400, { error: - "Agent enrollment or session operation unavailable or invalid", + "Agent enrollment or channel operation unavailable or invalid", sent: false, }); } else if (![7, 9].includes(filters?.kind)) { diff --git a/dev/session-commands.mjs b/dev/session-commands.mjs index f0ee2a1d1..603fea973 100644 --- a/dev/session-commands.mjs +++ b/dev/session-commands.mjs @@ -1,7 +1,7 @@ import { sessionMetadata } from "../src/features/sessions/metadata.ts"; // Host signing allowlist. No arbitrary kinds, roles or metadata edits. const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; -export function validSessionCommand(event) { +export function validChannelCommand(event) { if ( !event || !Number.isSafeInteger(event.created_at) || @@ -25,18 +25,41 @@ export function validSessionCommand(event) { const [h, p] = 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", "about"]; + const expected = ["h", "name", "visibility", "channel_type"]; + const optional = tags.slice(expected.length); + const optionalNames = optional.map(([name]) => name); + const validOptionalOrder = [[], ["about"], ["ttl"], ["about", "ttl"]].some( + (names) => + names.length === optionalNames.length && + names.every((name, index) => name === optionalNames[index]), + ); + const about = optional.find(([name]) => name === "about"); + const ttl = optional.find(([name]) => name === "ttl"); + const aboutValue = about?.[1]; + const ttlValue = ttl?.[1]; return ( event.content === "" && - tags.length === expected.length && - tags.every( - (tag, index) => tag.length === 2 && tag[0] === expected[index], - ) && + tags.length >= expected.length && + validOptionalOrder && + tags + .slice(0, expected.length) + .every( + (tag, index) => tag.length === 2 && tag[0] === expected[index], + ) && !!tags[1][1].trim() && [...tags[1][1]].length <= 120 && - tags[2][1] === "private" && + ["open", "private"].includes(tags[2][1]) && tags[3][1] === "stream" && - sessionMetadata(tags[4][1]) !== undefined + (!about || + (about.length === 2 && + [...aboutValue].length <= 1000 && + (sessionMetadata(aboutValue) !== undefined || + !aboutValue.includes("Buzz session (")))) && + (!ttl || + (ttl.length === 2 && + /^(?:[1-9]\d*)$/.test(ttlValue) && + Number(ttlValue) <= 2_147_483_647 && + sessionMetadata(aboutValue) === undefined)) ); } if (event.kind === 9000) diff --git a/dev/session-commands.test.mjs b/dev/session-commands.test.mjs index 50a026bb1..df9fe1a4f 100644 --- a/dev/session-commands.test.mjs +++ b/dev/session-commands.test.mjs @@ -1,5 +1,5 @@ import { expect, it } from "vitest"; -import { validSessionCommand } from "./session-commands.mjs"; +import { validChannelCommand } from "./session-commands.mjs"; const id = "11111111-1111-4111-8111-111111111111"; const event = (body, tags = [["h", id]]) => ({ kind: 9050, @@ -8,11 +8,11 @@ const event = (body, tags = [["h", id]]) => ({ tags, }); it("rejects custom session commands", () => { - expect(validSessionCommand(event({ action: "create", title: "Work" }))).toBe( + expect(validChannelCommand(event({ action: "create", title: "Work" }))).toBe( false, ); expect( - validSessionCommand( + validChannelCommand( event({ action: "move", parent_id: id, confirm_history: true }), ), ).toBe(false); @@ -27,17 +27,17 @@ it("allows only ordinary invitations, without extra roles", () => { ["p", "a".repeat(64)], ], }; - expect(validSessionCommand(invite)).toBe(true); + expect(validChannelCommand(invite)).toBe(true); expect( - validSessionCommand({ + validChannelCommand({ ...invite, tags: [...invite.tags, ["role", "admin"]], }), ).toBe(false); - expect(validSessionCommand({ ...invite, kind: 9001 })).toBe(false); + expect(validChannelCommand({ ...invite, kind: 9001 })).toBe(false); }); -it("allows only private stream creation marked for Sessions", () => { +it("preserves strict Sessions metadata while allowing ordinary stream visibility", () => { const create = { kind: 9007, created_at: 1, @@ -50,7 +50,7 @@ it("allows only private stream creation marked for Sessions", () => { ["about", "Buzz session (buzz.sessions/v1)"], ], }; - expect(validSessionCommand(create)).toBe(true); + expect(validChannelCommand(create)).toBe(true); const child = (description) => ({ ...create, tags: create.tags.map((tag) => @@ -58,23 +58,21 @@ it("allows only private stream creation marked for Sessions", () => { ), }); expect( - validSessionCommand(child(`Buzz session (buzz.sessions/v1)\nparent:${id}`)), + validChannelCommand(child(`Buzz session (buzz.sessions/v1)\nparent:${id}`)), ).toBe(true); for (const parent of ["invalid", `${id}\nrole:owner`, `${id}extra`]) { expect( - validSessionCommand( + validChannelCommand( child(`Buzz session (buzz.sessions/v1)\nparent:${parent}`), ), ).toBe(false); } for (const [index, value] of [ - [2, "open"], [3, "dm"], - [4, "arbitrary"], [1, " "], ]) { expect( - validSessionCommand({ + validChannelCommand({ ...create, tags: create.tags.map((tag, i) => i === index ? [tag[0], value] : tag, @@ -83,12 +81,20 @@ it("allows only private stream creation marked for Sessions", () => { ).toBe(false); } expect( - validSessionCommand({ + validChannelCommand({ + ...create, + tags: create.tags.map((tag, i) => + i === 4 ? [tag[0], "Buzz session (forged)"] : tag, + ), + }), + ).toBe(false); + expect( + validChannelCommand({ ...create, tags: [...create.tags, ["p", "a".repeat(64)]], }), ).toBe(false); - expect(validSessionCommand({ ...create, content: "extra" })).toBe(false); + expect(validChannelCommand({ ...create, content: "extra" })).toBe(false); }); it("accepts one trailing outbox identifier for invitations and rejects ambiguous envelopes", () => { @@ -96,12 +102,102 @@ it("accepts one trailing outbox identifier for invitations and rejects ambiguous const h = ["h", id], p = ["p", "a".repeat(64)], client = ["client-id", id]; - expect(validSessionCommand(invite([h, p, client]))).toBe(true); + expect(validChannelCommand(invite([h, p, client]))).toBe(true); for (const tags of [ [h, p, client, client], [client, h, p], [h, p, ["client-id", "invalid"]], [h, p, [...client, "extra"]], ]) - expect(validSessionCommand(invite(tags))).toBe(false); + expect(validChannelCommand(invite(tags))).toBe(false); +}); + +it("allows bounded ordinary stream creation without a Sessions marker", () => { + const create = { + kind: 9007, + created_at: 1, + content: "", + tags: [ + ["h", id], + ["name", "Release notes"], + ["visibility", "open"], + ["channel_type", "stream"], + ["about", "Updates for the team"], + ], + }; + expect(validChannelCommand(create)).toBe(true); + expect( + validChannelCommand({ ...create, tags: create.tags.slice(0, 4) }), + ).toBe(true); + expect( + validChannelCommand({ + ...create, + tags: create.tags.map((tag) => + tag[0] === "about" ? ["about", "x".repeat(1001)] : tag, + ), + }), + ).toBe(false); + expect( + validChannelCommand({ + ...create, + tags: create.tags.map((tag) => + tag[0] === "about" ? ["about", "Buzz session (forged)"] : tag, + ), + }), + ).toBe(false); + expect( + validChannelCommand({ + ...create, + tags: [...create.tags, ["ttl", "604800"]], + }), + ).toBe(true); + expect( + validChannelCommand({ + ...create, + tags: [...create.tags.slice(0, 4), ["ttl", "604800"]], + }), + ).toBe(true); + for (const ttl of ["0", "-1", "forever", "2147483648"]) { + expect( + validChannelCommand({ + ...create, + tags: [...create.tags, ["ttl", ttl]], + }), + ).toBe(false); + } +}); + +it("rejects every truncated mandatory creation-tag prefix", () => { + const create = { + kind: 9007, + created_at: 1, + content: "", + tags: [ + ["h", id], + ["name", "Release notes"], + ["visibility", "open"], + ["channel_type", "stream"], + ], + }; + for (let length = 0; length < create.tags.length; length++) + expect( + validChannelCommand({ ...create, tags: create.tags.slice(0, length) }), + ).toBe(false); +}); + +it("does not add temporary cleanup to Sessions metadata", () => { + const create = { + kind: 9007, + created_at: 1, + content: "", + tags: [ + ["h", id], + ["name", "Work"], + ["visibility", "private"], + ["channel_type", "stream"], + ["about", "Buzz session (buzz.sessions/v1)"], + ["ttl", "604800"], + ], + }; + expect(validChannelCommand(create)).toBe(false); }); diff --git a/docs/assets/sidebar-polish/create-channel-dialog.png b/docs/assets/sidebar-polish/create-channel-dialog.png new file mode 100644 index 000000000..45927dace Binary files /dev/null and b/docs/assets/sidebar-polish/create-channel-dialog.png differ diff --git a/docs/assets/sidebar-polish/private-channel-and-sidebar.png b/docs/assets/sidebar-polish/private-channel-and-sidebar.png new file mode 100644 index 000000000..e8a3fe5e2 Binary files /dev/null and b/docs/assets/sidebar-polish/private-channel-and-sidebar.png differ diff --git a/src/bundled/channels/ChannelSidebarItem.tsx b/src/bundled/channels/ChannelSidebarItem.tsx index de23c92a1..a94735cf9 100644 --- a/src/bundled/channels/ChannelSidebarItem.tsx +++ b/src/bundled/channels/ChannelSidebarItem.tsx @@ -3,9 +3,9 @@ import type { ChannelSummary } from "../../features/relay/contracts"; import type { RelaySession } from "../../features/relay/session"; import { ChatCircleIcon, - HashIcon, UsersIcon, } from "../../shared/design-system/icons/index"; +import { channelIcon } from "../../features/channels/channel-icon"; import { ChannelActivityPopover } from "./ChannelActivityPopover"; import { ChannelSidebarRow } from "./ChannelSidebarRow"; import { UnreadBadge } from "./UnreadBadge"; @@ -49,7 +49,7 @@ export const ChannelSidebarItem = memo(function ChannelSidebarItem({ ? (channel.participants?.length ?? 0) > 1 ? UsersIcon : ChatCircleIcon - : HashIcon; + : channelIcon(channel); return ( } /> @@ -162,7 +163,7 @@ export function ChannelSidebarRow({ )} -