From 73370ef5d002e8ac701ecda0523907027b7f16c3 Mon Sep 17 00:00:00 2001 From: Carl <32a2e2c9d428ee08902cab75d956da2c1d235a22d4766b0dd4138bf6e2e5db1d@buzz.block.builderlab.xyz> Date: Tue, 6 Oct 2026 15:25:26 -0700 Subject: [PATCH 1/3] refactor: share existing status, DM and sidebar intent policy Signed-off-by: Carl <32a2e2c9d428ee08902cab75d956da2c1d235a22d4766b0dd4138bf6e2e5db1d@buzz.block.builderlab.xyz> --- dev/direct-messages.mjs | 12 ++--- dev/relay-broker-api.test.mjs | 28 +++++++++++ dev/sidebar-intents.test.mjs | 45 +++++++++++++++++ dev/sidebar-mutes.mjs | 8 +-- dev/sidebar-preferences.mjs | 21 +++----- dev/sidebar-sort.mjs | 21 +++----- dev/sidebar-stars.mjs | 8 +-- dev/user-status.mjs | 13 +++-- dev/user-status.test.mjs | 21 ++++++++ docs/contributing.md | 12 ++++- src/features/relay/direct-message-policy.ts | 12 +++++ src/features/relay/direct-messages.test.ts | 3 ++ src/features/relay/direct-messages.ts | 10 +--- src/features/relay/native-sidebar.test.ts | 26 ++++++++++ src/features/relay/native-sidebar.ts | 34 +++---------- src/features/relay/sidebar-edits.test.ts | 54 +++++++++++++++++++++ src/features/relay/sidebar-edits.ts | 34 +++++++++++++ src/features/relay/user-status-policy.ts | 11 +++++ src/features/relay/user-status.test.ts | 37 ++++++++++++++ src/features/relay/user-status.ts | 17 ++----- 20 files changed, 327 insertions(+), 100 deletions(-) create mode 100644 dev/sidebar-intents.test.mjs create mode 100644 src/features/relay/direct-message-policy.ts create mode 100644 src/features/relay/sidebar-edits.test.ts create mode 100644 src/features/relay/user-status-policy.ts diff --git a/dev/direct-messages.mjs b/dev/direct-messages.mjs index 871ca7ebf..e5ef057df 100644 --- a/dev/direct-messages.mjs +++ b/dev/direct-messages.mjs @@ -1,3 +1,4 @@ +import { validDirectMessageParticipants } from "../src/features/relay/direct-message-policy.ts"; import { randomUUID } from "node:crypto"; import { finalizeEvent } from "nostr-tools"; @@ -6,15 +7,8 @@ export function directMessageEvent(input, viewer, key) { if ( !input || !Array.isArray(input.pubkeys) || - input.pubkeys.length < 1 || - input.pubkeys.length > 8 || - input.pubkeys.some( - (value) => - typeof value !== "string" || - !/^[0-9a-f]{64}$/.test(value) || - value === viewer, - ) || - new Set(input.pubkeys).size !== input.pubkeys.length + input.pubkeys.some((value) => typeof value !== "string") || + !validDirectMessageParticipants(input.pubkeys, viewer) ) throw new Error("Choose between one and eight other people."); return finalizeEvent( diff --git a/dev/relay-broker-api.test.mjs b/dev/relay-broker-api.test.mjs index f402f6855..99e6a0d97 100644 --- a/dev/relay-broker-api.test.mjs +++ b/dev/relay-broker-api.test.mjs @@ -1891,6 +1891,10 @@ test("direct-message transport signs only bounded participants and binds the ret [recipients[0], recipients[0]], Array(9).fill(recipients[0]), ["invalid"], + ["A".repeat(64)], + [123], + null, + "a".repeat(64), ]) { expect((await h.post("direct-message", { pubkeys })).status).toBe(400); } @@ -1989,6 +1993,30 @@ test("status signing and publication preserve scoped replacements and explicit c expect( (await h.post("publish", finalizeEvent(future, secret))).status, ).toBe(400); + for (const input of [ + { content: "😀".repeat(51) }, + { content: "one\ntwo" }, + { + content: "x", + tags: [ + ["d", "general"], + ["emoji", " "], + ], + }, + { + content: "x", + tags: [ + ["d", "general"], + ["emoji", "😀".repeat(51)], + ], + }, + ]) { + const template = { ...future, created_at: 1700000000, ...input }; + expect((await h.post("sign", template)).status).toBe(400); + expect( + (await h.post("publish", finalizeEvent(template, secret))).status, + ).toBe(400); + } expect(h.publications).toHaveLength(2); } finally { await h.close(); diff --git a/dev/sidebar-intents.test.mjs b/dev/sidebar-intents.test.mjs new file mode 100644 index 000000000..764c7a9e4 --- /dev/null +++ b/dev/sidebar-intents.test.mjs @@ -0,0 +1,45 @@ +import { expect, it } from "vitest"; +import { assertSidebarAssignmentIntent } from "./sidebar-preferences.mjs"; +import { assertSidebarStarIntent } from "./sidebar-stars.mjs"; +import { assertSidebarMuteIntent } from "./sidebar-mutes.mjs"; +import { assertSidebarSortIntent } from "./sidebar-sort.mjs"; + +it("keeps broker-only envelope checks before shared sidebar policy", () => { + const cases = [ + [assertSidebarAssignmentIntent, { channelId: "c" }], + [assertSidebarStarIntent, { channelId: "c", starred: true }], + [assertSidebarMuteIntent, { channelId: "c", muted: false }], + [ + assertSidebarSortIntent, + { group: "channels", mode: "recent", sectionIds: [] }, + ], + ]; + for (const [assertIntent, valid] of cases) { + expect(() => assertIntent(valid)).not.toThrow(); + for (const invalid of [null, [], {}, { ...valid, extra: true }]) + expect(() => assertIntent(invalid)).toThrow("Invalid sidebar"); + } + for (const createSection of [ + null, + false, + [], + { id: "x", name: "Work" }, + { id: "11111111-1111-1111-1111-111111111111", name: "Work", extra: true }, + ]) + expect(() => + assertSidebarAssignmentIntent({ channelId: "c", createSection }), + ).toThrow("Invalid sidebar assignment intent"); + expect(() => + assertSidebarSortIntent({ + group: "channels", + mode: "recent", + sectionIds: [1], + }), + ).toThrow("Invalid sidebar sort intent"); + expect(() => + assertSidebarStarIntent({ channelId: "c", starred: "true" }), + ).toThrow("Invalid sidebar star intent"); + expect(() => assertSidebarMuteIntent({ channelId: 1, muted: false })).toThrow( + "Invalid sidebar mute intent", + ); +}); diff --git a/dev/sidebar-mutes.mjs b/dev/sidebar-mutes.mjs index ff8740262..cb2ebd09c 100644 --- a/dev/sidebar-mutes.mjs +++ b/dev/sidebar-mutes.mjs @@ -1,4 +1,7 @@ -import { editSidebarToggle } from "../src/features/relay/sidebar-edits.ts"; +import { + editSidebarToggle, + validSidebarChannelId, +} from "../src/features/relay/sidebar-edits.ts"; import { finalizeEvent, getPublicKey, nip44 } from "nostr-tools"; import { decodeSidebarPreferences } from "./sidebar-preferences.mjs"; @@ -9,8 +12,7 @@ export function assertSidebarMuteIntent(intent) { typeof intent !== "object" || Array.isArray(intent) || typeof intent.channelId !== "string" || - !intent.channelId.trim() || - intent.channelId.length > 256 || + !validSidebarChannelId(intent.channelId) || typeof intent.muted !== "boolean" || Object.keys(intent).some((key) => !["channelId", "muted"].includes(key)) ) diff --git a/dev/sidebar-preferences.mjs b/dev/sidebar-preferences.mjs index 7edb2396f..0b9fbb05b 100644 --- a/dev/sidebar-preferences.mjs +++ b/dev/sidebar-preferences.mjs @@ -1,4 +1,7 @@ -import { editSidebarAssignment } from "../src/features/relay/sidebar-edits.ts"; +import { + editSidebarAssignment, + validSidebarAssignment, +} from "../src/features/relay/sidebar-edits.ts"; import { projectSidebarRecord } from "../src/features/relay/sidebar-registers.ts"; import { finalizeEvent, getPublicKey, nip44, verifyEvent } from "nostr-tools"; import { @@ -67,27 +70,17 @@ function validAssignmentIntent(intent) { typeof intent === "object" && !Array.isArray(intent) && typeof intent.channelId === "string" && - intent.channelId.trim().length > 0 && - intent.channelId.length <= 256 && - (intent.sectionId === undefined || - (typeof intent.sectionId === "string" && - intent.sectionId.trim().length > 0 && - intent.sectionId.length <= 256)) && + (intent.sectionId === undefined || typeof intent.sectionId === "string") && (intent.createSection === undefined || - (intent.sectionId === undefined && - intent.createSection && + (intent.createSection && typeof intent.createSection === "object" && !Array.isArray(intent.createSection) && typeof intent.createSection.id === "string" && - /^[0-9a-f]{8}(-[0-9a-f]{4}){3}-[0-9a-f]{12}$/.test( - intent.createSection.id, - ) && typeof intent.createSection.name === "string" && - intent.createSection.name.trim().length > 0 && - intent.createSection.name.length <= 256 && Object.keys(intent.createSection).every((key) => ["id", "name"].includes(key), ))) && + validSidebarAssignment(intent) && Object.keys(intent).every((key) => ["channelId", "sectionId", "createSection"].includes(key), ) diff --git a/dev/sidebar-sort.mjs b/dev/sidebar-sort.mjs index cb2ac5ac6..0dfd7cb4e 100644 --- a/dev/sidebar-sort.mjs +++ b/dev/sidebar-sort.mjs @@ -1,31 +1,22 @@ -import { editSidebarSort } from "../src/features/relay/sidebar-edits.ts"; +import { + editSidebarSort, + validSidebarSort, +} from "../src/features/relay/sidebar-edits.ts"; import { projectSidebarRecord } from "../src/features/relay/sidebar-registers.ts"; import { finalizeEvent, getPublicKey, nip44, verifyEvent } from "nostr-tools"; import { projectSidebarPreferences } from "../src/features/relay/sidebar-preferences.ts"; import { SIDEBAR_REQUEST_BYTES } from "./sidebar-preferences.mjs"; const SORT_COORDINATE = "channel-sort"; -const SORT_KEYS = new Set(["starred", "channels", "forums", "dms"]); -function validSortGroup(group, sectionIds) { - return ( - SORT_KEYS.has(group) || - (group.startsWith("section:") && sectionIds.includes(group.slice(8))) - ); -} export function assertSidebarSortIntent(intent) { if ( !intent || typeof intent !== "object" || Array.isArray(intent) || typeof intent.group !== "string" || - intent.group.length > 264 || - !["alpha", "recent"].includes(intent.mode) || !Array.isArray(intent.sectionIds) || - intent.sectionIds.length > 100 || - intent.sectionIds.some( - (id) => typeof id !== "string" || !id.trim() || id.length > 256, - ) || - !validSortGroup(intent.group, intent.sectionIds) || + intent.sectionIds.some((id) => typeof id !== "string") || + !validSidebarSort(intent.group, intent.mode, intent.sectionIds) || Object.keys(intent).some( (key) => !["group", "mode", "sectionIds"].includes(key), ) diff --git a/dev/sidebar-stars.mjs b/dev/sidebar-stars.mjs index 5e5293c1b..ed9e79c2e 100644 --- a/dev/sidebar-stars.mjs +++ b/dev/sidebar-stars.mjs @@ -1,4 +1,7 @@ -import { editSidebarToggle } from "../src/features/relay/sidebar-edits.ts"; +import { + editSidebarToggle, + validSidebarChannelId, +} from "../src/features/relay/sidebar-edits.ts"; import { finalizeEvent, getPublicKey, nip44 } from "nostr-tools"; import { decodeSidebarPreferences } from "./sidebar-preferences.mjs"; @@ -9,8 +12,7 @@ export function assertSidebarStarIntent(intent) { typeof intent !== "object" || Array.isArray(intent) || typeof intent.channelId !== "string" || - !intent.channelId.trim() || - intent.channelId.length > 256 || + !validSidebarChannelId(intent.channelId) || typeof intent.starred !== "boolean" || Object.keys(intent).some((key) => !["channelId", "starred"].includes(key)) ) diff --git a/dev/user-status.mjs b/dev/user-status.mjs index c6f3d313d..d6991bc9a 100644 --- a/dev/user-status.mjs +++ b/dev/user-status.mjs @@ -1,13 +1,16 @@ +import { + USER_STATUS_KIND, + validStatusText, +} from "../src/features/relay/user-status-policy.ts"; + /** The status write surface is only the NIP-38 general coordinate. */ export function validStatusTemplate( event, now = Math.floor(Date.now() / 1000), ) { if ( - event?.kind !== 30315 || + event?.kind !== USER_STATUS_KIND || typeof event.content !== "string" || - event.content.length > 100 || - /[\r\n]/.test(event.content) || !Number.isSafeInteger(event.created_at) || event.created_at < 0 || event.created_at > now + 300 || @@ -31,8 +34,8 @@ export function validStatusTemplate( return false; const emoji = event.tags.find(([key]) => key === "emoji")?.[1]; if ( - emoji !== undefined && - (!emoji.trim() || emoji.length > 100 || /[\r\n]/.test(emoji)) + !validStatusText(event.content, emoji ?? "") || + (emoji !== undefined && !emoji.trim()) ) return false; const expiration = event.tags.find(([key]) => key === "expiration")?.[1]; diff --git a/dev/user-status.test.mjs b/dev/user-status.test.mjs index ad496547c..be0faa4fa 100644 --- a/dev/user-status.test.mjs +++ b/dev/user-status.test.mjs @@ -50,3 +50,24 @@ it("bounds future signing timestamps while allowing same-second replacements", ( false, ); }); + +it.each([ + ["text at limit", { content: "x".repeat(100) }, true], + ["UTF-16 emoji at limit", { content: "😀".repeat(50) }, true], + ["UTF-16 emoji over limit", { content: "😀".repeat(51) }, false], + ["untrimmed wire limit", { content: ` ${"x".repeat(100)} ` }, false], + ["line feed", { content: "one\ntwo" }, false], + ["carriage return", { content: "one\rtwo" }, false], + ["non-string content", { content: null }, false], + ["empty emoji tag", { tags: [...template.tags, ["emoji", ""]] }, false], + ["blank emoji tag", { tags: [...template.tags, ["emoji", " "]] }, false], + ["multiline emoji", { tags: [...template.tags, ["emoji", "x\ny"]] }, false], + [ + "emoji at limit", + { tags: [...template.tags, ["emoji", "😀".repeat(50)]] }, + true, + ], + ["non-string emoji", { tags: [...template.tags, ["emoji", 1]] }, false], +])("preserves status wire policy: %s", (_name, patch, accepted) => { + expect(validStatusTemplate({ ...template, ...patch })).toBe(accepted); +}); diff --git a/docs/contributing.md b/docs/contributing.md index ee0c95257..5f2c10e04 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -283,7 +283,15 @@ merely to follow this testing guidance. ### Broker reduction sequence -The following is the proposed removal order, not authorization to delete current +The behavior-preserving cleanup pass shares status text/emoji limits, DM +participant-set policy, and sidebar intent rules with their existing +`src/features/relay` owners. The broker still executes those checks host-side and +retains its stricter untrusted-envelope checks; native Rust enforcement is +unchanged. Status reads still validate raw text before trimming, while local edits +trim first. This does not make broker and native signing policies identical. + +The remaining sequence below requires retiring or replacing browser capabilities; +it is not an automatic queue of follow-up cleanup PRs or authorization to delete current browser workflows. Each slice must identify its callers, move needed regression coverage to the shipped owner, and delete displaced code in the same change. A whole broker endpoint/module can go only after its browser use is explicitly @@ -291,7 +299,7 @@ retired or replaced; do not weaken a still-callable endpoint to reduce duplicati | Order | Existing `dev/` responsibility | Reduction and exit condition | | --- | --- | --- | -| 1 | `user-status.mjs` | Remove repeated text/emoji policy in favor of the existing `src/features/relay` owner. Keep remaining broker shape/time enforcement only while its status endpoint is supported; do not copy it into Rust incidentally. | +| 1 | `user-status.mjs` | Text/emoji policy is now shared with `src/features/relay/user-status-policy.ts`. Remove the remaining broker shape/time checks only when its status endpoint is retired; do not copy them into Rust incidentally. | | 2 | `channel-kit.mjs`, `session-commands.mjs`, `direct-messages.mjs`, signing branches in `relay-broker.mjs` | Retire browser write capabilities one feature at a time after native acceptance and replacement fixture coverage. Remove each Node validator/encryption helper with its last caller; keep native signing tests. | | 3 | `read-state.mjs`, `sidebar-{preferences,mutes,sort,stars}.mjs` | Retire encrypted-state broker routes and their Node copies once browser callers no longer need them. Shared frontend edit policy and native custody remain. | | 4 | `agent-memory.mjs`, `agent-observer.mjs`, `archive.mjs`, `project-git.mjs`, `attachment-{file,upload}.mjs`, `media-preparation.mjs` | Remove feature-by-feature after archive/observer, repository and media browser uses are retired or replaced and native failure/recovery coverage is retained. Do not delete user data or migrate credentials as cleanup. | diff --git a/src/features/relay/direct-message-policy.ts b/src/features/relay/direct-message-policy.ts new file mode 100644 index 000000000..ba2cae916 --- /dev/null +++ b/src/features/relay/direct-message-policy.ts @@ -0,0 +1,12 @@ +/** Participant-set policy shared by the app and the existing browser broker. */ +export function validDirectMessageParticipants( + pubkeys: readonly string[], + viewer: string, +): boolean { + return ( + pubkeys.length >= 1 && + pubkeys.length <= 8 && + new Set(pubkeys).size === pubkeys.length && + pubkeys.every((key) => /^[0-9a-f]{64}$/.test(key) && key !== viewer) + ); +} diff --git a/src/features/relay/direct-messages.test.ts b/src/features/relay/direct-messages.test.ts index 1f5bf8629..25d8d5701 100644 --- a/src/features/relay/direct-messages.test.ts +++ b/src/features/relay/direct-messages.test.ts @@ -144,6 +144,9 @@ it("rejects self, duplicate, empty and over-limit recipients before contacting t [], [t.viewer.pubkey], [t.other.pubkey, t.other.pubkey], + ["a".repeat(63)], + ["A".repeat(64)], + [` ${t.other.pubkey}`], Array.from({ length: 9 }, () => keypair().pubkey), ]) await expect( diff --git a/src/features/relay/direct-messages.ts b/src/features/relay/direct-messages.ts index 6d47ca1c3..69485be91 100644 --- a/src/features/relay/direct-messages.ts +++ b/src/features/relay/direct-messages.ts @@ -1,3 +1,4 @@ +import { validDirectMessageParticipants } from "./direct-message-policy"; import { foldProfiles } from "./profiles"; import type { ChannelQueries } from "./contracts"; import type { ReadTransport } from "./transport"; @@ -65,14 +66,7 @@ export function createDirectMessages( async open(pubkeys: readonly string[], signal: AbortSignal) { if (!available || !transport?.openDirectMessage) throw new Error("This connection cannot start direct messages."); - if ( - !pubkeys.length || - pubkeys.length > 8 || - new Set(pubkeys).size !== pubkeys.length || - pubkeys.some( - (key) => !/^[0-9a-f]{64}$/.test(key) || key === transport.viewer, - ) - ) + if (!validDirectMessageParticipants(pubkeys, transport.viewer)) throw new Error("Choose between one and eight other people."); const active = AbortSignal.any([lifetime, signal]); active.throwIfAborted(); diff --git a/src/features/relay/native-sidebar.test.ts b/src/features/relay/native-sidebar.test.ts index 321c74145..d68e09788 100644 --- a/src/features/relay/native-sidebar.test.ts +++ b/src/features/relay/native-sidebar.test.ts @@ -564,3 +564,29 @@ it.each([ } }, ); + +it("rejects invalid sidebar intents before native reads or signing", async () => { + const transport = await connectNativeTransport(community); + vi.mocked(invoke).mockClear(); + for (const channelId of [" ", "x".repeat(257)]) { + await expect( + transport.writeSidebarStar?.({ channelId, starred: true }, signal), + ).rejects.toThrow("Invalid sidebar star intent"); + await expect( + transport.writeSidebarMute?.({ channelId, muted: true }, signal), + ).rejects.toThrow("Invalid sidebar mute intent"); + await expect( + transport.writeSidebarAssignment?.({ channelId }, signal), + ).rejects.toThrow("Invalid sidebar assignment intent"); + } + await expect( + transport.writeSidebarAssignment?.( + { channelId: "c", createSection: { id: "invalid", name: "Work" } }, + signal, + ), + ).rejects.toThrow("Invalid sidebar assignment intent"); + await expect( + transport.writeSidebarSort?.("section:missing", "recent", [], signal), + ).rejects.toThrow("Invalid sidebar sort intent"); + expect(invoke).not.toHaveBeenCalled(); +}); diff --git a/src/features/relay/native-sidebar.ts b/src/features/relay/native-sidebar.ts index 766bbec24..eea78cd9c 100644 --- a/src/features/relay/native-sidebar.ts +++ b/src/features/relay/native-sidebar.ts @@ -3,6 +3,9 @@ import { editSidebarAssignment, editSidebarSort, editSidebarToggle, + validSidebarAssignment, + validSidebarChannelId, + validSidebarSort, } from "./sidebar-edits"; import { invoke } from "@tauri-apps/api/core"; import { eventDto, type RelayEvent } from "./events"; @@ -147,19 +150,7 @@ export function nativeSidebar(transport: ReadTransport) { signal: AbortSignal, ): Promise { const { channelId, sectionId, createSection } = intent; - if ( - !channelId.trim() || - channelId.length > 256 || - (sectionId !== undefined && - (!sectionId.trim() || sectionId.length > 256)) || - (createSection && - (sectionId !== undefined || - !/^[0-9a-f]{8}(-[0-9a-f]{4}){3}-[0-9a-f]{12}$/.test( - createSection.id, - ) || - !createSection.name.trim() || - createSection.name.length > 256)) - ) + if (!validSidebarAssignment(intent)) throw new Error("Invalid sidebar assignment intent"); const section = createSection?.id ?? sectionId; const prepare = (current: Record, createdAt: number) => { @@ -219,16 +210,7 @@ export function nativeSidebar(transport: ReadTransport) { sectionIds: readonly string[], signal: AbortSignal, ) { - if ( - group.length > 264 || - !["alpha", "recent"].includes(mode) || - sectionIds.length > 100 || - sectionIds.some((id) => !id.trim() || id.length > 256) || - (!["starred", "channels", "forums", "dms"].includes(group) && - !( - group.startsWith("section:") && sectionIds.includes(group.slice(8)) - )) - ) + if (!validSidebarSort(group, mode, sectionIds)) throw new Error("Invalid sidebar sort intent"); const prepare = (current: Record, createdAt: number) => { const next = editSidebarSort(current, createdAt, group, mode); @@ -256,11 +238,7 @@ export function nativeSidebar(transport: ReadTransport) { ) { const { channelId } = intent; const enabled = intent[field]; - if ( - !channelId.trim() || - channelId.length > 256 || - typeof enabled !== "boolean" - ) + if (!validSidebarChannelId(channelId) || typeof enabled !== "boolean") throw new Error( `Invalid sidebar ${field === "starred" ? "star" : "mute"} intent`, ); diff --git a/src/features/relay/sidebar-edits.test.ts b/src/features/relay/sidebar-edits.test.ts new file mode 100644 index 000000000..ac6827019 --- /dev/null +++ b/src/features/relay/sidebar-edits.test.ts @@ -0,0 +1,54 @@ +import { expect, it } from "vitest"; +import { + validSidebarAssignment, + validSidebarChannelId, + validSidebarSort, +} from "./sidebar-edits"; + +it("bounds sidebar identifiers without normalizing their wire value", () => { + for (const id of ["c", " c ", "x".repeat(256), "😀".repeat(128)]) + expect(validSidebarChannelId(id)).toBe(true); + for (const id of ["", " ", "x".repeat(257), "😀".repeat(129)]) + expect(validSidebarChannelId(id)).toBe(false); +}); + +it("validates create-section policy separately from the host envelope", () => { + const createSection = { + id: "11111111-1111-1111-1111-111111111111", + name: " Work ", + }; + expect(validSidebarAssignment({ channelId: "c", createSection })).toBe(true); + expect(validSidebarAssignment({ channelId: "c" })).toBe(true); + for (const intent of [ + { channelId: "c", sectionId: "" }, + { channelId: "c", sectionId: "existing", createSection }, + { channelId: "c", createSection: { ...createSection, id: "bad" } }, + { channelId: "c", createSection: { ...createSection, name: " " } }, + { + channelId: "c", + createSection: { ...createSection, name: "x".repeat(257) }, + }, + ]) + expect(validSidebarAssignment(intent)).toBe(false); +}); + +it("retains built-in and known-section sorting limits", () => { + for (const group of ["starred", "channels", "forums", "dms"]) + for (const mode of ["alpha", "recent"] as const) + expect(validSidebarSort(group, mode, [])).toBe(true); + const section = "x".repeat(256); + expect(validSidebarSort(`section:${section}`, "recent", [section])).toBe( + true, + ); + expect(validSidebarSort("section:unknown", "recent", [])).toBe(false); + expect( + validSidebarSort(`section:${section}x`, "recent", [`${section}x`]), + ).toBe(false); + expect(validSidebarSort("channels", "alpha", Array(100).fill("s"))).toBe( + true, + ); + expect(validSidebarSort("channels", "alpha", Array(101).fill("s"))).toBe( + false, + ); + expect(validSidebarSort("channels", "recent", [" "])).toBe(false); +}); diff --git a/src/features/relay/sidebar-edits.ts b/src/features/relay/sidebar-edits.ts index 879c8f95a..be28c1b66 100644 --- a/src/features/relay/sidebar-edits.ts +++ b/src/features/relay/sidebar-edits.ts @@ -8,6 +8,40 @@ import { nextSidebarSectionOrder, } from "./sidebar-registers.ts"; +// Typed intent policy; host adapters retain untrusted-envelope validation. +export function validSidebarChannelId(channelId: string): boolean { + return !!channelId.trim() && channelId.length <= 256; +} + +export function validSidebarAssignment( + intent: SidebarAssignmentIntent, +): boolean { + const { channelId, sectionId, createSection } = intent; + return ( + validSidebarChannelId(channelId) && + (sectionId === undefined || validSidebarChannelId(sectionId)) && + (!createSection || + (sectionId === undefined && + /^[0-9a-f]{8}(-[0-9a-f]{4}){3}-[0-9a-f]{12}$/.test(createSection.id) && + validSidebarChannelId(createSection.name))) + ); +} + +export function validSidebarSort( + group: string, + mode: SidebarSortMode, + sectionIds: readonly string[], +): boolean { + return ( + group.length <= 264 && + ["alpha", "recent"].includes(mode) && + sectionIds.length <= 100 && + sectionIds.every(validSidebarChannelId) && + (["starred", "channels", "forums", "dms"].includes(group) || + (group.startsWith("section:") && sectionIds.includes(group.slice(8)))) + ); +} + // Pure edits of validated, projected heads. Hosts retain validation, signing, // publication, readback and serialization; these functions never perform I/O. export function editSidebarAssignment( diff --git a/src/features/relay/user-status-policy.ts b/src/features/relay/user-status-policy.ts new file mode 100644 index 000000000..489ef0d84 --- /dev/null +++ b/src/features/relay/user-status-policy.ts @@ -0,0 +1,11 @@ +export const USER_STATUS_KIND = 30315; +export const STATUS_TEXT_LIMIT = 100; + +/** Wire limits use UTF-16 units. Callers own trimming and optional-tag policy. */ +export function validStatusText(text: string, emoji: string): boolean { + return ( + text.length <= STATUS_TEXT_LIMIT && + emoji.length <= STATUS_TEXT_LIMIT && + !/[\r\n]/.test(text + emoji) + ); +} diff --git a/src/features/relay/user-status.test.ts b/src/features/relay/user-status.test.ts index b26a2a6e8..a2dc378dc 100644 --- a/src/features/relay/user-status.test.ts +++ b/src/features/relay/user-status.test.ts @@ -322,3 +322,40 @@ it("fences an aborted save preflight before accepting its old-session result", a expect(queries.snapshot().size).toBe(0); expect(sign).not.toHaveBeenCalled(); }); + +it("trims local status edits before checking limits but bounds incoming wire text before trimming", async () => { + const { owner, queries, publish } = setup(); + await queries.save({ text: ` ${"x".repeat(100)}\n`, emoji: " 🚌\r" }); + expect(publish.mock.calls[0]?.[0]).toMatchObject({ + content: "x".repeat(100), + tags: [ + ["d", "general"], + ["emoji", "🚌"], + ], + }); + owner.accept([status(` ${"x".repeat(100)} `, "", now + 2)]); + expect(queries.snapshot().get(alice.pubkey)?.emoji).toBe("🚌"); + owner.accept([status("Incoming", " ", now + 3)]); + expect(queries.snapshot().get(alice.pubkey)).toMatchObject({ + text: "Incoming", + emoji: "", + }); +}); + +it.each([ + { text: "x".repeat(101), emoji: "" }, + { text: "😀".repeat(51), emoji: "" }, + { text: "", emoji: "😀".repeat(51) }, + { text: "one\ntwo", emoji: "" }, + { text: "", emoji: "one\rtwo" }, +])( + "rejects invalid status text before signing and when receiving: %j", + async (input) => { + const { owner, queries, sign } = setup(); + owner.accept([status("Before")]); + owner.accept([status(input.text, input.emoji, now + 1)]); + expect(queries.snapshot().get(alice.pubkey)?.text).toBe("Before"); + await expect(queries.save(input)).rejects.toThrow("one short line"); + expect(sign).not.toHaveBeenCalled(); + }, +); diff --git a/src/features/relay/user-status.ts b/src/features/relay/user-status.ts index f8d349c04..524120224 100644 --- a/src/features/relay/user-status.ts +++ b/src/features/relay/user-status.ts @@ -3,8 +3,8 @@ import { eventDto, type RelayEvent } from "./events"; import type { RelayReader } from "./reader"; import type { RelayWriter } from "./transport"; -export const USER_STATUS_KIND = 30315; -export const STATUS_TEXT_LIMIT = 100; +import { USER_STATUS_KIND, validStatusText } from "./user-status-policy"; +export { USER_STATUS_KIND, STATUS_TEXT_LIMIT } from "./user-status-policy"; export type UserStatus = Readonly<{ userId: string; text: string; @@ -23,12 +23,7 @@ function parse(event: RelayEvent): UserStatus | undefined { ) return; const emoji = event.tags.find(([key]) => key === "emoji")?.[1] ?? ""; - if ( - event.content.length > STATUS_TEXT_LIMIT || - emoji.length > 100 || - /[\r\n]/.test(event.content + emoji) - ) - return; + if (!validStatusText(event.content, emoji)) return; const expiration = event.tags.find(([key]) => key === "expiration")?.[1]; const expiresAt = expiration === undefined ? undefined : Number(expiration); if ( @@ -221,11 +216,7 @@ export function createUserStatuses( if (saving) throw new Error("A status update is already in progress."); const text = input.text.trim(); const emoji = input.emoji.trim(); - if ( - text.length > STATUS_TEXT_LIMIT || - emoji.length > 100 || - /[\r\n]/.test(text + emoji) - ) + if (!validStatusText(text, emoji)) throw new Error("Keep your status to one short line (100 characters)."); const expiresAt = text || emoji ? input.expiresAt : undefined; if ( From 3f85fdc7c42fff12e31e1fae7dec0a28fbeffa2a Mon Sep 17 00:00:00 2001 From: Carl <32a2e2c9d428ee08902cab75d956da2c1d235a22d4766b0dd4138bf6e2e5db1d@buzz.block.builderlab.xyz> Date: Tue, 6 Oct 2026 15:54:24 -0700 Subject: [PATCH 2/3] Consolidate broker sidebar toggles and bound archive test setup Signed-off-by: Carl <32a2e2c9d428ee08902cab75d956da2c1d235a22d4766b0dd4138bf6e2e5db1d@buzz.block.builderlab.xyz> --- dev/archive.test.mjs | 44 +++-- dev/relay-broker.mjs | 8 +- dev/sidebar-group-moves.test.mjs | 2 +- dev/sidebar-intents.test.mjs | 4 +- dev/sidebar-mutes-broker.test.mjs | 2 +- dev/sidebar-mutes.mjs | 86 --------- dev/sidebar-mutes.test.mjs | 207 --------------------- dev/sidebar-preference-writes.test.mjs | 2 +- dev/sidebar-stars.mjs | 86 --------- dev/sidebar-stars.test.mjs | 211 ---------------------- dev/sidebar-toggle.mjs | 102 +++++++++++ dev/sidebar-toggle.test.mjs | 240 +++++++++++++++++++++++++ docs/contributing.md | 6 +- 13 files changed, 385 insertions(+), 615 deletions(-) delete mode 100644 dev/sidebar-mutes.mjs delete mode 100644 dev/sidebar-mutes.test.mjs delete mode 100644 dev/sidebar-stars.mjs delete mode 100644 dev/sidebar-stars.test.mjs create mode 100644 dev/sidebar-toggle.mjs create mode 100644 dev/sidebar-toggle.test.mjs diff --git a/dev/archive.test.mjs b/dev/archive.test.mjs index 000bf30ef..75a22e22b 100644 --- a/dev/archive.test.mjs +++ b/dev/archive.test.mjs @@ -253,19 +253,37 @@ test("clear reclaims every free page from a multi-megabyte archive", () => { const file = path(), h = harness(file), db = new DatabaseSync(file); - for (let i = 0; i < 200; i++) h.ingest(event(i)); - // Inflate synthetic stored envelopes without weakening real ingest validation. - db.exec( - "UPDATE archive_events SET envelope=zeroblob(16000); PRAGMA wal_checkpoint(TRUNCATE)", - ); - const before = statSync(file).size; - h.request({ action: "clear", kind: 24200 }); - expect(statSync(file).size).toBeLessThan(before); - expect(statSync(`${file}-wal`).size).toBe(0); - expect(db.prepare("PRAGMA freelist_count").get().freelist_count).toBe(0); - expect(h.settings().bytes).toBe(0); - db.close(); - h.store.close(); + try { + // Reclamation exercises stored pages, not 200 fresh-ingest crypto checks and + // commits. Seed the same multi-megabyte footprint in one transaction; the + // ingest/decoding tests above retain real signed, encrypted envelopes. + const insert = db.prepare( + "INSERT INTO archive_events(viewer,community,subscription,id,agent,kind,created,received,bytes,envelope) VALUES (?,?,'observer',?,?,24200,?,?,16000,zeroblob(16000))", + ); + const now = Math.floor(Date.now() / 1000); + db.exec("BEGIN"); + for (let i = 0; i < 200; i++) + insert.run( + viewer, + community, + i.toString(16).padStart(64, "0"), + sender, + now, + now, + ); + db.exec("COMMIT; PRAGMA wal_checkpoint(TRUNCATE)"); + const before = statSync(file).size; + expect(before).toBeGreaterThanOrEqual(200 * 16000); + expect(h.settings().bytes).toBe(200 * 16000); + h.request({ action: "clear", kind: 24200 }); + expect(statSync(file).size).toBeLessThan(before); + expect(statSync(`${file}-wal`).size).toBe(0); + expect(db.prepare("PRAGMA freelist_count").get().freelist_count).toBe(0); + expect(h.settings().bytes).toBe(0); + } finally { + db.close(); + h.store.close(); + } }); test("v1 migration preserves encrypted rows, policy settings and CAS revisions", () => { diff --git a/dev/relay-broker.mjs b/dev/relay-broker.mjs index 33ff09593..8168bbfaa 100644 --- a/dev/relay-broker.mjs +++ b/dev/relay-broker.mjs @@ -5,10 +5,6 @@ import { isWorkflowDefinitionBatch } from "../src/features/workflows/queries.ts" import { validStatusTemplate } from "./user-status.mjs"; import { memoryFilter, decodeAgentMemory } from "./agent-memory.mjs"; import { memoryResponseText } from "../src/features/agents/memory.ts"; -import { - assertSidebarMuteIntent, - mutateSidebarMute, -} from "./sidebar-mutes.mjs"; import { prepareMedia } from "./media-preparation.mjs"; import { assertSidebarSortIntent, mutateSidebarSort } from "./sidebar-sort.mjs"; import { readProjectGit } from "./project-git.mjs"; @@ -47,7 +43,9 @@ import { SocketRequestError } from "../src/features/relay/socket-requests.ts"; import { assertSidebarStarIntent, mutateSidebarStar, -} from "./sidebar-stars.mjs"; + assertSidebarMuteIntent, + mutateSidebarMute, +} from "./sidebar-toggle.mjs"; import { validateWorkflowEvent, WORKFLOW_KINDS, diff --git a/dev/sidebar-group-moves.test.mjs b/dev/sidebar-group-moves.test.mjs index f24fe699b..e7d15086b 100644 --- a/dev/sidebar-group-moves.test.mjs +++ b/dev/sidebar-group-moves.test.mjs @@ -11,7 +11,7 @@ import { decodeSidebarPreferences, mutateSidebarAssignment, } from "./sidebar-preferences.mjs"; -import { mutateSidebarStar } from "./sidebar-stars.mjs"; +import { mutateSidebarStar } from "./sidebar-toggle.mjs"; async function setup({ cachedAssignment = true } = {}) { const secret = generateSecretKey(); diff --git a/dev/sidebar-intents.test.mjs b/dev/sidebar-intents.test.mjs index 764c7a9e4..0e202c8f6 100644 --- a/dev/sidebar-intents.test.mjs +++ b/dev/sidebar-intents.test.mjs @@ -1,7 +1,7 @@ import { expect, it } from "vitest"; import { assertSidebarAssignmentIntent } from "./sidebar-preferences.mjs"; -import { assertSidebarStarIntent } from "./sidebar-stars.mjs"; -import { assertSidebarMuteIntent } from "./sidebar-mutes.mjs"; +import { assertSidebarStarIntent } from "./sidebar-toggle.mjs"; +import { assertSidebarMuteIntent } from "./sidebar-toggle.mjs"; import { assertSidebarSortIntent } from "./sidebar-sort.mjs"; it("keeps broker-only envelope checks before shared sidebar policy", () => { diff --git a/dev/sidebar-mutes-broker.test.mjs b/dev/sidebar-mutes-broker.test.mjs index 5810aa767..55adc6078 100644 --- a/dev/sidebar-mutes-broker.test.mjs +++ b/dev/sidebar-mutes-broker.test.mjs @@ -5,7 +5,7 @@ import { brokerSocket, openBrokerSocket } from "../tests/broker-socket.mjs"; import { generateSecretKey, getPublicKey, verifyEvent } from "nostr-tools"; import { SIDEBAR_HEAD_BYTES } from "./sidebar-preferences.mjs"; import { relayBrokerPlugin } from "./relay-broker.mjs"; -import { prepareSidebarMute } from "./sidebar-mutes.mjs"; +import { prepareSidebarMute } from "./sidebar-toggle.mjs"; import { connectBrokerTransport } from "../src/features/relay/transport.ts"; import { fixtureRelayUrl, fixtureAliases } from "../tests/relay-config.ts"; diff --git a/dev/sidebar-mutes.mjs b/dev/sidebar-mutes.mjs deleted file mode 100644 index cb2ebd09c..000000000 --- a/dev/sidebar-mutes.mjs +++ /dev/null @@ -1,86 +0,0 @@ -import { - editSidebarToggle, - validSidebarChannelId, -} from "../src/features/relay/sidebar-edits.ts"; -import { finalizeEvent, getPublicKey, nip44 } from "nostr-tools"; -import { decodeSidebarPreferences } from "./sidebar-preferences.mjs"; - -const COORDINATE = "channel-mutes"; -export function assertSidebarMuteIntent(intent) { - if ( - !intent || - typeof intent !== "object" || - Array.isArray(intent) || - typeof intent.channelId !== "string" || - !validSidebarChannelId(intent.channelId) || - typeof intent.muted !== "boolean" || - Object.keys(intent).some((key) => !["channelId", "muted"].includes(key)) - ) - throw new Error("Invalid sidebar mute intent"); -} - -/** One explicit mute intent against a fresh signed head; keep unmute tombstones. */ -export function prepareSidebarMute(events, intent, secret, now = Date.now()) { - assertSidebarMuteIntent(intent); - // The shared bounded decoder verifies signature, own author, schema and budgets. - decodeSidebarPreferences(events, secret); - if ( - events.length > 1 || - events.some( - (event) => - !event.tags.some( - ([name, value]) => name === "d" && value === COORDINATE, - ), - ) - ) - throw new Error("Invalid sidebar mute head"); - const viewer = getPublicKey(secret); - const key = nip44.v2.utils.getConversationKey(secret, viewer); - try { - const head = events[0]; - const current = head - ? JSON.parse(nip44.v2.decrypt(head.content, key)) - : { version: 1, channels: {} }; - const mutes = editSidebarToggle( - current, - intent.channelId, - "muted", - intent.muted, - now, - ); - if (mutes === current) return { mutes }; - const event = finalizeEvent( - { - kind: 30078, - content: nip44.v2.encrypt(JSON.stringify(mutes), key), - created_at: Math.max( - Math.floor(now / 1000), - (head?.created_at ?? 0) + 1, - ), - tags: [ - ["d", COORDINATE], - ["t", COORDINATE], - ], - }, - secret, - ); - // Refuse over-budget changes rather than silently trimming other channels. - decodeSidebarPreferences([event], secret); - return { mutes, event }; - } finally { - key.fill(0); - } -} - -export async function mutateSidebarMute(intent, secret, readHead, publish) { - assertSidebarMuteIntent(intent); - const draft = prepareSidebarMute(await readHead(), intent, secret); - if (!draft.event) return draft.mutes; - await publish(draft.event); - const confirmation = prepareSidebarMute(await readHead(), intent, secret); - if (confirmation.event) - throw new Error( - "Sidebar mutes changed on another device; reload and try again", - ); - return confirmation.mutes; -} diff --git a/dev/sidebar-mutes.test.mjs b/dev/sidebar-mutes.test.mjs deleted file mode 100644 index 149f0bd71..000000000 --- a/dev/sidebar-mutes.test.mjs +++ /dev/null @@ -1,207 +0,0 @@ -import { expect, it, vi } from "vitest"; -import { - finalizeEvent, - generateSecretKey, - getPublicKey, - nip44, - verifyEvent, -} from "nostr-tools"; -import { - assertSidebarMuteIntent, - prepareSidebarMute, - mutateSidebarMute, -} from "./sidebar-mutes.mjs"; -import { - decodeSidebarPreferences, - SIDEBAR_REQUEST_BYTES, -} from "./sidebar-preferences.mjs"; - -function harness() { - const secret = generateSecretKey(); - const viewer = getPublicKey(secret); - return { - secret, - viewer, - encrypt(channels, overrides = {}) { - const key = nip44.v2.utils.getConversationKey(secret, viewer); - try { - return finalizeEvent( - { - kind: 30078, - created_at: 100, - tags: [["d", "channel-mutes"]], - content: nip44.v2.encrypt( - JSON.stringify({ version: 1, channels }), - key, - ), - ...overrides, - }, - secret, - ); - } finally { - key.fill(0); - } - }, - }; -} -it("rejects invalid intent shapes before relay reads", async () => { - const h = harness(); - for (const intent of [ - null, - [], - {}, - { channelId: "", muted: true }, - { channelId: "a" }, - { channelId: "a", muted: 1 }, - { channelId: "x".repeat(257), muted: true }, - { channelId: "a", muted: true, extra: 1 }, - ]) - expect(() => assertSidebarMuteIntent(intent)).toThrow( - "Invalid sidebar mute intent", - ); - const read = vi.fn(); - await expect(mutateSidebarMute({}, h.secret, read, vi.fn())).rejects.toThrow( - "Invalid sidebar mute intent", - ); - expect(read).not.toHaveBeenCalled(); -}); -it("encrypts explicit Mute/Unmute with monotonic timestamps and preserves unrelated tombstones", () => { - const h = harness(); - const channels = { - alpha: { muted: false, updatedAt: 60000 }, - beta: { muted: true, updatedAt: 2 }, - gone: { muted: false, updatedAt: 3 }, - }; - const added = prepareSidebarMute( - [h.encrypt(channels)], - { channelId: "alpha", muted: true }, - h.secret, - 50000, - ); - expect(verifyEvent(added.event)).toBe(true); - expect(added.event).toMatchObject({ - pubkey: h.viewer, - kind: 30078, - created_at: 101, - tags: [ - ["d", "channel-mutes"], - ["t", "channel-mutes"], - ], - }); - expect(added.event.content).not.toContain("alpha"); - expect(added.mutes.channels).toEqual({ - ...channels, - alpha: { muted: true, updatedAt: 60001 }, - }); - expect(decodeSidebarPreferences([added.event], h.secret).muted).toEqual([ - "alpha", - "beta", - ]); - const removed = prepareSidebarMute( - [added.event], - { channelId: "alpha", muted: false }, - h.secret, - 50000, - ); - expect(removed.mutes.channels).toEqual({ - ...channels, - alpha: { muted: false, updatedAt: 60002 }, - }); - expect(decodeSidebarPreferences([removed.event], h.secret).muted).toEqual([ - "beta", - ]); - expect( - prepareSidebarMute( - [removed.event], - { channelId: "alpha", muted: false }, - h.secret, - ).event, - ).toBeUndefined(); - expect( - prepareSidebarMute([], { channelId: "new", muted: false }, h.secret, 50000) - .mutes.channels, - ).toEqual({ new: { muted: false, updatedAt: 50000 } }); -}); -it("refuses untrusted, ambiguous, malformed and over-budget heads rather than seeding", () => { - const h = harness(), - other = harness(); - const intent = { channelId: "alpha", muted: true }; - const valid = h.encrypt({}); - for (const events of [ - null, - [other.encrypt({})], - [valid, valid], - [{ ...JSON.parse(JSON.stringify(valid)), sig: "0".repeat(128) }], - [h.encrypt({}, { tags: [["d", "channel-sections"]] })], - [ - h.encrypt( - {}, - { - tags: [ - ["d", "channel-mutes"], - ["d", "channel-mutes"], - ], - }, - ), - ], - [h.encrypt({ alpha: { muted: true, updatedAt: -1 } })], - [h.encrypt({}, { content: "x".repeat(SIDEBAR_REQUEST_BYTES) })], - ]) - expect(() => prepareSidebarMute(events, intent, h.secret)).toThrow(); - const full = Object.fromEntries( - Array.from({ length: 500 }, (_, i) => [ - `id-${i}`, - { muted: false, updatedAt: 1 }, - ]), - ); - expect(() => prepareSidebarMute([h.encrypt(full)], intent, h.secret)).toThrow( - "budget exceeded", - ); -}); -it("confirms fresh retained state, including newer unrelated entries, and does not publish no-ops", async () => { - const h = harness(); - let heads = []; - const read = vi.fn(async () => heads); - const publish = vi.fn(async () => { - heads = [ - h.encrypt({ - alpha: { muted: true, updatedAt: 1 }, - beta: { muted: true, updatedAt: 2 }, - }), - ]; - }); - const intent = { channelId: "alpha", muted: true }; - expect( - (await mutateSidebarMute(intent, h.secret, read, publish)).channels, - ).toHaveProperty("beta"); - expect(read).toHaveBeenCalledTimes(2); - expect(publish).toHaveBeenCalledOnce(); - await mutateSidebarMute(intent, h.secret, read, publish); - expect(publish).toHaveBeenCalledOnce(); -}); -it("does not report success on read/publish failures or conflicting confirmation", async () => { - const h = harness(), - intent = { channelId: "alpha", muted: true }; - const publish = vi.fn(); - await expect( - mutateSidebarMute( - intent, - h.secret, - async () => { - throw new Error("read failed"); - }, - publish, - ), - ).rejects.toThrow("read failed"); - expect(publish).not.toHaveBeenCalled(); - const read = vi.fn(async () => []); - await expect( - mutateSidebarMute(intent, h.secret, read, async () => { - throw new Error("publish failed"); - }), - ).rejects.toThrow("publish failed"); - expect(read).toHaveBeenCalledOnce(); - await expect( - mutateSidebarMute(intent, h.secret, read, publish), - ).rejects.toThrow("changed on another device"); -}); diff --git a/dev/sidebar-preference-writes.test.mjs b/dev/sidebar-preference-writes.test.mjs index b957bac2c..886533dba 100644 --- a/dev/sidebar-preference-writes.test.mjs +++ b/dev/sidebar-preference-writes.test.mjs @@ -9,7 +9,7 @@ import { } from "nostr-tools"; import { SIDEBAR_HEAD_BYTES } from "./sidebar-preferences.mjs"; import { relayBrokerPlugin } from "./relay-broker.mjs"; -import { prepareSidebarStar } from "./sidebar-stars.mjs"; +import { prepareSidebarStar } from "./sidebar-toggle.mjs"; import { connectBrokerTransport } from "../src/features/relay/transport.ts"; import { fixtureRelayUrl, fixtureAliases } from "../tests/relay-config.ts"; diff --git a/dev/sidebar-stars.mjs b/dev/sidebar-stars.mjs deleted file mode 100644 index ed9e79c2e..000000000 --- a/dev/sidebar-stars.mjs +++ /dev/null @@ -1,86 +0,0 @@ -import { - editSidebarToggle, - validSidebarChannelId, -} from "../src/features/relay/sidebar-edits.ts"; -import { finalizeEvent, getPublicKey, nip44 } from "nostr-tools"; -import { decodeSidebarPreferences } from "./sidebar-preferences.mjs"; - -const COORDINATE = "channel-stars"; -export function assertSidebarStarIntent(intent) { - if ( - !intent || - typeof intent !== "object" || - Array.isArray(intent) || - typeof intent.channelId !== "string" || - !validSidebarChannelId(intent.channelId) || - typeof intent.starred !== "boolean" || - Object.keys(intent).some((key) => !["channelId", "starred"].includes(key)) - ) - throw new Error("Invalid sidebar star intent"); -} - -/** One explicit star intent against a fresh signed head; keep unstar tombstones. */ -export function prepareSidebarStar(events, intent, secret, now = Date.now()) { - assertSidebarStarIntent(intent); - // The shared bounded decoder verifies signature, own author, schema and budgets. - decodeSidebarPreferences(events, secret); - if ( - events.length > 1 || - events.some( - (event) => - !event.tags.some( - ([name, value]) => name === "d" && value === COORDINATE, - ), - ) - ) - throw new Error("Invalid sidebar star head"); - const viewer = getPublicKey(secret); - const key = nip44.v2.utils.getConversationKey(secret, viewer); - try { - const head = events[0]; - const current = head - ? JSON.parse(nip44.v2.decrypt(head.content, key)) - : { version: 1, channels: {} }; - const stars = editSidebarToggle( - current, - intent.channelId, - "starred", - intent.starred, - now, - ); - if (stars === current) return { stars }; - const event = finalizeEvent( - { - kind: 30078, - content: nip44.v2.encrypt(JSON.stringify(stars), key), - created_at: Math.max( - Math.floor(now / 1000), - (head?.created_at ?? 0) + 1, - ), - tags: [ - ["d", COORDINATE], - ["t", COORDINATE], - ], - }, - secret, - ); - // Refuse over-budget changes rather than silently trimming other channels. - decodeSidebarPreferences([event], secret); - return { stars, event }; - } finally { - key.fill(0); - } -} - -export async function mutateSidebarStar(intent, secret, readHead, publish) { - assertSidebarStarIntent(intent); - const draft = prepareSidebarStar(await readHead(), intent, secret); - if (!draft.event) return draft.stars; - await publish(draft.event); - const confirmation = prepareSidebarStar(await readHead(), intent, secret); - if (confirmation.event) - throw new Error( - "Sidebar stars changed on another device; reload and try again", - ); - return confirmation.stars; -} diff --git a/dev/sidebar-stars.test.mjs b/dev/sidebar-stars.test.mjs deleted file mode 100644 index e4e48572c..000000000 --- a/dev/sidebar-stars.test.mjs +++ /dev/null @@ -1,211 +0,0 @@ -import { expect, it, vi } from "vitest"; -import { - finalizeEvent, - generateSecretKey, - getPublicKey, - nip44, - verifyEvent, -} from "nostr-tools"; -import { - assertSidebarStarIntent, - prepareSidebarStar, - mutateSidebarStar, -} from "./sidebar-stars.mjs"; -import { - decodeSidebarPreferences, - SIDEBAR_REQUEST_BYTES, -} from "./sidebar-preferences.mjs"; - -function harness() { - const secret = generateSecretKey(); - const viewer = getPublicKey(secret); - return { - secret, - viewer, - encrypt(channels, overrides = {}) { - const key = nip44.v2.utils.getConversationKey(secret, viewer); - try { - return finalizeEvent( - { - kind: 30078, - created_at: 100, - tags: [["d", "channel-stars"]], - content: nip44.v2.encrypt( - JSON.stringify({ version: 1, channels }), - key, - ), - ...overrides, - }, - secret, - ); - } finally { - key.fill(0); - } - }, - }; -} -it("rejects invalid intent shapes before relay reads", async () => { - const h = harness(); - for (const intent of [ - null, - [], - {}, - { channelId: "", starred: true }, - { channelId: "a" }, - { channelId: "a", starred: 1 }, - { channelId: "x".repeat(257), starred: true }, - { channelId: "a", starred: true, extra: 1 }, - ]) - expect(() => assertSidebarStarIntent(intent)).toThrow( - "Invalid sidebar star intent", - ); - const read = vi.fn(); - await expect(mutateSidebarStar({}, h.secret, read, vi.fn())).rejects.toThrow( - "Invalid sidebar star intent", - ); - expect(read).not.toHaveBeenCalled(); -}); -it("encrypts explicit Star/Unstar with monotonic timestamps and preserves unrelated tombstones", () => { - const h = harness(); - const channels = { - alpha: { starred: false, updatedAt: 60000 }, - beta: { starred: true, updatedAt: 2 }, - gone: { starred: false, updatedAt: 3 }, - }; - const added = prepareSidebarStar( - [h.encrypt(channels)], - { channelId: "alpha", starred: true }, - h.secret, - 50000, - ); - expect(verifyEvent(added.event)).toBe(true); - expect(added.event).toMatchObject({ - pubkey: h.viewer, - kind: 30078, - created_at: 101, - tags: [ - ["d", "channel-stars"], - ["t", "channel-stars"], - ], - }); - expect(added.event.content).not.toContain("alpha"); - expect(added.stars.channels).toEqual({ - ...channels, - alpha: { starred: true, updatedAt: 60001 }, - }); - expect(decodeSidebarPreferences([added.event], h.secret).starred).toEqual([ - "alpha", - "beta", - ]); - const removed = prepareSidebarStar( - [added.event], - { channelId: "alpha", starred: false }, - h.secret, - 50000, - ); - expect(removed.stars.channels).toEqual({ - ...channels, - alpha: { starred: false, updatedAt: 60002 }, - }); - expect(decodeSidebarPreferences([removed.event], h.secret).starred).toEqual([ - "beta", - ]); - expect( - prepareSidebarStar( - [removed.event], - { channelId: "alpha", starred: false }, - h.secret, - ).event, - ).toBeUndefined(); - expect( - prepareSidebarStar( - [], - { channelId: "new", starred: false }, - h.secret, - 50000, - ).stars.channels, - ).toEqual({}); -}); -it("refuses untrusted, ambiguous, malformed and over-budget heads rather than seeding", () => { - const h = harness(), - other = harness(); - const intent = { channelId: "alpha", starred: true }; - const valid = h.encrypt({}); - for (const events of [ - null, - [other.encrypt({})], - [valid, valid], - [{ ...JSON.parse(JSON.stringify(valid)), sig: "0".repeat(128) }], - [h.encrypt({}, { tags: [["d", "channel-sections"]] })], - [ - h.encrypt( - {}, - { - tags: [ - ["d", "channel-stars"], - ["d", "channel-stars"], - ], - }, - ), - ], - [h.encrypt({ alpha: { starred: true, updatedAt: -1 } })], - [h.encrypt({}, { content: "x".repeat(SIDEBAR_REQUEST_BYTES) })], - ]) - expect(() => prepareSidebarStar(events, intent, h.secret)).toThrow(); - const full = Object.fromEntries( - Array.from({ length: 500 }, (_, i) => [ - `id-${i}`, - { starred: false, updatedAt: 1 }, - ]), - ); - expect(() => prepareSidebarStar([h.encrypt(full)], intent, h.secret)).toThrow( - "budget exceeded", - ); -}); -it("confirms fresh retained state, including newer unrelated entries, and does not publish no-ops", async () => { - const h = harness(); - let heads = []; - const read = vi.fn(async () => heads); - const publish = vi.fn(async () => { - heads = [ - h.encrypt({ - alpha: { starred: true, updatedAt: 1 }, - beta: { starred: true, updatedAt: 2 }, - }), - ]; - }); - const intent = { channelId: "alpha", starred: true }; - expect( - (await mutateSidebarStar(intent, h.secret, read, publish)).channels, - ).toHaveProperty("beta"); - expect(read).toHaveBeenCalledTimes(2); - expect(publish).toHaveBeenCalledOnce(); - await mutateSidebarStar(intent, h.secret, read, publish); - expect(publish).toHaveBeenCalledOnce(); -}); -it("does not report success on read/publish failures or conflicting confirmation", async () => { - const h = harness(), - intent = { channelId: "alpha", starred: true }; - const publish = vi.fn(); - await expect( - mutateSidebarStar( - intent, - h.secret, - async () => { - throw new Error("read failed"); - }, - publish, - ), - ).rejects.toThrow("read failed"); - expect(publish).not.toHaveBeenCalled(); - const read = vi.fn(async () => []); - await expect( - mutateSidebarStar(intent, h.secret, read, async () => { - throw new Error("publish failed"); - }), - ).rejects.toThrow("publish failed"); - expect(read).toHaveBeenCalledOnce(); - await expect( - mutateSidebarStar(intent, h.secret, read, publish), - ).rejects.toThrow("changed on another device"); -}); diff --git a/dev/sidebar-toggle.mjs b/dev/sidebar-toggle.mjs new file mode 100644 index 000000000..0dd8833e2 --- /dev/null +++ b/dev/sidebar-toggle.mjs @@ -0,0 +1,102 @@ +import { + editSidebarToggle, + validSidebarChannelId, +} from "../src/features/relay/sidebar-edits.ts"; +import { finalizeEvent, getPublicKey, nip44 } from "nostr-tools"; +import { decodeSidebarPreferences } from "./sidebar-preferences.mjs"; + +// Only these two fixed host commands exist; never select a coordinate from input. +function sidebarToggle(name, field) { + const resultKey = `${name}s`; + const coordinate = `channel-${resultKey}`; + function assertIntent(intent) { + if ( + !intent || + typeof intent !== "object" || + Array.isArray(intent) || + typeof intent.channelId !== "string" || + !validSidebarChannelId(intent.channelId) || + typeof intent[field] !== "boolean" || + Object.keys(intent).some((key) => !["channelId", field].includes(key)) + ) + throw new Error(`Invalid sidebar ${name} intent`); + } + + /** Apply one explicit toggle to a fresh signed head, preserving tombstones. */ + function prepare(events, intent, secret, now = Date.now()) { + assertIntent(intent); + // The shared bounded decoder verifies signature, own author, schema and budgets. + decodeSidebarPreferences(events, secret); + if ( + events.length > 1 || + events.some( + (event) => + !event.tags.some( + ([name, value]) => name === "d" && value === coordinate, + ), + ) + ) + throw new Error(`Invalid sidebar ${name} head`); + const viewer = getPublicKey(secret); + const key = nip44.v2.utils.getConversationKey(secret, viewer); + try { + const head = events[0]; + const current = head + ? JSON.parse(nip44.v2.decrypt(head.content, key)) + : { version: 1, channels: {} }; + const blob = editSidebarToggle( + current, + intent.channelId, + field, + intent[field], + now, + ); + if (blob === current) return { [resultKey]: blob }; + const event = finalizeEvent( + { + kind: 30078, + content: nip44.v2.encrypt(JSON.stringify(blob), key), + created_at: Math.max( + Math.floor(now / 1000), + (head?.created_at ?? 0) + 1, + ), + tags: [ + ["d", coordinate], + ["t", coordinate], + ], + }, + secret, + ); + // Refuse over-budget changes rather than silently trimming other channels. + decodeSidebarPreferences([event], secret); + return { [resultKey]: blob, event }; + } finally { + key.fill(0); + } + } + + async function mutate(intent, secret, readHead, publish) { + assertIntent(intent); + const draft = prepare(await readHead(), intent, secret); + if (!draft.event) return draft[resultKey]; + await publish(draft.event); + const confirmation = prepare(await readHead(), intent, secret); + if (confirmation.event) + throw new Error( + `Sidebar ${resultKey} changed on another device; reload and try again`, + ); + return confirmation[resultKey]; + } + return { assertIntent, prepare, mutate }; +} + +export const { + assertIntent: assertSidebarStarIntent, + prepare: prepareSidebarStar, + mutate: mutateSidebarStar, +} = sidebarToggle("star", "starred"); +export const { + assertIntent: assertSidebarMuteIntent, + prepare: prepareSidebarMute, + mutate: mutateSidebarMute, +} = sidebarToggle("mute", "muted"); diff --git a/dev/sidebar-toggle.test.mjs b/dev/sidebar-toggle.test.mjs new file mode 100644 index 000000000..f756f1b0a --- /dev/null +++ b/dev/sidebar-toggle.test.mjs @@ -0,0 +1,240 @@ +import { describe, expect, it, vi } from "vitest"; +import { + finalizeEvent, + generateSecretKey, + getPublicKey, + nip44, + verifyEvent, +} from "nostr-tools"; +import { + assertSidebarStarIntent, + prepareSidebarStar, + mutateSidebarStar, + assertSidebarMuteIntent, + prepareSidebarMute, + mutateSidebarMute, +} from "./sidebar-toggle.mjs"; +import { + decodeSidebarPreferences, + SIDEBAR_REQUEST_BYTES, +} from "./sidebar-preferences.mjs"; + +describe.each([ + [ + "star", + "starred", + assertSidebarStarIntent, + prepareSidebarStar, + mutateSidebarStar, + ], + [ + "mute", + "muted", + assertSidebarMuteIntent, + prepareSidebarMute, + mutateSidebarMute, + ], +])("sidebar %s", (name, field, assertIntent, prepare, mutate) => { + const resultKey = `${name}s`; + const coordinate = `channel-${resultKey}`; + function harness() { + const secret = generateSecretKey(); + const viewer = getPublicKey(secret); + return { + secret, + viewer, + encrypt(channels, overrides = {}) { + const key = nip44.v2.utils.getConversationKey(secret, viewer); + try { + return finalizeEvent( + { + kind: 30078, + created_at: 100, + tags: [["d", coordinate]], + content: nip44.v2.encrypt( + JSON.stringify({ version: 1, channels }), + key, + ), + ...overrides, + }, + secret, + ); + } finally { + key.fill(0); + } + }, + }; + } + it("rejects invalid intent shapes before relay reads", async () => { + const h = harness(); + for (const intent of [ + null, + [], + {}, + { channelId: "", [field]: true }, + { channelId: "a" }, + { channelId: "a", [field]: 1 }, + { channelId: "a", [field === "starred" ? "muted" : "starred"]: true }, + { channelId: "x".repeat(257), [field]: true }, + { channelId: "a", [field]: true, extra: 1 }, + ]) + expect(() => assertIntent(intent)).toThrow( + `Invalid sidebar ${name} intent`, + ); + const read = vi.fn(); + await expect(mutate({}, h.secret, read, vi.fn())).rejects.toThrow( + `Invalid sidebar ${name} intent`, + ); + expect(read).not.toHaveBeenCalled(); + }); + it("encrypts explicit enable/disable with monotonic timestamps and preserves unrelated tombstones", () => { + const h = harness(); + const channels = { + alpha: { [field]: false, updatedAt: 60000 }, + beta: { [field]: true, updatedAt: 2 }, + gone: { [field]: false, updatedAt: 3 }, + }; + const added = prepare( + [h.encrypt(channels)], + { channelId: "alpha", [field]: true }, + h.secret, + 50000, + ); + expect(verifyEvent(added.event)).toBe(true); + expect(added.event).toMatchObject({ + pubkey: h.viewer, + kind: 30078, + created_at: 101, + tags: [ + ["d", coordinate], + ["t", coordinate], + ], + }); + expect(added.event.content).not.toContain("alpha"); + expect(added[resultKey].channels).toEqual({ + ...channels, + alpha: { [field]: true, updatedAt: 60001 }, + }); + expect(decodeSidebarPreferences([added.event], h.secret)[field]).toEqual([ + "alpha", + "beta", + ]); + const removed = prepare( + [added.event], + { channelId: "alpha", [field]: false }, + h.secret, + 50000, + ); + expect(removed[resultKey].channels).toEqual({ + ...channels, + alpha: { [field]: false, updatedAt: 60002 }, + }); + expect(decodeSidebarPreferences([removed.event], h.secret)[field]).toEqual([ + "beta", + ]); + expect( + prepare([removed.event], { channelId: "alpha", [field]: false }, h.secret) + .event, + ).toBeUndefined(); + expect( + prepare([], { channelId: "new", [field]: false }, h.secret, 50000)[ + resultKey + ].channels, + ).toEqual( + field === "starred" ? {} : { new: { muted: false, updatedAt: 50000 } }, + ); + }); + it("refuses untrusted, ambiguous, malformed and over-budget heads rather than seeding", () => { + const h = harness(), + other = harness(); + const intent = { channelId: "alpha", [field]: true }; + const valid = h.encrypt({}); + for (const events of [ + null, + [other.encrypt({})], + [valid, valid], + [{ ...JSON.parse(JSON.stringify(valid)), sig: "0".repeat(128) }], + [h.encrypt({}, { tags: [["d", "channel-sections"]] })], + [ + h.encrypt( + {}, + { + tags: [ + ["d", field === "starred" ? "channel-mutes" : "channel-stars"], + ], + }, + ), + ], + [ + h.encrypt( + {}, + { + tags: [ + ["d", coordinate], + ["d", coordinate], + ], + }, + ), + ], + [h.encrypt({ alpha: { [field]: true, updatedAt: -1 } })], + [h.encrypt({}, { content: "x".repeat(SIDEBAR_REQUEST_BYTES) })], + ]) + expect(() => prepare(events, intent, h.secret)).toThrow(); + const full = Object.fromEntries( + Array.from({ length: 500 }, (_, i) => [ + `id-${i}`, + { [field]: false, updatedAt: 1 }, + ]), + ); + expect(() => prepare([h.encrypt(full)], intent, h.secret)).toThrow( + "budget exceeded", + ); + }); + it("confirms fresh retained state, including newer unrelated entries, and does not publish no-ops", async () => { + const h = harness(); + let heads = []; + const read = vi.fn(async () => heads); + const publish = vi.fn(async () => { + heads = [ + h.encrypt({ + alpha: { [field]: true, updatedAt: 1 }, + beta: { [field]: true, updatedAt: 2 }, + }), + ]; + }); + const intent = { channelId: "alpha", [field]: true }; + expect( + (await mutate(intent, h.secret, read, publish)).channels, + ).toHaveProperty("beta"); + expect(read).toHaveBeenCalledTimes(2); + expect(publish).toHaveBeenCalledOnce(); + await mutate(intent, h.secret, read, publish); + expect(publish).toHaveBeenCalledOnce(); + }); + it("does not report success on read/publish failures or conflicting confirmation", async () => { + const h = harness(), + intent = { channelId: "alpha", [field]: true }; + const publish = vi.fn(); + await expect( + mutate( + intent, + h.secret, + async () => { + throw new Error("read failed"); + }, + publish, + ), + ).rejects.toThrow("read failed"); + expect(publish).not.toHaveBeenCalled(); + const read = vi.fn(async () => []); + await expect( + mutate(intent, h.secret, read, async () => { + throw new Error("publish failed"); + }), + ).rejects.toThrow("publish failed"); + expect(read).toHaveBeenCalledOnce(); + await expect(mutate(intent, h.secret, read, publish)).rejects.toThrow( + "changed on another device", + ); + }); +}); diff --git a/docs/contributing.md b/docs/contributing.md index 5f2c10e04..b16ee5df5 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -288,7 +288,9 @@ participant-set policy, and sidebar intent rules with their existing `src/features/relay` owners. The broker still executes those checks host-side and retains its stricter untrusted-envelope checks; native Rust enforcement is unchanged. Status reads still validate raw text before trimming, while local edits -trim first. This does not make broker and native signing policies identical. +trim first. Star and mute commands share one broker implementation in +`sidebar-toggle.mjs`; their distinct absent-unstar/unmute behavior stays in the +shared edit owner. This does not make broker and native signing policies identical. The remaining sequence below requires retiring or replacing browser capabilities; it is not an automatic queue of follow-up cleanup PRs or authorization to delete current @@ -301,7 +303,7 @@ retired or replaced; do not weaken a still-callable endpoint to reduce duplicati | --- | --- | --- | | 1 | `user-status.mjs` | Text/emoji policy is now shared with `src/features/relay/user-status-policy.ts`. Remove the remaining broker shape/time checks only when its status endpoint is retired; do not copy them into Rust incidentally. | | 2 | `channel-kit.mjs`, `session-commands.mjs`, `direct-messages.mjs`, signing branches in `relay-broker.mjs` | Retire browser write capabilities one feature at a time after native acceptance and replacement fixture coverage. Remove each Node validator/encryption helper with its last caller; keep native signing tests. | -| 3 | `read-state.mjs`, `sidebar-{preferences,mutes,sort,stars}.mjs` | Retire encrypted-state broker routes and their Node copies once browser callers no longer need them. Shared frontend edit policy and native custody remain. | +| 3 | `read-state.mjs`, `sidebar-{preferences,sort,toggle}.mjs` | Retire encrypted-state broker routes and their Node copies once browser callers no longer need them. Shared frontend edit policy and native custody remain. | | 4 | `agent-memory.mjs`, `agent-observer.mjs`, `archive.mjs`, `project-git.mjs`, `attachment-{file,upload}.mjs`, `media-preparation.mjs` | Remove feature-by-feature after archive/observer, repository and media browser uses are retired or replaced and native failure/recovery coverage is retained. Do not delete user data or migrate credentials as cleanup. | | 5 | `relay-broker.mjs` and its declaration | Remove residual routes and the custom stream bridge only after their consumers and broker-backed fixtures are retired or replaced. Any shared-transport replacement is a separate design decision, not an assumed prerequisite. | From fafa157e87e32654e32a1806e07973879c495f2b Mon Sep 17 00:00:00 2001 From: Carl <32a2e2c9d428ee08902cab75d956da2c1d235a22d4766b0dd4138bf6e2e5db1d@buzz.block.builderlab.xyz> Date: Tue, 6 Oct 2026 16:05:54 -0700 Subject: [PATCH 3/3] docs: preserve browser capabilities in host consolidation guidance Signed-off-by: Carl <32a2e2c9d428ee08902cab75d956da2c1d235a22d4766b0dd4138bf6e2e5db1d@buzz.block.builderlab.xyz> --- README.md | 5 ++-- dev/README.md | 7 +++-- docs/contributing.md | 67 ++++++++++++++++++++++---------------------- 3 files changed, 40 insertions(+), 39 deletions(-) diff --git a/README.md b/README.md index 8263b374a..6387a6ab8 100644 --- a/README.md +++ b/README.md @@ -74,8 +74,9 @@ per-platform steps and limits. For manual testing of shipped behavior, use `BUZZ_DEV_VIEWER= just desktop` to exercise the native path, even if `.env.local` contains a public viewer pin. -Broker-backed runs do not count as acceptance. New features do not get broker -support; see [host boundaries and testing guidance](docs/contributing.md#shared-logic-and-host-boundaries). +Broker-backed runs do not prove native acceptance. Browser capabilities remain +supported; consolidate shared policy rather than maintain parallel feature logic. +See [host boundaries and testing guidance](docs/contributing.md#shared-logic-and-host-boundaries). Live **browser/broker development** requires an existing Buzz account in the OS credential store: the `buzz-desktop` / `secrets` Keychain entry on macOS, or the same entry in diff --git a/dev/README.md b/dev/README.md index a2bbb654b..5f321bc4e 100644 --- a/dev/README.md +++ b/dev/README.md @@ -9,6 +9,7 @@ for the runtime/identity matrix, ownership rules and cross-host test expectation Keep platform-neutral feature policy under its existing `src/features/*` owner; retain host custody and boundary enforcement here. Existing tests are discovered by Vitest. [Broker setup](../README.md#relay-channels) requires an explicit public -identity pin; never put a private key in environment configuration. The broker is -being shrunk and does not receive new feature support. Use the native path for -manual acceptance; see the [broker reduction sequence](../docs/contributing.md#broker-reduction-sequence). +identity pin; never put a private key in environment configuration. Preserve +supported browser capabilities while consolidating duplicate implementations. +Use the native path for native acceptance and browser workflows to validate +browser support; see the [consolidation guidance](../docs/contributing.md#broker-consolidation-and-completion). diff --git a/docs/contributing.md b/docs/contributing.md index b16ee5df5..86f776f37 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -202,11 +202,13 @@ Browser and desktop share the React application, community sessions, durable outbox, protocol models and live scheduler. **The goal is one code path per feature in development and release:** desktop development uses the same shared frontend and Rust host as packaged desktop, not a Node implementation of the feature. -`dev/` is only for browser-only development and broker tests, and is being shrunk; -it is not a second product backend. New features do not get broker support. A -pinned `just web` run therefore cannot reach new native features; develop and test -those with `BUZZ_DEV_VIEWER= just desktop` instead. Node code does not run in a -browser merely because it serves one, and packaged desktop has no Node backend. +`dev/` supplies browser-development host support and broker tests, not a second +owner of feature logic. **Preserve supported browser capabilities:** reduce +duplicate implementations, not available workflows. Reuse shared feature owners +rather than add parallel Node feature logic; native-only capabilities do not +require speculative browser parity. Develop and test those with +`BUZZ_DEV_VIEWER= just desktop`. Node code does not run in a browser merely because +it serves one, and packaged desktop has no Node backend. | Mode | Identity and relay host | | --- | --- | @@ -243,10 +245,12 @@ app as incidental cleanup. See [identity custody and acceptance](identity.md). contract cases are the long-term pattern for necessary cross-language logic, not a universal adapter or code generator. Existing JSON fixtures can be read by Vitest and Rust `include_str!`. -- **`dev/` Node copy + Rust implementation:** only Rust ships. Shared cases are a - temporary drift check until the Node copy is removed, not a reason to preserve - or expand it. Document intentional or unresolved differences with separate - expected outcomes; neither implementation automatically defines intended policy. +- **Node browser host + Rust native host:** only Rust ships in packaged desktop, + but browser workflows still need host support. Share application policy where + practical; retain necessary host enforcement and I/O. Shared cases check drift + wherever both hosts implement the same contract, without requiring automatic + parity. Document intentional or unresolved differences with separate expected + outcomes; neither implementation automatically defines intended policy. Changing outcomes is a behavior decision, not a refactor. Do not add a second source of feature policy in `dev/`. @@ -259,13 +263,12 @@ same change. Reuse existing runners and CI; this is not an extra full-suite gate for each edit. Keep feature details with their owner, not copied into this guide. [Canvas shape cases](../src/features/channel-templates/canvas-signing-contract.json) -are a temporary Node/Rust mirror check, not a permanent dual-host contract. They -cover kind-40100 tag shape, including an explicitly rejected native deserialization -case; each host separately tests the UTF-8 content limit. This is not whole endpoint -parity: broker freshness checks, native serialized-event limits, HTTP/IPC -authorization and publication outcomes are separate layers. Existing host-specific -checks remain necessary until the corresponding broker endpoint is retired; retain -the Rust regression cases when removing the Node consumer. +check the existing Node/Rust contract for kind-40100 tag shape, including an +explicitly rejected native deserialization case; each host separately tests the +UTF-8 content limit. This is not whole endpoint parity: broker freshness checks, +native serialized-event limits, HTTP/IPC authorization and publication outcomes +are separate layers. Keep host-specific checks and regression coverage while the +corresponding boundary is callable, including after any adapter replacement. **Manual testing of anything that ships uses `BUZZ_DEV_VIEWER= just desktop`.** The explicit empty value overrides a pin inherited from `.env.local`; merely @@ -281,7 +284,7 @@ agreed isolated identity/data setup; browser fixtures and test identities must never use real keys. Do not launch a native app or switch someone's active identity merely to follow this testing guidance. -### Broker reduction sequence +### Broker consolidation and completion The behavior-preserving cleanup pass shares status text/emoji limits, DM participant-set policy, and sidebar intent rules with their existing @@ -292,26 +295,22 @@ trim first. Star and mute commands share one broker implementation in `sidebar-toggle.mjs`; their distinct absent-unstar/unmute behavior stays in the shared edit owner. This does not make broker and native signing policies identical. -The remaining sequence below requires retiring or replacing browser capabilities; -it is not an automatic queue of follow-up cleanup PRs or authorization to delete current -browser workflows. Each slice must identify its callers, move needed regression -coverage to the shipped owner, and delete displaced code in the same change. A -whole broker endpoint/module can go only after its browser use is explicitly -retired or replaced; do not weaken a still-callable endpoint to reduce duplication. - -| Order | Existing `dev/` responsibility | Reduction and exit condition | -| --- | --- | --- | -| 1 | `user-status.mjs` | Text/emoji policy is now shared with `src/features/relay/user-status-policy.ts`. Remove the remaining broker shape/time checks only when its status endpoint is retired; do not copy them into Rust incidentally. | -| 2 | `channel-kit.mjs`, `session-commands.mjs`, `direct-messages.mjs`, signing branches in `relay-broker.mjs` | Retire browser write capabilities one feature at a time after native acceptance and replacement fixture coverage. Remove each Node validator/encryption helper with its last caller; keep native signing tests. | -| 3 | `read-state.mjs`, `sidebar-{preferences,sort,toggle}.mjs` | Retire encrypted-state broker routes and their Node copies once browser callers no longer need them. Shared frontend edit policy and native custody remain. | -| 4 | `agent-memory.mjs`, `agent-observer.mjs`, `archive.mjs`, `project-git.mjs`, `attachment-{file,upload}.mjs`, `media-preparation.mjs` | Remove feature-by-feature after archive/observer, repository and media browser uses are retired or replaced and native failure/recovery coverage is retained. Do not delete user data or migrate credentials as cleanup. | -| 5 | `relay-broker.mjs` and its declaration | Remove residual routes and the custom stream bridge only after their consumers and broker-backed fixtures are retired or replaced. Any shared-transport replacement is a separate design decision, not an assumed prerequisite. | +There is no follow-up retirement queue. Further consolidation needs a demonstrated +duplicate responsibility, a current shared owner or justified replacement, and +preserved browser and native behavior. Necessary host-specific enforcement and +I/O are not duplication to remove merely because two hosts implement them. + +A broker endpoint/module can be removed only after a replacement preserves its +supported browser workflow and custody guarantees, all callers move, and relevant +regression coverage follows. Native acceptance alone does not prove a browser +replacement works. Capability retirement is a separate explicit product decision, +not part of this cleanup; do not weaken a callable endpoint to reduce line count. Developer tools (`developer-settings.ts`, `live-setup-probe.mjs`) are not Rust feature mirrors. Legacy library and hosted-community helpers (`agent-library.mjs`, -`builderlab.mjs`) need separate caller/capability decisions before removal; they -are not automatically replaced by native identity. This sequence adds no new -backend and changes no runtime defaults or credentials. +`builderlab.mjs`) are not automatically replaced by native identity. A transport +replacement or new backend needs a separate design decision. This cleanup changes +no runtime defaults or credentials. ## Interactive product iteration