From b58de7cfa7a13b1a6dbb2fc269186439a6fb79a0 Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Tue, 25 Aug 2026 13:32:09 -0700 Subject: [PATCH 1/9] fix(desktop-messages): preserve inline agent mentions with persistent addressing (#6793) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **Category:** fix **User Impact:** Users can place `@Agent` mentions inline anywhere in a message—even when that agent is also persistently addressed—so separate agents can receive separate instructions in the same message. **Problem:** Selecting an agent from the composer mention picker could turn the selection into persistent addressing instead of leaving an inline `@Agent` at the cursor. That made persistent addressing and inline composition mutually exclusive: once the persistent behavior took over, users lost the clear `@Agent A do X, @Agent B do Y` message structure they previously had. **Solution:** Make inline mentioning and persistent addressing independent behaviors: - Ordinary mention selections always insert `@Agent` inline at the current cursor position. - This remains true when the agent is already persistently addressed; the persistent audience never blocks or replaces an inline mention. - If **Automatically mention agents** is enabled, a successfully sent inline mention may additionally make that agent persistent for later messages. Existing saved preferences remain respected. - The dedicated automatic-mention control and primary+Shift+Enter shortcut continue to add or remove persistent addressing directly. - For users without a saved preference, **Automatically mention agents** now defaults off.
File changes **desktop/src/features/messages/lib/autoPinMentionedAgentsPreference.test.mjs** Updates preference coverage for the default-off behavior while preserving explicit saved choices. **desktop/src/features/messages/lib/autoPinMentionedAgentsPreference.ts** Defaults automatic post-send persistence off when no valid preference exists. **desktop/src/features/messages/ui/useAgentAddressLockPicker.test.mjs** Verifies ordinary picker selections insert inline mentions without changing or pulsing the persistent audience, including when the selected agent is already persistently addressed. **desktop/src/features/messages/ui/useAgentAddressLockPicker.ts** Keeps ordinary mention selection on the existing inline insertion path while preserving the separate persistent-address controls and shortcut. **desktop/tests/e2e/persistent-agent-audience.spec.ts** Covers separately targeted inline instructions to two agents, signed outgoing recipients, default-off persistence, and explicit opt-in persistence scenarios.
## Reproduction steps ### Inline mentions without persistence 1. Open a channel with at least two available agents and leave **Automatically mention agents** disabled. 2. Use the composer mention picker to insert the first agent, type an instruction, then insert a second agent and type a different instruction. 3. Confirm the draft reads like `@Agent A review this, @Agent B test that` and neither agent appears in the persistent addressed-agent controls. 4. Send the message and confirm both agents are recipients while neither remains persistently addressed for the next draft. ### Inline mentions with persistence 1. Enable **Automatically mention agents**, then mention an agent inline and send successfully. 2. Confirm that agent becomes persistently addressed for later messages. 3. In a new draft, select the same agent from the mention picker again. 4. Confirm a new inline `@Agent` is inserted at the cursor while the agent remains persistently addressed. 5. Confirm the dedicated automatic-mention control and primary+Shift+Enter shortcut can still add or remove persistent addressing directly. ## Demo - **Before:** Persistent addressing could consume an ordinary picker selection, preventing users from placing that agent inline in the message. - **After:** Ordinary selection always produces an inline `@Agent`; persistence is a separate optional behavior that can coexist with inline mentions. ### Related issue N/A — reported through the Buzz feature room. ### Testing At `a4579c0a666f3644abe44271614186d7b9356bf5`: - Post-push hooks passed desktop check, desktop typecheck, and the full desktop unit suite. - The two persistence-focused Playwright smoke scenarios passed after explicitly opting into **Automatically mention agents**. - The one-time multi-agent targeting and persistent-address shortcut Playwright scenarios passed on the feature changes before the final rebase; CI is validating the rebased branch. --------- Signed-off-by: Taylor Ho Co-authored-by: Carl --- .../autoPinMentionedAgentsPreference.test.mjs | 20 +- .../lib/autoPinMentionedAgentsPreference.ts | 2 +- .../src/features/messages/lib/useDrafts.ts | 4 + .../features/messages/ui/MessageComposer.tsx | 55 ++--- .../MessageComposerDraftImagePersist.test.mjs | 219 ++++++++++++++++++ .../messages/ui/useDraftPersistSnapshot.ts | 73 +++++- desktop/tests/e2e/mentions.spec.ts | 30 ++- .../e2e/persistent-agent-audience.spec.ts | 26 ++- 8 files changed, 373 insertions(+), 56 deletions(-) diff --git a/desktop/src/features/messages/lib/autoPinMentionedAgentsPreference.test.mjs b/desktop/src/features/messages/lib/autoPinMentionedAgentsPreference.test.mjs index d7516fe691..b30008109c 100644 --- a/desktop/src/features/messages/lib/autoPinMentionedAgentsPreference.test.mjs +++ b/desktop/src/features/messages/lib/autoPinMentionedAgentsPreference.test.mjs @@ -9,25 +9,25 @@ globalThis.localStorage = { const preference = await import("./autoPinMentionedAgentsPreference.ts"); -test("defaults missing and invalid values to keeping mentioned agents pinned", () => { - assert.equal(preference.parseKeepMentionedAgentsPinned(null), true); - assert.equal(preference.parseKeepMentionedAgentsPinned("invalid"), true); +test("defaults missing and invalid values to one-time agent mentions", () => { + assert.equal(preference.parseKeepMentionedAgentsPinned(null), false); + assert.equal(preference.parseKeepMentionedAgentsPinned("invalid"), false); assert.equal(preference.parseKeepMentionedAgentsPinned("true"), true); assert.equal(preference.parseKeepMentionedAgentsPinned("false"), false); }); test("persists changes to the post-mention pinning preference", () => { - preference.setKeepMentionedAgentsPinned(false); - assert.equal(preference.getKeepMentionedAgentsPinned(), false); - assert.equal( - values.get(preference.KEEP_MENTIONED_AGENTS_PINNED_STORAGE_KEY), - "false", - ); - preference.setKeepMentionedAgentsPinned(true); assert.equal(preference.getKeepMentionedAgentsPinned(), true); assert.equal( values.get(preference.KEEP_MENTIONED_AGENTS_PINNED_STORAGE_KEY), "true", ); + + preference.setKeepMentionedAgentsPinned(false); + assert.equal(preference.getKeepMentionedAgentsPinned(), false); + assert.equal( + values.get(preference.KEEP_MENTIONED_AGENTS_PINNED_STORAGE_KEY), + "false", + ); }); diff --git a/desktop/src/features/messages/lib/autoPinMentionedAgentsPreference.ts b/desktop/src/features/messages/lib/autoPinMentionedAgentsPreference.ts index 0a3821b4e3..8f8e0b12d6 100644 --- a/desktop/src/features/messages/lib/autoPinMentionedAgentsPreference.ts +++ b/desktop/src/features/messages/lib/autoPinMentionedAgentsPreference.ts @@ -2,7 +2,7 @@ import * as React from "react"; export const KEEP_MENTIONED_AGENTS_PINNED_STORAGE_KEY = "buzz.messages.keepMentionedAgentsPinned"; -export const DEFAULT_KEEP_MENTIONED_AGENTS_PINNED = true; +export const DEFAULT_KEEP_MENTIONED_AGENTS_PINNED = false; const listeners = new Set<() => void>(); let keepMentionedAgentsPinned = readStoredPreference(); diff --git a/desktop/src/features/messages/lib/useDrafts.ts b/desktop/src/features/messages/lib/useDrafts.ts index 2a78e88132..a3e0fcf919 100644 --- a/desktop/src/features/messages/lib/useDrafts.ts +++ b/desktop/src/features/messages/lib/useDrafts.ts @@ -114,6 +114,10 @@ function storageKey(): string { : legacyStorageKey(); } +export function getDraftStoreScope(): string { + return storageKey(); +} + function legacyStorageKey(): string { return `${LEGACY_DRAFT_STORE_KEY_PREFIX}:${currentPubkey}`; } diff --git a/desktop/src/features/messages/ui/MessageComposer.tsx b/desktop/src/features/messages/ui/MessageComposer.tsx index 98c600e87f..d4db6f5d17 100644 --- a/desktop/src/features/messages/ui/MessageComposer.tsx +++ b/desktop/src/features/messages/ui/MessageComposer.tsx @@ -170,32 +170,33 @@ function MessageComposerImpl({ media.queuedAttachmentsRef.current.length === 0; const ownsDropZone = mediaController === undefined; const backgroundUpload = useBackgroundMediaUpload(); - useDraftPersistLifecycle({ - effectiveDraftKey, - channelId, - loadDraft: drafts.loadDraft, - persistDraft: drafts.persistDraft, - getMentionRefs: mentions.getDraftMentionRefs, - restoreMentionRefs: mentions.restoreDraftMentionRefs, - livePendingImeta: media.pendingImeta, - setPendingImeta: media.setPendingImeta, - getQueuedAttachments: () => media.queuedAttachmentsRef.current, - saveQueuedAttachmentsForDraft, - clearQueuedAttachments: media.clearQueuedAttachments, - restoreQueuedAttachments: media.restoreQueuedAttachments, - takeQueuedAttachmentsForDraft, - setContent: (content) => { - setComposerContent(content); - richText.setContent(content); - }, - clearContent: () => { - setComposerContent(""); - richText.clearContent(); - }, - setSpoileredAttachmentUrls, - spoileredAttachmentUrlsRef, - syncComposerContentFromEditor, - }); + const { trackAuthoredContent: trackDraftAuthoredContent } = + useDraftPersistLifecycle({ + effectiveDraftKey, + channelId, + loadDraft: drafts.loadDraft, + persistDraft: drafts.persistDraft, + getMentionRefs: mentions.getDraftMentionRefs, + restoreMentionRefs: mentions.restoreDraftMentionRefs, + livePendingImeta: media.pendingImeta, + setPendingImeta: media.setPendingImeta, + getQueuedAttachments: () => media.queuedAttachmentsRef.current, + saveQueuedAttachmentsForDraft, + clearQueuedAttachments: media.clearQueuedAttachments, + restoreQueuedAttachments: media.restoreQueuedAttachments, + takeQueuedAttachmentsForDraft, + setContent: (content) => { + setComposerContent(content); + richText.setContent(content); + }, + clearContent: () => { + setComposerContent(""); + richText.clearContent(); + }, + setSpoileredAttachmentUrls, + spoileredAttachmentUrlsRef, + syncComposerContentFromEditor, + }); // biome-ignore lint/correctness/useExhaustiveDependencies: effectiveDraftKey is the sole trigger React.useEffect(() => { media.setUploadState({ status: "idle" }); @@ -272,6 +273,8 @@ function MessageComposerImpl({ onLinkSelectionChange: (info) => onLinkSelectionChangeRef.current?.(info), onLinkShortcut: () => onLinkShortcutRef.current?.() ?? false, onUpdate: ({ cursor, linkPreviewContent, text }) => { + trackDraftAuthoredContent(text); + contentRef.current = text; setComposerContentFromText(text); setPreviewContent(linkPreviewContent); if (!isSubmitLockedRef.current && !editTargetRef.current) { diff --git a/desktop/src/features/messages/ui/MessageComposerDraftImagePersist.test.mjs b/desktop/src/features/messages/ui/MessageComposerDraftImagePersist.test.mjs index 46b5d91d5b..e5f08b6ebd 100644 --- a/desktop/src/features/messages/ui/MessageComposerDraftImagePersist.test.mjs +++ b/desktop/src/features/messages/ui/MessageComposerDraftImagePersist.test.mjs @@ -585,6 +585,225 @@ test("draft_lifecycle_empty_target_clears_stale_mention_refs", async () => { await handle.unmount(); }); +test("draft_lifecycle_persists_an_explicit_clear_before_async_rerender", async () => { + const DRAFT_KEY = "chan-clear-race"; + setupStore("pubkey-clear-race"); + persistDraftEntry(DRAFT_KEY, "draft text", DRAFT_KEY, [], []); + + let editorContent = ""; + let trackAuthoredContent; + const spoileredRef = { current: new Set() }; + + function HarnessComposer() { + ({ trackAuthoredContent } = useDraftPersistLifecycle({ + effectiveDraftKey: DRAFT_KEY, + channelId: DRAFT_KEY, + loadDraft: loadDraftEntry, + persistDraft: persistDraftEntry, + getMentionRefs: () => [], + restoreMentionRefs: () => {}, + livePendingImeta: [], + setPendingImeta: () => {}, + setContent: (content) => { + editorContent = content; + }, + clearContent: () => { + editorContent = ""; + }, + setSpoileredAttachmentUrls: () => {}, + spoileredAttachmentUrlsRef: spoileredRef, + syncComposerContentFromEditor: () => editorContent, + })); + return null; + } + + const handle = await mountStrictMode(HarnessComposer); + assert.equal(editorContent, "draft text"); + + trackAuthoredContent(""); + assert.equal( + loadDraftEntry(DRAFT_KEY), + undefined, + "the authoritative update removes the stale body even while editor reads lag", + ); + + await handle.unmount(); + assert.equal( + loadDraftEntry(DRAFT_KEY), + undefined, + "async settlement must not repersist the deleted body", + ); + + const remounted = await mountStrictMode(HarnessComposer); + assert.equal(editorContent, "", "the deleted body must not be restored"); + await remounted.unmount(); +}); + +test("draft_lifecycle_clear_caption_preserves_image_and_spoiler_on_remount", async () => { + const DRAFT_KEY = "chan-clear-caption-image"; + setupStore("pubkey-clear-caption-image"); + persistDraftEntry(DRAFT_KEY, "caption", DRAFT_KEY, [IMG_A], [IMG_A.url]); + + let editorContent = ""; + let pendingImeta = []; + let spoileredUrls = new Set(); + let trackAuthoredContent; + + function HarnessComposer() { + ({ trackAuthoredContent } = useDraftPersistLifecycle({ + effectiveDraftKey: DRAFT_KEY, + channelId: DRAFT_KEY, + loadDraft: loadDraftEntry, + persistDraft: persistDraftEntry, + getMentionRefs: () => [], + restoreMentionRefs: () => {}, + livePendingImeta: pendingImeta, + setPendingImeta: (imeta) => { + pendingImeta = imeta; + }, + setContent: (content) => { + editorContent = content; + }, + clearContent: () => { + editorContent = ""; + }, + setSpoileredAttachmentUrls: (urls) => { + spoileredUrls = urls; + }, + spoileredAttachmentUrlsRef: { + get current() { + return spoileredUrls; + }, + }, + syncComposerContentFromEditor: () => editorContent, + })); + return null; + } + + const handle = await mountStrictMode(HarnessComposer); + trackAuthoredContent(""); + editorContent = ""; + await handle.unmount(); + + const remounted = await mountStrictMode(HarnessComposer); + assert.equal(editorContent, ""); + assert.equal(pendingImeta[0]?.url, IMG_A.url); + assert.deepEqual([...spoileredUrls], [IMG_A.url]); + assert.equal(loadDraftEntry(DRAFT_KEY)?.content, ""); + await remounted.unmount(); +}); + +test("draft_lifecycle_clear_caption_preserves_queued_file_on_remount", async () => { + const DRAFT_KEY = "chan-clear-caption-file"; + setupStore("pubkey-clear-caption-file"); + persistDraftEntry(DRAFT_KEY, "caption", DRAFT_KEY, [], []); + const FILE_A = { + file: new File(["report"], "report.pdf", { type: "application/pdf" }), + id: 9, + spoilered: true, + }; + + let editorContent = ""; + let queuedAttachments = [FILE_A]; + let trackAuthoredContent; + const spoileredRef = { current: new Set() }; + + function HarnessComposer() { + ({ trackAuthoredContent } = useDraftPersistLifecycle({ + effectiveDraftKey: DRAFT_KEY, + channelId: DRAFT_KEY, + loadDraft: loadDraftEntry, + persistDraft: persistDraftEntry, + getMentionRefs: () => [], + restoreMentionRefs: () => {}, + livePendingImeta: [], + setPendingImeta: () => {}, + getQueuedAttachments: () => queuedAttachments, + saveQueuedAttachmentsForDraft, + clearQueuedAttachments: () => { + queuedAttachments = []; + }, + restoreQueuedAttachments: (attachments) => { + queuedAttachments = attachments; + }, + takeQueuedAttachmentsForDraft, + setContent: (content) => { + editorContent = content; + }, + clearContent: () => { + editorContent = ""; + }, + setSpoileredAttachmentUrls: () => {}, + spoileredAttachmentUrlsRef: spoileredRef, + syncComposerContentFromEditor: () => editorContent, + })); + return null; + } + + saveQueuedAttachmentsForDraft(DRAFT_KEY, [FILE_A]); + const handle = await mountStrictMode(HarnessComposer); + trackAuthoredContent(""); + editorContent = ""; + await handle.unmount(); + + const remounted = await mountStrictMode(HarnessComposer); + assert.equal(editorContent, ""); + assert.equal(queuedAttachments[0]?.file.name, "report.pdf"); + assert.equal(queuedAttachments[0]?.spoilered, true); + await remounted.unmount(); +}); + +test("draft_lifecycle_clear_authority_is_scoped_to_relay_and_identity", async () => { + const DRAFT_KEY = "shared-key"; + installFreshLocalStorage(); + clearAllDrafts(); + initDraftStore("pubkey-a", "wss://relay-a.example"); + persistDraftEntry(DRAFT_KEY, "workspace A", DRAFT_KEY, [], []); + + let editorContent = ""; + let trackAuthoredContent; + const spoileredRef = { current: new Set() }; + function HarnessComposer() { + ({ trackAuthoredContent } = useDraftPersistLifecycle({ + effectiveDraftKey: DRAFT_KEY, + channelId: DRAFT_KEY, + loadDraft: loadDraftEntry, + persistDraft: persistDraftEntry, + getMentionRefs: () => [], + restoreMentionRefs: () => {}, + livePendingImeta: [], + setPendingImeta: () => {}, + setContent: (content) => { + editorContent = content; + }, + clearContent: () => { + editorContent = ""; + }, + setSpoileredAttachmentUrls: () => {}, + spoileredAttachmentUrlsRef: spoileredRef, + syncComposerContentFromEditor: () => editorContent, + })); + return null; + } + + const workspaceA = await mountStrictMode(HarnessComposer); + trackAuthoredContent(""); + editorContent = ""; + await workspaceA.unmount(); + + initDraftStore("pubkey-b", "wss://relay-b.example"); + persistDraftEntry(DRAFT_KEY, "workspace B", DRAFT_KEY, [], []); + const workspaceB = await mountStrictMode(HarnessComposer); + assert.equal(editorContent, "workspace B"); + await workspaceB.unmount(); + assert.equal(loadDraftEntry(DRAFT_KEY)?.content, "workspace B"); + + initDraftStore("pubkey-a", "wss://relay-a.example"); + const workspaceARemount = await mountStrictMode(HarnessComposer); + assert.equal(editorContent, "", "workspace A stale text must stay cleared"); + await workspaceARemount.unmount(); +}); + test("draft_lifecycle_preserves_local_files_across_a_b_a_switch", async () => { setupStore("pubkey-switch-files"); const FILE_A = { diff --git a/desktop/src/features/messages/ui/useDraftPersistSnapshot.ts b/desktop/src/features/messages/ui/useDraftPersistSnapshot.ts index 14dae33adb..694bf6a2a5 100644 --- a/desktop/src/features/messages/ui/useDraftPersistSnapshot.ts +++ b/desktop/src/features/messages/ui/useDraftPersistSnapshot.ts @@ -2,9 +2,10 @@ import * as React from "react"; import type { ImetaMedia } from "@/features/messages/lib/imetaMediaMarkdown"; import type { QueuedMediaAttachment } from "@/features/messages/lib/backgroundMediaUploadStore"; -import type { - DraftMentionRef, - DraftState, +import { + getDraftStoreScope, + type DraftMentionRef, + type DraftState, } from "@/features/messages/lib/useDrafts"; type UseDraftPersistLifecycleParams = { @@ -60,6 +61,21 @@ type UseDraftPersistLifecycleParams = { syncComposerContentFromEditor: () => string; }; +type UseDraftPersistLifecycleResult = { + /** + * Record the latest authored editor content. Empty content is persisted + * immediately and remains authoritative across composer remounts until a + * later non-empty editor update supersedes it. + */ + trackAuthoredContent: (content: string) => void; +}; + +const authoritativelyClearedDraftKeys = new Set(); + +function scopedDraftKey(draftKey: string): string { + return `${getDraftStoreScope()}:${draftKey}`; +} + /** * Owns the draft-persist lifecycle for `MessageComposer`. * @@ -104,8 +120,10 @@ export function useDraftPersistLifecycle({ setSpoileredAttachmentUrls, spoileredAttachmentUrlsRef, syncComposerContentFromEditor, -}: UseDraftPersistLifecycleParams): void { +}: UseDraftPersistLifecycleParams): UseDraftPersistLifecycleResult { const pendingImetaForPersistRef = React.useRef([]); + const emptyContentIsAuthoritativeRef = React.useRef(false); + const isRestoringContentRef = React.useRef(false); const restoredQueuedAttachmentsRef = React.useRef( [], ); @@ -117,7 +135,7 @@ export function useDraftPersistLifecycle({ pendingImetaForPersistRef.current = livePendingImeta; // biome-ignore lint/correctness/useExhaustiveDependencies: effectiveDraftKey is the sole trigger - React.useEffect(() => { + React.useLayoutEffect(() => { // The outgoing draft is persisted by the cleanup below, which runs before // this body on key changes and has the correct outgoing channelId in its // closure. Do NOT re-persist prevKey here: channelId in this render @@ -134,10 +152,21 @@ export function useDraftPersistLifecycle({ : []; } restoreQueuedAttachments?.(restoredQueuedAttachmentsRef.current); + const authoritativeDraftKey = effectiveDraftKey + ? scopedDraftKey(effectiveDraftKey) + : null; + const wasAuthoritativelyCleared = authoritativeDraftKey + ? authoritativelyClearedDraftKeys.has(authoritativeDraftKey) + : false; const saved = effectiveDraftKey ? loadDraft(effectiveDraftKey) : undefined; + emptyContentIsAuthoritativeRef.current = wasAuthoritativelyCleared; + isRestoringContentRef.current = true; if (saved) { - setContent(saved.content); - restoreMentionRefs(saved.mentionRefs ?? []); + const restoredContent = wasAuthoritativelyCleared ? "" : saved.content; + setContent(restoredContent); + restoreMentionRefs( + wasAuthoritativelyCleared ? [] : (saved.mentionRefs ?? []), + ); // Set the persist-snapshot ref SYNCHRONOUSLY before calling the async // state setter, so the cleanup closure (which may fire before the state // update commits in React StrictMode's simulate-unmount pass) reads the @@ -153,6 +182,7 @@ export function useDraftPersistLifecycle({ setPendingImeta([]); setSpoileredAttachmentUrls(new Set()); } + isRestoringContentRef.current = false; return () => { if (effectiveDraftKey) { @@ -160,7 +190,9 @@ export function useDraftPersistLifecycle({ if (queuedAttachments.length > 0) { saveQueuedAttachmentsForDraft?.(effectiveDraftKey, queuedAttachments); } - const content = syncComposerContentFromEditor(); + const content = emptyContentIsAuthoritativeRef.current + ? "" + : syncComposerContentFromEditor(); persistDraft( effectiveDraftKey, content, @@ -172,4 +204,29 @@ export function useDraftPersistLifecycle({ } }; }, [effectiveDraftKey]); + + const trackAuthoredContent = React.useCallback( + (content: string) => { + if (!effectiveDraftKey || isRestoringContentRef.current) return; + const authoritativeDraftKey = scopedDraftKey(effectiveDraftKey); + if (content.length > 0) { + authoritativelyClearedDraftKeys.delete(authoritativeDraftKey); + emptyContentIsAuthoritativeRef.current = false; + return; + } + authoritativelyClearedDraftKeys.add(authoritativeDraftKey); + emptyContentIsAuthoritativeRef.current = true; + persistDraft( + effectiveDraftKey, + content, + channelId ?? effectiveDraftKey, + [...pendingImetaForPersistRef.current], + [...spoileredAttachmentUrlsRef.current], + [], + ); + }, + [channelId, effectiveDraftKey, persistDraft, spoileredAttachmentUrlsRef], + ); + + return { trackAuthoredContent }; } diff --git a/desktop/tests/e2e/mentions.spec.ts b/desktop/tests/e2e/mentions.spec.ts index 2136e2e12f..edcf523e36 100644 --- a/desktop/tests/e2e/mentions.spec.ts +++ b/desktop/tests/e2e/mentions.spec.ts @@ -1260,7 +1260,7 @@ test("selecting a persona mention reuses an existing persona agent", async ({ await expect(mentionChip).toHaveText("Fizz"); }); -test("managed relay-profile agents with member roles use the agent address tray", async ({ +test("managed relay-profile agents with member roles can be addressed explicitly", async ({ page, }) => { await installMockBridge(page, { @@ -1286,9 +1286,17 @@ test("managed relay-profile agents with member roles use the agent address tray" await input.fill("@char"); const dropdown = autocomplete(page); - await expect(dropdown.getByText("charlie")).toBeVisible(); - await expect(dropdown.getByText("agent")).toBeVisible(); - await input.press("Enter"); + const charlieRow = dropdown.getByTestId( + `mention-suggestion-${TEST_IDENTITIES.charlie.pubkey}`, + ); + await expect(charlieRow.getByText("charlie")).toBeVisible(); + await expect(charlieRow.getByText("agent")).toBeVisible(); + await charlieRow + .getByRole("button", { + name: "Automatically mention charlie", + exact: true, + }) + .click(); await expect(input).toHaveText("@charlie "); await expect(input.locator(".agent-mention-highlight")).toHaveText("charlie"); @@ -2618,7 +2626,7 @@ test("system member-joined rows render the joined person as a plain profile name await expect(joinedPersonName).not.toHaveAttribute("data-mention"); }); -test("selecting a managed non-member agent from a DM addresses it", async ({ +test("a managed non-member agent from a DM can be addressed explicitly", async ({ page, }) => { await installMockBridge(page, { @@ -2638,10 +2646,18 @@ test("selecting a managed non-member agent from a DM addresses it", async ({ await input.fill("@char"); const dropdown = autocomplete(page); - await expect(dropdown.getByText("charlie")).toBeVisible(); + const charlieRow = dropdown.getByTestId( + `mention-suggestion-${TEST_IDENTITIES.charlie.pubkey}`, + ); + await expect(charlieRow.getByText("charlie")).toBeVisible(); await expect(autocomplete(page)).toHaveCount(1); await expect(input.locator(".mention-chip")).toHaveCount(0); - await input.press("Enter"); + await charlieRow + .getByRole("button", { + name: "Automatically mention charlie", + exact: true, + }) + .click(); await expect(input).toHaveText("@charlie "); await expect(input.locator(".agent-mention-highlight")).toHaveText("charlie"); diff --git a/desktop/tests/e2e/persistent-agent-audience.spec.ts b/desktop/tests/e2e/persistent-agent-audience.spec.ts index f9481c91f0..9a02e2e1a8 100644 --- a/desktop/tests/e2e/persistent-agent-audience.spec.ts +++ b/desktop/tests/e2e/persistent-agent-audience.spec.ts @@ -8,6 +8,20 @@ const CHANNEL_ID = "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50"; const AGENT_A = "a".repeat(64); const AGENT_B = "b".repeat(64); const THREAD_ROOT_ID = "mock-general-welcome"; +const KEEP_MENTIONED_AGENTS_PINNED_STORAGE_KEY = + "buzz.messages.keepMentionedAgentsPinned"; + +test.beforeEach(async ({ page }) => { + await page.addInitScript((storageKey) => { + window.localStorage.removeItem(storageKey); + }, KEEP_MENTIONED_AGENTS_PINNED_STORAGE_KEY); +}); + +async function keepMentionedAgentsPinned(page: Page) { + await page.addInitScript((storageKey) => { + window.localStorage.setItem(storageKey, "true"); + }, KEEP_MENTIONED_AGENTS_PINNED_STORAGE_KEY); +} async function seedTheme(page: Page, theme: string, accent = "#c0a2f1") { await page.addInitScript( @@ -271,7 +285,7 @@ test("automatically mentions multiple agents from the mention picker", async ({ ).toBeVisible(); }); -test("Tab immediately selects a manually mentioned agent", async ({ page }) => { +test("Tab inserts a one-time agent mention by default", async ({ page }) => { await installAudienceFixtures(page); await openGeneral(page); @@ -286,7 +300,7 @@ test("Tab immediately selects a manually mentioned agent", async ({ page }) => { await expect(composer.getByTestId("mention-autocomplete")).toHaveCount(0); await expect( composer.getByTestId(`composer-address-lock-${AGENT_A}`), - ).toBeVisible(); + ).toHaveCount(0); const selectAllShortcut = await page.evaluate(() => /mac|iphone|ipad|ipod/i.test(navigator.platform) ? "Meta+A" : "Control+A", ); @@ -301,6 +315,7 @@ test("Tab immediately selects a manually mentioned agent", async ({ page }) => { test("primary+Shift+M addresses the default agent, then selects the highlighted agent", async ({ page, }) => { + await keepMentionedAgentsPinned(page); await installAudienceFixtures(page); await openGeneral(page); @@ -448,7 +463,7 @@ test("the mention button opens settings and can undo an address", async ({ .toContain(AGENT_A); await expect( composer.getByTestId(`composer-address-lock-${AGENT_A}`), - ).toHaveCount(1); + ).toHaveCount(0); }); test("always-mentioned agents remain in the mention button while Enter-send resolves", async ({ @@ -568,9 +583,10 @@ test("a failed always-mentioned send shakes the composer avatar", async ({ await expect(avatar).toHaveAttribute("data-shake-version", "1"); }); -test("a manually mentioned agent becomes selected immediately", async ({ +test("a manual mention persists when automatic mentions are enabled", async ({ page, }) => { + await keepMentionedAgentsPinned(page); await installAudienceFixtures(page, { sendMessageDelayMs: 1_500 }); await openGeneral(page); @@ -652,6 +668,7 @@ test("a manually mentioned agent becomes selected immediately", async ({ test("the auto-pin popover can turn off automatic agent mentions", async ({ page, }) => { + await keepMentionedAgentsPinned(page); await installAudienceFixtures(page); await openGeneral(page); @@ -717,6 +734,7 @@ test("reduced motion removes addressed agents without spatial animation", async page, }) => { await page.emulateMedia({ reducedMotion: "reduce" }); + await keepMentionedAgentsPinned(page); await installAudienceFixtures(page); await openGeneral(page); From 22f32c99e2e983b7e015e8c058cac47438d05b2f Mon Sep 17 00:00:00 2001 From: Wes Date: Tue, 25 Aug 2026 14:35:53 -0600 Subject: [PATCH 2/9] docs(nest): make commit attribution policy-neutral (#6707) ## Summary - replace the generated Nest's unconditional human author/sign-off rules with portable guidance that separates authorship, material co-authorship, DCO certification, and cryptographic signing - defer attribution to repository-local policy, forbid inferred or guessed identities, and require inspection of every outgoing commit - bump the Nest template version so existing installations refresh, with regression coverage for fresh generation and upgrade preservation ### Related issue None found. Related runtime identity work exists in #6177, but this PR is intentionally limited to the generated Nest guidance and its refresh behavior. ### Testing - `bin/just desktop-tauri-clippy` - `bin/just desktop-tauri-test` - `bin/just file-size-check` - `git diff --check` - pre-push hooks: `push-head-scope`, `branch-skew`, `file-size-check`, and `desktop-tauri-checks` Signed-off-by: Wes Co-authored-by: Carl <32a2e2c9d428ee08902cab75d956da2c1d235a22d4766b0dd4138bf6e2e5db1d@buzz.block.builderlab.xyz> --- desktop/src-tauri/src/managed_agents/nest.rs | 2 +- .../src/managed_agents/nest/tests.rs | 43 +++++++++++++++++++ .../src/managed_agents/nest_agents.md | 17 +++++--- 3 files changed, 54 insertions(+), 8 deletions(-) diff --git a/desktop/src-tauri/src/managed_agents/nest.rs b/desktop/src-tauri/src/managed_agents/nest.rs index 72cf466427..3d191926a3 100644 --- a/desktop/src-tauri/src/managed_agents/nest.rs +++ b/desktop/src-tauri/src/managed_agents/nest.rs @@ -48,7 +48,7 @@ const BUZZ_CLI_SKILL_MD: &str = include_str!("nest_skill.md"); /// Template content version for AGENTS.md static content (above managed markers). /// Bump this when changing `nest_agents.md` to trigger refresh on existing installs. /// Version 1 is implicitly "before this mechanism existed" (no version file). -const NEST_AGENTS_VERSION: u32 = 4; +const NEST_AGENTS_VERSION: u32 = 5; /// Template content version for SKILL.md. /// Bump this when changing `nest_skill.md` to trigger refresh on existing installs. diff --git a/desktop/src-tauri/src/managed_agents/nest/tests.rs b/desktop/src-tauri/src/managed_agents/nest/tests.rs index bc67a5b69e..9aa1eeb098 100644 --- a/desktop/src-tauri/src/managed_agents/nest/tests.rs +++ b/desktop/src-tauri/src/managed_agents/nest/tests.rs @@ -41,6 +41,21 @@ fn nest_skill_contains_safe_mention_workflow() { assert!(BUZZ_CLI_SKILL_MD.contains("never changes membership automatically")); } +#[test] +fn nest_agents_template_separates_commit_attribution_claims() { + assert_eq!(AGENTS_MD.matches("## Git Commit Attribution").count(), 1); + assert!(AGENTS_MD.contains( + "Git authorship, co-authorship, DCO sign-off, and cryptographic signing are separate claims" + )); + assert!(AGENTS_MD + .contains("Request, approval, review, or accountability alone is not co-authorship")); + assert!(AGENTS_MD.contains("A sign-off is not an approval marker")); + assert!(AGENTS_MD.contains("Never use another person's signing key")); + assert!(AGENTS_MD.contains("inspect every outgoing commit against the actual upstream or base")); + assert!(AGENTS_MD.contains("An agent-owned repository may use the agent as author")); + assert!(!AGENTS_MD.contains("every commit MUST include a `Signed-off-by`")); +} + #[test] fn ensure_nest_creates_all_dirs_and_agents_md() { let tmp = tempfile::tempdir().unwrap(); @@ -431,6 +446,34 @@ fn refresh_agents_md_writes_version_file() { assert_eq!(version.trim(), NEST_AGENTS_VERSION.to_string()); } +#[test] +fn refresh_agents_md_upgrades_attribution_and_preserves_owned_content() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path().join(".buzz"); + ensure_nest_at(&root).unwrap(); + + let agents_md = root.join("AGENTS.md"); + fs::write( + &agents_md, + "# Buzz Nest\n\n## Git Commit Identity\n\n\ + - **Human sign-off (required):** every commit MUST include a `Signed-off-by`.\n\n\ + \n\ + ## Active Agents\n\n| Name | Persona | How to address |\n\ + |------|---------|----------------|\n| Kit | Builder | @Kit |\n\ + \n\n## Local Notes\n\nKeep me.\n", + ) + .unwrap(); + fs::write(root.join(".nest-agents-version"), "4\n").unwrap(); + + ensure_nest_at(&root).unwrap(); + + let content = fs::read_to_string(&agents_md).unwrap(); + assert_eq!(content.matches("## Git Commit Attribution").count(), 1); + assert!(!content.contains("**Human sign-off (required):**")); + assert!(content.contains("| Kit | Builder | @Kit |")); + assert!(content.contains("## Local Notes\n\nKeep me.")); +} + #[test] fn refresh_skill_md_writes_version_file() { let tmp = tempfile::tempdir().unwrap(); diff --git a/desktop/src-tauri/src/managed_agents/nest_agents.md b/desktop/src-tauri/src/managed_agents/nest_agents.md index 7cb7489b85..dbba2624db 100644 --- a/desktop/src-tauri/src/managed_agents/nest_agents.md +++ b/desktop/src-tauri/src/managed_agents/nest_agents.md @@ -44,15 +44,18 @@ created: 2026-01-15 - **`.scratch/` is disposable** — don't rely on it across sessions - **Stay on task** — only stage files relevant to your current work -## Git Commit Identity +## Git Commit Attribution -The human operator signs off for accountability. +Git authorship, co-authorship, DCO sign-off, and cryptographic signing are separate claims. Follow repository-local rules and the authorizing human's explicit directions; do not infer attribution from repository ownership or from who requested, approved, or reviewed the work. -- **Human sign-off (required):** every commit MUST include a `Signed-off-by` trailer for the human operator who is responsible for the agent's work. Add via `git commit --trailer "Signed-off-by: Human Name "`. One blank line must separate trailers from the commit body. -- **Human credit (`Co-authored-by`):** every commit MUST also include a `Co-authored-by` trailer for the same human operator, with identical name and email to the `Signed-off-by` line. GitHub parses `Co-authored-by` for contribution-graph credit; `Signed-off-by` alone does not grant it. Add via `git commit --trailer "Co-authored-by: Human Name "`. Place `Co-authored-by` before `Signed-off-by` in the trailer block. -- **Discovering the human's identity:** read `git config user.name` and `git config user.email` from the working repository. These reflect the human operator's configured identity for that repo (which may differ from their global config). Use these exact values for both trailers. Do NOT hardcode, guess, or prompt for the email — the repo config is the source of truth. If `git config user.email` returns empty, STOP and ask the human operator for their name and email before committing. -- **Signing:** if the agent has a registered signing key, sign commits. If not, commits will land unverified — this is acceptable until agent SSH keys are provisioned. Do NOT use the human's signing key. -- **Verify before pushing:** `git log -1` should show the human's `Signed-off-by` trailer. +- **Author:** use the person or agent required by the applicable policy. If no policy specifies an author, use the identity that actually authored the change. +- **Co-authors:** add `Co-authored-by` only for other people or agents who materially authored the change. Request, approval, review, or accountability alone is not co-authorship. +- **DCO:** add `Signed-off-by` only when repository policy requires that identity's DCO certification. A sign-off is not an approval marker. +- **Identity:** resolve required identities from trusted local configuration or explicit verified direction; never hard-code or guess them. A managed runtime may make effective `git config user.*` values identify the agent. Stop and ask if a required identity cannot be established. +- **Signing:** use only the signing key configured for the committing identity. Never use another person's signing key. +- **Verify before pushing:** inspect every outgoing commit against the actual upstream or base and confirm its attribution matches the applicable policy. + +A repository may require an accountable human as author and the implementing agent as co-author. An agent-owned repository may use the agent as author and require no human trailer. In both cases, repository-local policy controls. ## Active Agents From ee6ca5fa28bce04dfecb6717de65b08a57f2ac47 Mon Sep 17 00:00:00 2001 From: Jordan Mecom Date: Tue, 25 Aug 2026 13:57:23 -0700 Subject: [PATCH 3/9] Remove public relay signing key fallback (#6729) Require `BUZZ_RELAY_PRIVATE_KEY` on every relay startup and remove the shared public fallback key completely. For local development, `just bootstrap` now generates a random key once in the gitignored `.env` file. Re-running bootstrap preserves that key, so relay-authored events retain the same identity across restarts. The local relay recipes reload `.env`, while isolated CI and E2E launchers pass explicit per-run keys. Startup fails immediately when the key is missing or invalid, before connecting to Postgres or Redis. Deployed relays continue to receive their stable key from the existing chart-managed secret. Validated with: - `./scripts/test-ensure-local-relay-key.sh` - `cargo test -p buzz-relay --bin buzz-relay` - `cargo clippy -p buzz-relay --bin buzz-relay -- -D warnings` - `cargo fmt --all -- --check` - `shellcheck scripts/ensure-local-relay-key.sh scripts/test-ensure-local-relay-key.sh` Signed-off-by: Jordan Mecom --- .env.example | 4 +- .github/workflows/ci.yml | 2 + Justfile | 21 +++++++- TESTING.md | 11 +++-- crates/buzz-relay/src/main.rs | 55 +++++++++++---------- scripts/e2e-git-perms.sh | 1 + scripts/ensure-local-relay-key.sh | 66 ++++++++++++++++++++++++++ scripts/run-desktop-release-smoke.sh | 2 + scripts/start-isolated-test-relay.sh | 2 + scripts/start-relay-for-tests.sh | 5 +- scripts/test-ensure-local-relay-key.sh | 33 +++++++++++++ 11 files changed, 169 insertions(+), 33 deletions(-) create mode 100755 scripts/ensure-local-relay-key.sh create mode 100755 scripts/test-ensure-local-relay-key.sh diff --git a/.env.example b/.env.example index 0f7bbba6f1..cb503392b3 100644 --- a/.env.example +++ b/.env.example @@ -51,8 +51,8 @@ TYPESENSE_URL=http://localhost:8108 BUZZ_BIND_ADDR=0.0.0.0:3000 # Public WebSocket URL — used in NIP-42 auth challenges RELAY_URL=ws://localhost:3000 -# Stable relay signing key. Set this in dev if you want REST-created forum posts -# to keep resolving to the original author across relay restarts. +# Stable relay signing key (required). `just bootstrap` generates a random key in +# the gitignored .env file. Preserve that value across restarts and backups. # BUZZ_RELAY_PRIVATE_KEY=<32-byte hex private key> # Optional: path to the web UI dist directory. When set, the relay serves # the web frontend at / for browser requests. Leave unset for local dev diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f2bc8db65e..fd2949492f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -529,6 +529,7 @@ jobs: REDIS_URL=redis://localhost:6379 \ RELAY_URL=ws://localhost:3000 \ BUZZ_BIND_ADDR=0.0.0.0:3000 \ + BUZZ_RELAY_PRIVATE_KEY="$(openssl rand -hex 32)" \ BUZZ_REQUIRE_AUTH_TOKEN=false \ BUZZ_RECONCILE_CHANNELS=true \ BUZZ_RATE_LIMIT_HUMAN_MESSAGES_PER_MIN=100000 \ @@ -714,6 +715,7 @@ jobs: REDIS_URL=redis://localhost:6379 \ RELAY_URL=ws://localhost:3000 \ BUZZ_BIND_ADDR=0.0.0.0:3000 \ + BUZZ_RELAY_PRIVATE_KEY="$(openssl rand -hex 32)" \ BUZZ_REQUIRE_AUTH_TOKEN=false \ BUZZ_RECONCILE_CHANNELS=true \ BUZZ_GIT_PROBE_WRITERS=8 \ diff --git a/Justfile b/Justfile index fe5d7bf285..6c7740bc7a 100644 --- a/Justfile +++ b/Justfile @@ -43,6 +43,7 @@ bootstrap: cp .env.example .env echo "Created .env from .env.example — review it before running just dev." fi + ./scripts/ensure-local-relay-key.sh .env # Start Docker services, run migrations, install desktop deps setup: bootstrap @@ -307,6 +308,7 @@ test: test-unit: #!/usr/bin/env bash set -euo pipefail + ./scripts/test-ensure-local-relay-key.sh if command -v cargo-nextest &>/dev/null; then cargo nextest run -p buzz-core -p buzz-auth --lib cargo nextest run -p buzz-voice --lib @@ -423,6 +425,9 @@ relay: bootstrap _ensure-migrations #!/usr/bin/env bash set -euo pipefail export PATH="{{justfile_directory()}}/bin:$PATH" + set -o allexport + source .env + set +o allexport cargo run -p buzz-relay # Start the relay with the built web UI served from it @@ -430,6 +435,9 @@ relay-web: bootstrap _ensure-migrations #!/usr/bin/env bash set -euo pipefail export PATH="{{justfile_directory()}}/bin:$PATH" + set -o allexport + source .env + set +o allexport [[ -d node_modules ]] || pnpm install pnpm -C web build BUZZ_WEB_DIR=./web/dist cargo run -p buzz-relay @@ -439,6 +447,9 @@ admin: bootstrap _ensure-migrations #!/usr/bin/env bash set -euo pipefail export PATH="{{justfile_directory()}}/bin:$PATH" + set -o allexport + source .env + set +o allexport [[ -d node_modules ]] || pnpm install pnpm -C admin-web build export BUZZ_ADMIN_HOST="${BUZZ_ADMIN_HOST:-admin.localhost:3000}" @@ -459,7 +470,12 @@ admin-check: fmt-check pnpm -C admin-web exec playwright test # Start the relay server in release mode -relay-release: _ensure-migrations +relay-release: bootstrap _ensure-migrations + #!/usr/bin/env bash + set -euo pipefail + set -o allexport + source .env + set +o allexport cargo run -p buzz-relay --release @@ -468,6 +484,9 @@ dev *ARGS: bootstrap _ensure-sidecar-stubs _ensure-migrations #!/usr/bin/env bash set -euo pipefail export PATH="{{justfile_directory()}}/bin:$PATH" + set -o allexport + source .env + set +o allexport bind_addr="${BUZZ_BIND_ADDR:-0.0.0.0:3000}" relay_port="${bind_addr##*:}"; [[ -n "$relay_port" ]] || relay_port=3000 health_port="${BUZZ_HEALTH_PORT:-8080}" diff --git a/TESTING.md b/TESTING.md index 29d07a80de..0e64b74066 100644 --- a/TESTING.md +++ b/TESTING.md @@ -29,7 +29,7 @@ CLI signs every request with NIP-98, so you don't need `nak` or hand-rolled ```bash . ./bin/activate-hermit # activate pinned toolchain -cp .env.example .env # one-time +just bootstrap # create .env and its stable relay key once just setup # start Docker services, run migrations ``` @@ -70,6 +70,9 @@ Rebuild after any code change — the steps below use the release binaries. In a separate terminal (it runs in the foreground): ```bash +set -o allexport +source .env # includes the key generated by just bootstrap +set +o allexport buzz-relay # release binary from step 2, serves ws://localhost:3000 # alternatives: # cargo run --release -p buzz-relay # rebuild + run in release @@ -89,9 +92,9 @@ curl -s http://localhost:8080/_readiness # → {"status":"ready"} > `BUZZ_HEALTH_PORT`) so K8s probes bypass auth middleware. The main app > port also exposes `/health` for convenience. -The relay starts in dev mode (`BUZZ_REQUIRE_AUTH_TOKEN=false`). The startup -log emits a WARN about this — that's expected for local testing. See the env -vars table at the bottom if you need to lock it down. +The relay starts in dev mode (`BUZZ_REQUIRE_AUTH_TOKEN=false`) with the stable +relay identity generated in `.env`. See the env vars table at the bottom if +you need to lock it down. > **Already running Buzz Desktop (or another relay) on `:3000` / `:8080` / > `:9102`?** Buzz binds three ports — main, health, metrics — and any of diff --git a/crates/buzz-relay/src/main.rs b/crates/buzz-relay/src/main.rs index 566b684f83..3e39b22f8b 100644 --- a/crates/buzz-relay/src/main.rs +++ b/crates/buzz-relay/src/main.rs @@ -35,6 +35,16 @@ fn buzz_auto_migrate_enabled(value: Option<&str>) -> bool { }) } +fn relay_keypair_from_config(relay_private_key: Option<&str>) -> anyhow::Result { + let hex = relay_private_key.ok_or_else(|| { + anyhow::anyhow!( + "BUZZ_RELAY_PRIVATE_KEY must be set. Run `just bootstrap` for local \ + development or configure a stable 32-byte hex private key." + ) + })?; + nostr::Keys::parse(hex).map_err(|e| anyhow::anyhow!("invalid BUZZ_RELAY_PRIVATE_KEY: {e}")) +} + /// Controls how many per-community gauge series the usage poller emits. /// /// Datadog cost is proportional to the number of unique time-series. With ~25 @@ -143,6 +153,7 @@ async fn main() -> anyhow::Result<()> { error!("Invalid configuration: {e}"); anyhow::anyhow!("Configuration error: {e}") })?; + let relay_keypair = relay_keypair_from_config(config.relay_private_key.as_deref())?; info!( bind_addr = %config.bind_addr, relay_url = %config.relay_url, @@ -422,29 +433,6 @@ async fn main() -> anyhow::Result<()> { let workflow_config = buzz_workflow::WorkflowConfig::default(); let workflow_engine = Arc::new(WorkflowEngine::new(db.clone(), workflow_config)); - let relay_keypair = if let Some(hex) = &config.relay_private_key { - nostr::Keys::parse(hex) - .map_err(|e| anyhow::anyhow!("invalid BUZZ_RELAY_PRIVATE_KEY: {e}"))? - } else if !config.require_auth_token { - // Dev mode: use a deterministic keypair so addressable events (kind:39000/39001/39002) - // replace correctly across restarts. Without this, each restart generates a new pubkey - // and replace_addressable_event inserts duplicates instead of replacing. - const DEV_RELAY_PRIVKEY: &str = - "0000000000000000000000000000000000000000000000000000000000000001"; - let keys = nostr::Keys::parse(DEV_RELAY_PRIVKEY).expect("hardcoded dev key is valid"); - tracing::warn!( - pubkey = %keys.public_key().to_hex(), - "Using hardcoded dev relay keypair (BUZZ_REQUIRE_AUTH_TOKEN=false). \ - Set BUZZ_RELAY_PRIVATE_KEY for production." - ); - keys - } else { - panic!( - "BUZZ_RELAY_PRIVATE_KEY must be set when BUZZ_REQUIRE_AUTH_TOKEN=true. \ - A stable relay identity is required for production." - ); - }; - config .media .validate() @@ -2037,8 +2025,8 @@ mod tests { use super::{ buzz_auto_migrate_enabled, dropped_in_memory_keys, idle_timeout_secs, - refresh_legacy_active_gauge_recency, run_periodic_until_cancelled, EmissionScope, - InMemoryMetricKey, + refresh_legacy_active_gauge_recency, relay_keypair_from_config, + run_periodic_until_cancelled, EmissionScope, InMemoryMetricKey, }; use metrics::GaugeFn; use metrics_util::{ @@ -2086,6 +2074,23 @@ mod tests { assert!(buzz_auto_migrate_enabled(Some("on"))); } + #[test] + fn configured_relay_identity_is_preserved() { + let configured = nostr::Keys::generate(); + let secret = configured.secret_key().to_secret_hex(); + + let selected = relay_keypair_from_config(Some(&secret)).expect("configured key"); + + assert_eq!(selected.public_key(), configured.public_key()); + } + + #[test] + fn missing_relay_identity_is_rejected() { + let result = relay_keypair_from_config(None); + + assert!(result.is_err()); + } + #[test] fn test_emission_scope_off_disallows_every_community() { assert!(EmissionScope::All.allows(&Uuid::new_v4())); diff --git a/scripts/e2e-git-perms.sh b/scripts/e2e-git-perms.sh index f6a6a93063..bc507a7ff3 100755 --- a/scripts/e2e-git-perms.sh +++ b/scripts/e2e-git-perms.sh @@ -336,6 +336,7 @@ export BUZZ_GIT_HOOK_HMAC_SECRET="${HMAC_SECRET}" export BUZZ_BIND_ADDR="${RELAY_HOST}:${RELAY_PORT}" export RELAY_URL="${RELAY_WS}" export RUST_LOG="buzz_relay=warn" +export BUZZ_RELAY_PRIVATE_KEY="${BUZZ_RELAY_PRIVATE_KEY:-$(openssl rand -hex 32)}" export BUZZ_REQUIRE_AUTH_TOKEN=false # Clean repos dir (isolated test state) diff --git a/scripts/ensure-local-relay-key.sh b/scripts/ensure-local-relay-key.sh new file mode 100755 index 0000000000..8cf926a3d7 --- /dev/null +++ b/scripts/ensure-local-relay-key.sh @@ -0,0 +1,66 @@ +#!/usr/bin/env bash +set -euo pipefail + +ENV_FILE="${1:-.env}" + +if [[ ! -f "${ENV_FILE}" ]]; then + echo "error: ${ENV_FILE} does not exist" >&2 + exit 1 +fi + +existing_key="$({ + unset BUZZ_RELAY_PRIVATE_KEY + set +u + # shellcheck disable=SC1090 + source "${ENV_FILE}" || exit 1 + printf '%s' "${BUZZ_RELAY_PRIVATE_KEY:-}" +})" + +if [[ -n "${existing_key}" ]]; then + chmod 600 "${ENV_FILE}" + exit 0 +fi + +relay_key="$(node <<'NODE' +const { randomBytes } = require("node:crypto"); +const curveOrder = BigInt( + "0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141", +); + +let bytes; +let scalar; +do { + bytes = randomBytes(32); + scalar = BigInt(`0x${bytes.toString("hex")}`); +} while (scalar === 0n || scalar >= curveOrder); + +process.stdout.write(bytes.toString("hex")); +NODE +)" + +temp_file="$(mktemp "${ENV_FILE}.tmp.XXXXXX")" +trap 'rm -f "${temp_file}"' EXIT + +awk -v key="${relay_key}" ' + BEGIN { replaced = 0 } + /^[[:space:]]*(export[[:space:]]+)?BUZZ_RELAY_PRIVATE_KEY=/ { + if (!replaced) { + print "BUZZ_RELAY_PRIVATE_KEY=" key + replaced = 1 + } + next + } + { print } + END { + if (!replaced) { + if (NR > 0) print "" + print "BUZZ_RELAY_PRIVATE_KEY=" key + } + } +' "${ENV_FILE}" > "${temp_file}" + +chmod 600 "${temp_file}" +mv "${temp_file}" "${ENV_FILE}" +trap - EXIT + +echo "Generated BUZZ_RELAY_PRIVATE_KEY in ${ENV_FILE}." diff --git a/scripts/run-desktop-release-smoke.sh b/scripts/run-desktop-release-smoke.sh index 33f7013ab3..85aee2e05c 100755 --- a/scripts/run-desktop-release-smoke.sh +++ b/scripts/run-desktop-release-smoke.sh @@ -103,6 +103,7 @@ else RELAY_BIN="${ROOT}/target/ci/buzz-relay" fi log "starting relay at ${RELAY_HTTP_URL}" +RELAY_PRIVATE_KEY="$(openssl rand -hex 32)" env \ DATABASE_URL="postgres://buzz:buzz_dev@localhost:5432/${DB_NAME}" \ REDIS_URL="redis://localhost:6379/${REDIS_DB}" \ @@ -110,6 +111,7 @@ env \ BUZZ_BIND_ADDR="127.0.0.1:${RELAY_PORT}" \ BUZZ_HEALTH_PORT="${HEALTH_PORT}" \ BUZZ_METRICS_PORT="${METRICS_PORT}" \ + BUZZ_RELAY_PRIVATE_KEY="${RELAY_PRIVATE_KEY}" \ BUZZ_REQUIRE_AUTH_TOKEN=false \ BUZZ_RECONCILE_CHANNELS=true \ BUZZ_RATE_LIMIT_HUMAN_MESSAGES_PER_MIN=1000000 \ diff --git a/scripts/start-isolated-test-relay.sh b/scripts/start-isolated-test-relay.sh index 1e2047e99c..1f6b6620c1 100755 --- a/scripts/start-isolated-test-relay.sh +++ b/scripts/start-isolated-test-relay.sh @@ -133,6 +133,7 @@ ok "Relay built" # survives (same pattern the perf stack uses). Logs to ${RELAY_LOG}. RELAY_LOG="${RELAY_LOG:-/tmp/dawn-relay-run.log}" TMUX_SESSION="${TMUX_SESSION:-dawn-relay}" +RELAY_PRIVATE_KEY="$(openssl rand -hex 32)" tmux kill-session -t "${TMUX_SESSION}" 2>/dev/null || true if command -v lsof >/dev/null 2>&1 && lsof -nP -iTCP:"${RELAY_MAIN}" -sTCP:LISTEN >/dev/null 2>&1; then err "Port ${RELAY_MAIN} is already in use; refusing to report a stale relay as this harness." @@ -151,6 +152,7 @@ tmux new-session -d -s "${TMUX_SESSION}" "cd '${REPO_ROOT}' && env \ BUZZ_S3_ACCESS_KEY=buzz_dev \ BUZZ_S3_SECRET_KEY=buzz_dev_secret \ BUZZ_S3_BUCKET=buzz-media \ + BUZZ_RELAY_PRIVATE_KEY=${RELAY_PRIVATE_KEY} \ BUZZ_REQUIRE_AUTH_TOKEN=false \ BUZZ_RECONCILE_CHANNELS=true \ './target/${CARGO_TARGET_PROFILE}/buzz-relay' > '${RELAY_LOG}' 2>&1" diff --git a/scripts/start-relay-for-tests.sh b/scripts/start-relay-for-tests.sh index b9d93935c0..4bd1a27408 100755 --- a/scripts/start-relay-for-tests.sh +++ b/scripts/start-relay-for-tests.sh @@ -152,6 +152,8 @@ fi log "Starting relay..." +TEST_RELAY_PRIVATE_KEY="${BUZZ_RELAY_PRIVATE_KEY:-$(openssl rand -hex 32)}" + # Optional NIP-43 membership gating: exported by callers that need a # membership-gated relay (e.g. the mesh lifecycle smoke). All three must be # set together — the relay fails fast otherwise. @@ -160,8 +162,8 @@ if [[ "${BUZZ_REQUIRE_RELAY_MEMBERSHIP:-}" == "true" ]]; then MEMBERSHIP_ENV+=( BUZZ_REQUIRE_RELAY_MEMBERSHIP=true RELAY_OWNER_PUBKEY="${RELAY_OWNER_PUBKEY:?RELAY_OWNER_PUBKEY required with BUZZ_REQUIRE_RELAY_MEMBERSHIP=true}" - BUZZ_RELAY_PRIVATE_KEY="${BUZZ_RELAY_PRIVATE_KEY:?BUZZ_RELAY_PRIVATE_KEY required with BUZZ_REQUIRE_RELAY_MEMBERSHIP=true}" ) + : "${BUZZ_RELAY_PRIVATE_KEY:?BUZZ_RELAY_PRIVATE_KEY required with BUZZ_REQUIRE_RELAY_MEMBERSHIP=true}" log "Membership gating enabled (NIP-43)" fi @@ -170,6 +172,7 @@ nohup env \ REDIS_URL=redis://localhost:6379 \ RELAY_URL=ws://localhost:3000 \ BUZZ_BIND_ADDR=0.0.0.0:3000 \ + BUZZ_RELAY_PRIVATE_KEY="${TEST_RELAY_PRIVATE_KEY}" \ BUZZ_REQUIRE_AUTH_TOKEN=false \ BUZZ_RECONCILE_CHANNELS=true \ BUZZ_GIT_PROBE_WRITERS=8 \ diff --git a/scripts/test-ensure-local-relay-key.sh b/scripts/test-ensure-local-relay-key.sh new file mode 100755 index 0000000000..be87100557 --- /dev/null +++ b/scripts/test-ensure-local-relay-key.sh @@ -0,0 +1,33 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" +TEST_DIR="$(mktemp -d "${TMPDIR:-/tmp}/buzz-relay-key-test.XXXXXX")" +trap 'rm -rf "${TEST_DIR}"' EXIT + +ENV_FILE="${TEST_DIR}/.env" +cp "${REPO_ROOT}/.env.example" "${ENV_FILE}" + +"${SCRIPT_DIR}/ensure-local-relay-key.sh" "${ENV_FILE}" >/dev/null +first_key="$(sed -n 's/^BUZZ_RELAY_PRIVATE_KEY=//p' "${ENV_FILE}")" + +if [[ ! "${first_key}" =~ ^[0-9a-f]{64}$ ]]; then + echo "FAIL: bootstrap did not generate a valid 32-byte hex relay key" >&2 + exit 1 +fi + +"${SCRIPT_DIR}/ensure-local-relay-key.sh" "${ENV_FILE}" >/dev/null +second_key="$(sed -n 's/^BUZZ_RELAY_PRIVATE_KEY=//p' "${ENV_FILE}")" + +if [[ "${first_key}" != "${second_key}" ]]; then + echo "FAIL: bootstrap replaced the existing relay key" >&2 + exit 1 +fi + +if [[ "$(grep -c '^BUZZ_RELAY_PRIVATE_KEY=' "${ENV_FILE}")" -ne 1 ]]; then + echo "FAIL: bootstrap wrote more than one relay key" >&2 + exit 1 +fi + +echo "PASS: bootstrap generates one relay key and reuses it" From 7a1b7d8e09f96c10b6617a66bb8589e9984ffb3c Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Tue, 25 Aug 2026 17:36:57 -0400 Subject: [PATCH 4/9] chore(release): release Buzz Desktop version 0.5.19 (#6828) ## Buzz Desktop release v0.5.19 - **Frozen main:** `ee6ca5fa28bce04dfecb6717de65b08a57f2ac47` - **Reviewed candidate:** `ab691bcdaeccaa6698a2199beaba3f6e93daae81` - **Previous desktop release:** `desktop-v0.5.18` - **Proposed immutable tag:** `desktop-v0.5.19` This PR may be **squash merged** after the Desktop Release Candidate check and all protected-branch checks pass. Merging authorizes publication of the exact reviewed candidate; later or unrelated changes on `main` cannot alter it. The checked-in changelog accounts for every non-merge commit in the release range. The Desktop tag points to the reviewed candidate commit, not the later squash commit. Publication remains bound to that immutable candidate tag. Signed-off-by: Wes Co-authored-by: Wes Co-authored-by: Release Automation --- .release/desktop-candidate.json | 14 +++--- CHANGELOG.md | 79 +++++++++++++++++++++++++++++++ desktop/package.json | 2 +- desktop/src-tauri/Cargo.lock | 2 +- desktop/src-tauri/Cargo.toml | 2 +- desktop/src-tauri/tauri.conf.json | 2 +- 6 files changed, 90 insertions(+), 11 deletions(-) diff --git a/.release/desktop-candidate.json b/.release/desktop-candidate.json index 784725b2e6..e2711c63b3 100644 --- a/.release/desktop-candidate.json +++ b/.release/desktop-candidate.json @@ -1,10 +1,10 @@ { "schema": 2, - "version": "0.5.18", - "base_sha": "aea0ef8df9fc24d9aa8bf5c761ab2910026a601b", - "previous_tag": "desktop-v0.5.17", - "previous_base_sha": "3fdf289b78c40f80abce86575c25b5ed6361d82c", - "previous_merge_sha": "8232299cbe6d90692fac3de46cde0ec123edd6c1", - "tag": "desktop-v0.5.18", - "commit_count": 66 + "version": "0.5.19", + "base_sha": "ee6ca5fa28bce04dfecb6717de65b08a57f2ac47", + "previous_tag": "desktop-v0.5.18", + "previous_base_sha": "aea0ef8df9fc24d9aa8bf5c761ab2910026a601b", + "previous_merge_sha": "1e6f1a2584d1620fcc16baf8df962bda8d7c7ae9", + "tag": "desktop-v0.5.19", + "commit_count": 69 } diff --git a/CHANGELOG.md b/CHANGELOG.md index 42fc482515..f721ba905b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,84 @@ # Changelog +## v0.5.19 + +### Desktop and shared changes + +- docs(nest): make commit attribution policy-neutral ([#6707](https://github.com/block/buzz/pull/6707)) ([`22f32c99e2e983b7e015e8c058cac47438d05b2f`](https://github.com/block/buzz/commit/22f32c99e2e983b7e015e8c058cac47438d05b2f)) +- fix(desktop-messages): preserve inline agent mentions with persistent addressing ([#6793](https://github.com/block/buzz/pull/6793)) ([`b58de7cfa7a13b1a6dbb2fc269186439a6fb79a0`](https://github.com/block/buzz/commit/b58de7cfa7a13b1a6dbb2fc269186439a6fb79a0)) +- feat(desktop): persist agent addressing across composer messages ([#6714](https://github.com/block/buzz/pull/6714)) ([`7ba1197aa6b01616b5920487dd585a37d2e7f74d`](https://github.com/block/buzz/commit/7ba1197aa6b01616b5920487dd585a37d2e7f74d)) +- feat: navigate images across message threads ([#6705](https://github.com/block/buzz/pull/6705)) ([`a526dca9bcaa08dfb5db77999cc1f584a17a9d64`](https://github.com/block/buzz/commit/a526dca9bcaa08dfb5db77999cc1f584a17a9d64)) +- revert fixed mention highlight ([#6716](https://github.com/block/buzz/pull/6716)) ([`12f3fea26e4c638a5fae20dce1ec0876e3bbca41`](https://github.com/block/buzz/commit/12f3fea26e4c638a5fae20dce1ec0876e3bbca41)) +- highlight search terms in results and messages ([#6702](https://github.com/block/buzz/pull/6702)) ([`29f2054c69f2e0ea4ee90141ac6a80503e5f9bd1`](https://github.com/block/buzz/commit/29f2054c69f2e0ea4ee90141ac6a80503e5f9bd1)) +- fix(desktop): make lightbox zoom controls interactive ([#6710](https://github.com/block/buzz/pull/6710)) ([`9b6a637d014607760d116b90c57062f82ab27cf3`](https://github.com/block/buzz/commit/9b6a637d014607760d116b90c57062f82ab27cf3)) +- Support community deletion in versioned media buckets ([#6738](https://github.com/block/buzz/pull/6738)) ([`d12dea4e67c5224a626d9c00f45e68d1def72d4c`](https://github.com/block/buzz/commit/d12dea4e67c5224a626d9c00f45e68d1def72d4c)) +- Fix TipTap editor mount race ([#6779](https://github.com/block/buzz/pull/6779)) ([`bb5b9357a7c8ddeaee73f6252c7d9f8a9014dbc9`](https://github.com/block/buzz/commit/bb5b9357a7c8ddeaee73f6252c7d9f8a9014dbc9)) +- feat(buzz-agent): gate LLM tool calls on session/request_permission ([#5712](https://github.com/block/buzz/pull/5712)) ([`a1219070fa6c3263c8a29637c70b7a317d4ecd9d`](https://github.com/block/buzz/commit/a1219070fa6c3263c8a29637c70b7a317d4ecd9d)) +- Fix mobile Huddle agent voice turn states ([#6611](https://github.com/block/buzz/pull/6611)) ([`8b812017a79c9279594330fb2b04c1eaf0e6e6c1`](https://github.com/block/buzz/commit/8b812017a79c9279594330fb2b04c1eaf0e6e6c1)) +- fix(desktop): polish inline chip states ([#6718](https://github.com/block/buzz/pull/6718)) ([`a8e1c66c4a5017a32e41e04e2ba6059e2dfcae21`](https://github.com/block/buzz/commit/a8e1c66c4a5017a32e41e04e2ba6059e2dfcae21)) +- feat(workflows): discover trigger filter values ([#6712](https://github.com/block/buzz/pull/6712)) ([`e760c51820b2103d965c22b44254678e10fb689a`](https://github.com/block/buzz/commit/e760c51820b2103d965c22b44254678e10fb689a)) +- feat(desktop): simplify the message action rail ([#6529](https://github.com/block/buzz/pull/6529)) ([`c5166f2164035ca96787daee6528d5dc04c4a02e`](https://github.com/block/buzz/commit/c5166f2164035ca96787daee6528d5dc04c4a02e)) +- fix(desktop): restore icon-only remote marker ([#6491](https://github.com/block/buzz/pull/6491)) ([`30d2fc52f96138311f2006627ffc1a6d5ff1865b`](https://github.com/block/buzz/commit/30d2fc52f96138311f2006627ffc1a6d5ff1865b)) +- fix(composer): wrap Buzz chip labels without orphaning icons ([#6581](https://github.com/block/buzz/pull/6581)) ([`f79d346a178408661fcad85122364ac2ad7e9cb2`](https://github.com/block/buzz/commit/f79d346a178408661fcad85122364ac2ad7e9cb2)) +- fix(desktop): bound thread /query and surface load errors, not false-empty ([#6447](https://github.com/block/buzz/pull/6447)) ([`f6e6617a9dcc2308d5039f8afaab974b49fb9577`](https://github.com/block/buzz/commit/f6e6617a9dcc2308d5039f8afaab974b49fb9577)) +- fix(messages): route edits to the owning composer ([#6575](https://github.com/block/buzz/pull/6575)) ([`4bf80978f52981f0035e6c0b86bdf1108bbf64c8`](https://github.com/block/buzz/commit/4bf80978f52981f0035e6c0b86bdf1108bbf64c8)) +- fix(desktop): align jump-to-latest pill with composer height ([#6606](https://github.com/block/buzz/pull/6606)) ([`9f55bf67456be10ff7c8238bf0d9e12e582848f6`](https://github.com/block/buzz/commit/9f55bf67456be10ff7c8238bf0d9e12e582848f6)) +- fix(desktop): emit singular `mention` feed category so alerts route correctly ([#6665](https://github.com/block/buzz/pull/6665)) ([`db5617dd1541aeab7bacaf039b6ca98f856776d0`](https://github.com/block/buzz/commit/db5617dd1541aeab7bacaf039b6ca98f856776d0)) +- show mention counts in channel notifications ([#6696](https://github.com/block/buzz/pull/6696)) ([`0e69b3fd7c44c09da62e2c4e89fdb4a26e666869`](https://github.com/block/buzz/commit/0e69b3fd7c44c09da62e2c4e89fdb4a26e666869)) +- fix(desktop): hide selection formatting tray on composer right-click ([#6683](https://github.com/block/buzz/pull/6683)) ([`2f13e30e88e84851e7ad336364dd3cfd547b8c16`](https://github.com/block/buzz/commit/2f13e30e88e84851e7ad336364dd3cfd547b8c16)) +- fix(desktop): stabilize members dialog scrolling ([#6670](https://github.com/block/buzz/pull/6670)) ([`72ba987c365abb98939153c4d43dde73257c1264`](https://github.com/block/buzz/commit/72ba987c365abb98939153c4d43dde73257c1264)) +- fix(desktop): keep member runtime status off the UI thread ([#6445](https://github.com/block/buzz/pull/6445)) ([`17af15effac63e6bc5338448326ce52ba4426e5f`](https://github.com/block/buzz/commit/17af15effac63e6bc5338448326ce52ba4426e5f)) +- perf(desktop): persist channel heads, collapse thread reads and reply sends ([#6572](https://github.com/block/buzz/pull/6572)) ([`2d280376ad36134cec1f23bead6d866d30bed147`](https://github.com/block/buzz/commit/2d280376ad36134cec1f23bead6d866d30bed147)) +- Downgrade desktop Huddles to audio protocol v2 ([#6610](https://github.com/block/buzz/pull/6610)) ([`0720f5380ce8a6c050afac159f8462c06cd51ab5`](https://github.com/block/buzz/commit/0720f5380ce8a6c050afac159f8462c06cd51ab5)) +- perf(desktop): make the Projects surface render-cheap ([#6460](https://github.com/block/buzz/pull/6460)) ([`040b203f73576e15ef749b0ff0ee6243f06a5c48`](https://github.com/block/buzz/commit/040b203f73576e15ef749b0ff0ee6243f06a5c48)) +- refactor(acp): clarify agent prompt sections ([#6501](https://github.com/block/buzz/pull/6501)) ([`f99532585a0715bac73b4a6361a9b4966bdb5095`](https://github.com/block/buzz/commit/f99532585a0715bac73b4a6361a9b4966bdb5095)) +- Add mobile Huddles voice MVP ([#6056](https://github.com/block/buzz/pull/6056)) ([`8c0f42e187ca82d701251fc849217530178ebace`](https://github.com/block/buzz/commit/8c0f42e187ca82d701251fc849217530178ebace)) +- feat(desktop-messages): keep agents addressed across messages ([#6315](https://github.com/block/buzz/pull/6315)) ([`a2d8be5efa126221c7676f7797555dfb2bf5b0e0`](https://github.com/block/buzz/commit/a2d8be5efa126221c7676f7797555dfb2bf5b0e0)) +- fix(desktop): remove Buzz entity link previews ([#6512](https://github.com/block/buzz/pull/6512)) ([`723affe5d1340896209bf3aca94c7b791bdcf38b`](https://github.com/block/buzz/commit/723affe5d1340896209bf3aca94c7b791bdcf38b)) +- fix(composer): preserve caret when inserting mentions mid-message ([#6531](https://github.com/block/buzz/pull/6531)) ([`074561233eef71df9690ec22c2a9c5e798c297a7`](https://github.com/block/buzz/commit/074561233eef71df9690ec22c2a9c5e798c297a7)) +- chore(deps): update rust crate futures-util to v0.3.33 ([#5448](https://github.com/block/buzz/pull/5448)) ([`d874d67c117e4582cc9549ebe85c942e5e49faf7`](https://github.com/block/buzz/commit/d874d67c117e4582cc9549ebe85c942e5e49faf7)) +- fix(desktop): restore true zoom by scaling the root rem ([#6514](https://github.com/block/buzz/pull/6514)) ([`97b1fee5c3d9ad574196e974b520061fccc47d07`](https://github.com/block/buzz/commit/97b1fee5c3d9ad574196e974b520061fccc47d07)) +- chore(desktop): drop unused ORIGINAL_CONTENT from empty-edit-delete spec ([#6517](https://github.com/block/buzz/pull/6517)) ([`eb484387d5816b6f9155ad3a0be79ede3c3d7ad5`](https://github.com/block/buzz/commit/eb484387d5816b6f9155ad3a0be79ede3c3d7ad5)) +- feat(workflows): clarify workflow setup and activation ([#6470](https://github.com/block/buzz/pull/6470)) ([`0e48ff26915aa32d5f05208847b9aba75f4f19cd`](https://github.com/block/buzz/commit/0e48ff26915aa32d5f05208847b9aba75f4f19cd)) +- perf(desktop): stop the Projects fan refetching on re-entry and running after leave ([#6458](https://github.com/block/buzz/pull/6458)) ([`b85d680fb1e3cc7eef6d31d47598741b35836a2e`](https://github.com/block/buzz/commit/b85d680fb1e3cc7eef6d31d47598741b35836a2e)) +- perf(desktop): keep the member roster off the channel-switch path ([#6456](https://github.com/block/buzz/pull/6456)) ([`b0466ac465336cb773fbf7355ec05f7d61f4a3aa`](https://github.com/block/buzz/commit/b0466ac465336cb773fbf7355ec05f7d61f4a3aa)) +- Clarify huddle message destination ([#6496](https://github.com/block/buzz/pull/6496)) ([`7da8f9abf3245d7ab31ba6e4ad72598b03471f2b`](https://github.com/block/buzz/commit/7da8f9abf3245d7ab31ba6e4ad72598b03471f2b)) +- feat(archive): add observer-frame retention schema and gated DB adapter ([#5719](https://github.com/block/buzz/pull/5719)) ([`fc2ce6728b3b4805040c0a2f2cc5c15f1c1806ce`](https://github.com/block/buzz/commit/fc2ce6728b3b4805040c0a2f2cc5c15f1c1806ce)) +- fix(desktop): restore human barge-in over agent TTS in huddles ([#6431](https://github.com/block/buzz/pull/6431)) ([`6039fed565fd73a07cdddc3143c86733cad91709`](https://github.com/block/buzz/commit/6039fed565fd73a07cdddc3143c86733cad91709)) + +### Other repository changes + +- Remove public relay signing key fallback ([#6729](https://github.com/block/buzz/pull/6729)) ([`ee6ca5fa28bce04dfecb6717de65b08a57f2ac47`](https://github.com/block/buzz/commit/ee6ca5fa28bce04dfecb6717de65b08a57f2ac47)) +- Qualify canonical relay images for staged delivery ([#6781](https://github.com/block/buzz/pull/6781)) ([`f24971033178926153b49d320bd876d15d9cb2bf`](https://github.com/block/buzz/commit/f24971033178926153b49d320bd876d15d9cb2bf)) +- Add database pressure observability ([#6700](https://github.com/block/buzz/pull/6700)) ([`113a33b7e49b7173ee1767c49ef2f49c63803034`](https://github.com/block/buzz/commit/113a33b7e49b7173ee1767c49ef2f49c63803034)) +- Add staging dev relay image workflow ([#6709](https://github.com/block/buzz/pull/6709)) ([`931747c9c42df14d5c23c87fe57e30b995321ae3`](https://github.com/block/buzz/commit/931747c9c42df14d5c23c87fe57e30b995321ae3)) +- Extract community persistence ([#6668](https://github.com/block/buzz/pull/6668)) ([`9d1e4b257657f382d3111ce748f3da8d063b7671`](https://github.com/block/buzz/commit/9d1e4b257657f382d3111ce748f3da8d063b7671)) +- Add inline profile camera capture ([#6680](https://github.com/block/buzz/pull/6680)) ([`9aa332af03b4fb416dd8648b320447617ffd6fa5`](https://github.com/block/buzz/commit/9aa332af03b4fb416dd8648b320447617ffd6fa5)) +- Hide Huddles in mobile agent DMs ([#6676](https://github.com/block/buzz/pull/6676)) ([`822c5ab231bc253d809d2d13da4b381f723dcd25`](https://github.com/block/buzz/commit/822c5ab231bc253d809d2d13da4b381f723dcd25)) +- Centralize replaceable event persistence ([#6660](https://github.com/block/buzz/pull/6660)) ([`8d2d0ff5ad42733e9949442c4b6358d0ba87f9a8`](https://github.com/block/buzz/commit/8d2d0ff5ad42733e9949442c4b6358d0ba87f9a8)) +- fix(ci): prevent poisoned Rust caches ([#6618](https://github.com/block/buzz/pull/6618)) ([`69b1225923c9bd98784e86a6976bbc34a3dc8630`](https://github.com/block/buzz/commit/69b1225923c9bd98784e86a6976bbc34a3dc8630)) +- docs(security): route reports through private advisories ([#6728](https://github.com/block/buzz/pull/6728)) ([`02dc49f0e60f75027e926e33a2d0021f7b4e0cd3`](https://github.com/block/buzz/commit/02dc49f0e60f75027e926e33a2d0021f7b4e0cd3)) +- fix(mobile): join starter channels after accepting invite ([#5915](https://github.com/block/buzz/pull/5915)) ([`6eff84d1271eb1b90e07c5a0673343a76a0753fc`](https://github.com/block/buzz/commit/6eff84d1271eb1b90e07c5a0673343a76a0753fc)) +- Add mobile profile editing ([#6583](https://github.com/block/buzz/pull/6583)) ([`a0298539f7043cd0f2d961030e60cc0fd82970b1`](https://github.com/block/buzz/commit/a0298539f7043cd0f2d961030e60cc0fd82970b1)) +- fix(mobile): recover stale and shuffled messages ([#6691](https://github.com/block/buzz/pull/6691)) ([`01091c15a15d6057d80463dfd828e6e1e4b60743`](https://github.com/block/buzz/commit/01091c15a15d6057d80463dfd828e6e1e4b60743)) +- feat(mobile): browse and join open channels ([#6243](https://github.com/block/buzz/pull/6243)) ([`26f4c3ed304db2c273f0bd4d2746aa9598f38366`](https://github.com/block/buzz/commit/26f4c3ed304db2c273f0bd4d2746aa9598f38366)) +- Polish Huddle participant interactions ([#6312](https://github.com/block/buzz/pull/6312)) ([`e23632941331502c0330e51d407e667bea26ef57`](https://github.com/block/buzz/commit/e23632941331502c0330e51d407e667bea26ef57)) +- Downgrade mobile Huddles to audio protocol v2 ([#6558](https://github.com/block/buzz/pull/6558)) ([`4baccd5394d6166bb68ff03b24e376e322281a59`](https://github.com/block/buzz/commit/4baccd5394d6166bb68ff03b24e376e322281a59)) +- chore(deps): update rust crate async-trait to v0.1.92 ([#6094](https://github.com/block/buzz/pull/6094)) ([`f7942167372501576c9f0f589cf2c166882668bb`](https://github.com/block/buzz/commit/f7942167372501576c9f0f589cf2c166882668bb)) +- chore(deps): update dependency sonner to v2.0.8 ([#6093](https://github.com/block/buzz/pull/6093)) ([`2d93ea095535e42ee3a9933f00a4c0bd5e9e1c67`](https://github.com/block/buzz/commit/2d93ea095535e42ee3a9933f00a4c0bd5e9e1c67)) +- chore(deps): update rust crate http-body-util to v0.1.4 ([#5452](https://github.com/block/buzz/pull/5452)) ([`9390e11c9babeef221aeb0a22cc61a52700d168f`](https://github.com/block/buzz/commit/9390e11c9babeef221aeb0a22cc61a52700d168f)) +- chore(deps): update rust crate http to v1.4.2 ([#5451](https://github.com/block/buzz/pull/5451)) ([`1a0a27d3586b14fb79ec8e162441e610f8548188`](https://github.com/block/buzz/commit/1a0a27d3586b14fb79ec8e162441e610f8548188)) +- chore(deps): update rust crate futures to v0.3.33 ([#5445](https://github.com/block/buzz/pull/5445)) ([`2cef92df676490654f13ebe1de56923b2636123f`](https://github.com/block/buzz/commit/2cef92df676490654f13ebe1de56923b2636123f)) +- chore(deps): update dependency @tauri-apps/api to v2.11.1 ([#5444](https://github.com/block/buzz/pull/5444)) ([`f84511c169cf9a98ac03e9c1acb6bf4d68de5cb8`](https://github.com/block/buzz/commit/f84511c169cf9a98ac03e9c1acb6bf4d68de5cb8)) +- chore(deps): update ubuntu:24.04 docker digest to 561618e ([#5442](https://github.com/block/buzz/pull/5442)) ([`9008a4d1724afbd8dba0fd99e99cd8305b5d2ecb`](https://github.com/block/buzz/commit/9008a4d1724afbd8dba0fd99e99cd8305b5d2ecb)) +- chore(deps): update swatinem/rust-cache digest to 6323deb ([#5441](https://github.com/block/buzz/pull/5441)) ([`694d804b24cd0b97403b3caf159a55ffd1fc3b08`](https://github.com/block/buzz/commit/694d804b24cd0b97403b3caf159a55ffd1fc3b08)) +- fix(deletion): allow IRSA S3 credentials ([#6495](https://github.com/block/buzz/pull/6495)) ([`47526784d5c1967de6b2c5d1ee642bccfefbfab9`](https://github.com/block/buzz/commit/47526784d5c1967de6b2c5d1ee642bccfefbfab9)) +- docs(nips): comprehensive NIP-FI — core + claimable profiles (EDGE/LIFECYCLE/DELEG/CONF) ([#5946](https://github.com/block/buzz/pull/5946)) ([`d97780b4777f2fe3430b4e30a7d47fc6837ee059`](https://github.com/block/buzz/commit/d97780b4777f2fe3430b4e30a7d47fc6837ee059)) +- fix(benchmarks): wait for scripted event delivery ([#6487](https://github.com/block/buzz/pull/6487)) ([`025425591ed67518a63870316f1473ffd02dd520`](https://github.com/block/buzz/commit/025425591ed67518a63870316f1473ffd02dd520)) +- Polish mobile channel navigation and message sends ([#6488](https://github.com/block/buzz/pull/6488)) ([`aeb741fd31044ec560d953b0986dec2e7e93e2c6`](https://github.com/block/buzz/commit/aeb741fd31044ec560d953b0986dec2e7e93e2c6)) + +[Compare desktop-v0.5.18...desktop-v0.5.19](https://github.com/block/buzz/compare/desktop-v0.5.18...desktop-v0.5.19) + ## v0.5.18 ### Desktop and shared changes diff --git a/desktop/package.json b/desktop/package.json index 4a6bdcd7f5..1064769148 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -1,7 +1,7 @@ { "name": "buzz", "private": true, - "version": "0.5.18", + "version": "0.5.19", "type": "module", "scripts": { "dev": "vite", diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index 0933149f5b..82758c1d88 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -1081,7 +1081,7 @@ dependencies = [ [[package]] name = "buzz-desktop" -version = "0.5.18" +version = "0.5.19" dependencies = [ "anyhow", "arboard", diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index 3f7189deea..97791da13f 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -7,7 +7,7 @@ members = ["crates/buzz-terminal"] [package] name = "buzz-desktop" -version = "0.5.18" +version = "0.5.19" description = "Buzz desktop app" authors = ["you"] edition = "2021" diff --git a/desktop/src-tauri/tauri.conf.json b/desktop/src-tauri/tauri.conf.json index 4a73c78064..c29a9b8dce 100644 --- a/desktop/src-tauri/tauri.conf.json +++ b/desktop/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Buzz", - "version": "0.5.18", + "version": "0.5.19", "identifier": "xyz.block.buzz.app", "build": { "beforeDevCommand": { From cae7f826b39b38e7d49a90d342499234dec007bf Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Tue, 25 Aug 2026 18:50:47 -0400 Subject: [PATCH 5/9] fix(ci): check out source in docker.yml merge job (#6833) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Problem Every `docker.yml` run on `main` has failed since [#6781](https://github.com/block/buzz/pull/6781) merged. That PR added a "Create deployment eligibility predicate" step to the `merge` job, which runs `jq` against `$GITHUB_WORKSPACE/scripts/create-deployment-eligibility-predicate.jq`. But the `merge` job has no `actions/checkout` step — the workspace is empty, so `jq` can't open the file and exits `2` (`jq: Could not open ... No such file or directory`). The `build` and `qualify` jobs each check out the source; `merge` never needed one until this step was added. ## Fix - `.github/workflows/docker.yml`: add `actions/checkout` (same pinned SHA as the other jobs, `df4cb1c` / v6.0.3) as the first step of the `merge` job. - `scripts/test-relay-image-eligibility-workflow.sh`: guard the regression by asserting the `merge` job checks out the source before building the predicate. ## Verification CI cannot exercise the `merge` job on a pull request — the job is gated `if: github.event_name != 'pull_request'`, so it only runs on push to `main`. The proof is the root cause (missing checkout for a step that reads a repo file) plus the eligibility workflow test: `scripts/test-relay-image-eligibility-workflow.sh` passes with the fix and fails with the new guard's specific message (`merge job must check out the source before building the eligibility predicate`) when the checkout is removed. Signed-off-by: Will Pfleger --- .github/workflows/docker.yml | 5 +++++ scripts/test-relay-image-eligibility-workflow.sh | 9 +++++++++ 2 files changed, 14 insertions(+) diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index ddb55f8542..f48f1bc92f 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -330,6 +330,11 @@ jobs: tag_prefix: debug- steps: + - name: Checkout source + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false + - name: Download all per-arch digests uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: diff --git a/scripts/test-relay-image-eligibility-workflow.sh b/scripts/test-relay-image-eligibility-workflow.sh index 168f9d3689..40745a8e88 100755 --- a/scripts/test-relay-image-eligibility-workflow.sh +++ b/scripts/test-relay-image-eligibility-workflow.sh @@ -35,6 +35,15 @@ if grep -Fq "buzz-staging-dev" "$workflow"; then exit 1 fi +# The merge job reads scripts/create-deployment-eligibility-predicate.jq from the +# workspace, so it must check out the source first. Guard against the checkout being +# dropped from that job (the workspace is otherwise empty and jq exits non-zero). +merge_job=$(awk '/^ merge:/{f=1} f&&/^ [a-z][a-z_-]*:$/&&!/^ merge:/{exit} f' "$workflow") +grep -Fq "actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10" <<<"$merge_job" || { + echo "merge job must check out the source before building the eligibility predicate" >&2 + exit 1 +} + select_run() { jq -r --arg source_sha aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa -f "$selector" | jq -r '.id // empty' } From 820a8589971df49bb9285a236ce1e2955a301abd Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Tue, 25 Aug 2026 18:50:58 -0400 Subject: [PATCH 6/9] fix(release): attribute desktop candidates to the operator (#6831) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The desktop release tooling hardcoded one contributor's personal identity into every release candidate commit. `scripts/prepare-desktop-release.sh` committed the candidate with a `git -c user.name='Wes' -c user.email='wesbillman@users.noreply.github.com'` override, and `scripts/desktop_release.py` `validate` required the candidate author to be exactly `Wes ` plus a matching `Signed-off-by` trailer. That leaked from Wes's working setup into the validation contract in #3568, so a release cut by any other operator was falsely attributed to and signed off by Wes (as happened on #6828). ## Change - `prepare-desktop-release.sh`: drop the `-c` identity overrides so `git commit -s` uses the operator's own configured identity to author and sign off the candidate. The automation `Co-authored-by` trailer is unchanged. - `desktop_release.py` `validate`: replace the exact-Wes checks with structural ones — the commit author must be non-empty, the body must contain a `Signed-off-by` trailer whose name and email match the commit author (honest DCO), and the existing automation `Co-authored-by` regex check stays. Failure messages remain specific. - `test-desktop-release-candidate.sh`: the fixture candidate now commits under the harness's own identity, and a new negative case rewrites the author to a mismatched identity and asserts the validator rejects it. Release authorization is bound to the merged PR via the GitHub API in `scripts/verify-desktop-release-merge.sh`, never the commit author field, so this does not weaken the trust model. `RELEASING.md` and `.github/workflows/desktop-release-candidate.yml` reference no author identity and need no change. Verified locally: `scripts/test-desktop-release-candidate.sh` passes, including the new sign-off/author-mismatch rejection. --------- Signed-off-by: Will Pfleger Co-authored-by: Duncan --- scripts/desktop_release.py | 13 ++++---- scripts/prepare-desktop-release.sh | 3 +- scripts/test-desktop-release-candidate.sh | 36 ++++++++++++++++++++++- 3 files changed, 44 insertions(+), 8 deletions(-) diff --git a/scripts/desktop_release.py b/scripts/desktop_release.py index ce9ceaab93..48e7ce86ce 100755 --- a/scripts/desktop_release.py +++ b/scripts/desktop_release.py @@ -257,12 +257,15 @@ def validate(args: argparse.Namespace) -> None: bad = [str(path.relative_to(ROOT)) for path, value in manifests.items() if value != version] if bad: raise SystemExit(f"version mismatch in: {', '.join(bad)}") - author = git("show", "-s", "--format=%an <%ae>", candidate) + name = git("show", "-s", "--format=%an", candidate) + email = git("show", "-s", "--format=%ae", candidate) + if not name or not email: + raise SystemExit("candidate has no author identity") + author = f"{name} <{email}>" body = git("show", "-s", "--format=%B", candidate) - if author != "Wes ": - raise SystemExit(f"unexpected candidate author: {author}") - if "Signed-off-by: Wes " not in body: - raise SystemExit("candidate is missing Wes Signed-off-by trailer") + signoffs = re.findall(rf"(?m)^Signed-off-by: {re.escape(author)}$", body) + if len(signoffs) != 1: + raise SystemExit(f"candidate must carry exactly one Signed-off-by trailer matching its author {author}") if not re.search(r"(?m)^Co-authored-by: .+ <.+>$", body): raise SystemExit("candidate is missing automation Co-authored-by trailer") print(f"validated immutable desktop candidate {candidate} for desktop-v{version}") diff --git a/scripts/prepare-desktop-release.sh b/scripts/prepare-desktop-release.sh index 43785075a1..eec08ac8c5 100755 --- a/scripts/prepare-desktop-release.sh +++ b/scripts/prepare-desktop-release.sh @@ -42,8 +42,7 @@ chore(release): release Buzz Desktop version $version Co-authored-by: $agent_name <$agent_email> EOF -git -c user.name='Wes' -c user.email='wesbillman@users.noreply.github.com' \ - commit -s -F "$msg" +git commit -s -F "$msg" scripts/desktop_release.py validate --candidate HEAD --version "$version" --repo block/buzz candidate_sha="$(git rev-parse HEAD)" diff --git a/scripts/test-desktop-release-candidate.sh b/scripts/test-desktop-release-candidate.sh index c63a157c00..503c64eb37 100755 --- a/scripts/test-desktop-release-candidate.sh +++ b/scripts/test-desktop-release-candidate.sh @@ -64,8 +64,42 @@ for path in ('desktop/package.json', 'desktop/src-tauri/tauri.conf.json'): open('desktop/src-tauri/Cargo.toml','w').write('[package]\nversion = "1.0.1"\n') PY git add . - git -c user.name=Wes -c user.email=wesbillman@users.noreply.github.com commit -q -s -m 'chore(release): release Buzz Desktop version 1.0.1' -m 'Co-authored-by: Test Automation ' + git commit -q -s -m 'chore(release): release Buzz Desktop version 1.0.1' -m 'Co-authored-by: Test Automation ' PATH="$mock_bin:$PATH" scripts/desktop_release.py validate --version 1.0.1 --repo block/buzz + good_candidate=$(git rev-parse HEAD) + + # A candidate whose Signed-off-by does not match its author is a dishonest + # DCO sign-off and must be rejected. Rewrite the author while keeping the + # original trailer body, then restore the honest candidate. + git -c user.name=Impostor -c user.email=impostor@example.com commit -q --amend --no-edit --reset-author + if PATH="$mock_bin:$PATH" scripts/desktop_release.py validate --version 1.0.1 --repo block/buzz >/dev/null 2>&1; then + echo "validator accepted a candidate whose sign-off does not match its author" >&2; exit 1 + fi + git reset -q --hard "$good_candidate" + + # The trailer must be a complete anchored line, not substring-matched. A prose + # line that merely contains the sign-off text, or a real trailer with trailing + # garbage, must be rejected. Both were accepted before the anchored parse. + for bogus in \ + 'not-a-trailer Signed-off-by: test ' \ + 'Signed-off-by: test trailing-garbage'; do + git commit -q --amend -m 'chore(release): release Buzz Desktop version 1.0.1' \ + -m 'Co-authored-by: Test Automation ' -m "$bogus" + if PATH="$mock_bin:$PATH" scripts/desktop_release.py validate --version 1.0.1 --repo block/buzz >/dev/null 2>&1; then + echo "validator accepted a malformed sign-off: $bogus" >&2; exit 1 + fi + git reset -q --hard "$good_candidate" + done + + # Two matching sign-offs are also invalid: the contract is exactly one. + git commit -q --amend -m 'chore(release): release Buzz Desktop version 1.0.1' \ + -m 'Co-authored-by: Test Automation ' \ + -m 'Signed-off-by: test ' -m 'Signed-off-by: test ' + if PATH="$mock_bin:$PATH" scripts/desktop_release.py validate --version 1.0.1 --repo block/buzz >/dev/null 2>&1; then + echo "validator accepted duplicate Signed-off-by trailers" >&2; exit 1 + fi + git reset -q --hard "$good_candidate" + grep -Fq "$unrelated_before" CHANGELOG.md grep -Fq "$unrelated_after" CHANGELOG.md ! grep -Fq "$prior_merge" CHANGELOG.md From e8cd7516e6df62c2a9025d7a821bf98e0e8f83b4 Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Tue, 25 Aug 2026 15:59:38 -0700 Subject: [PATCH 7/9] fix(desktop): respect automatic mention preference after send (#6837) **Category:** fix **User Impact:** Disabling automatic agent mentions now keeps one-time agent mentions out of the next message draft. **Problem:** A successfully sent inline agent mention was treated as eligible for post-send restoration even when **Automatically mention agents** was disabled, so the agent immediately reappeared in the composer. **Solution:** Gate the send-success restoration path on the live preference while leaving explicitly pinned agents and enabled automatic mentions unchanged.
File changes **desktop/src/features/messages/ui/MessageComposer.tsx** Skips the automatic post-send mention restoration path when the preference is disabled. **desktop/tests/e2e/persistent-agent-audience.spec.ts** Reproduces disabling the toggle before sending an inline agent mention and verifies the next composer is empty while the outgoing mention still reaches the agent.
## Reproduction steps 1. Enable **Automatically mention agents** in the composer mention options. 2. Disable it again. 3. Compose and send `@Agent test` using the inline mention picker. 4. Confirm the message still mentions the agent, but the cleared composer does not repopulate `@Agent`. 5. Re-enable the preference and repeat; confirm the mention is restored for the next message. ## Testing At `a179a2d00a6ac5cf0bc4a1c7c1ef3d5dfa9d0875`: - Full desktop unit suite: 5,501 passed. - Desktop check and typecheck passed in the pre-push hooks. - Desktop file-size ratchet passed in the pre-push hooks. - Full `persistent-agent-audience.spec.ts` Playwright file: 17 passed, including preference off (empty composer), preference on (restored mention), and the existing address-undo journey. ## Visual validation | Automatic mentions off | Automatic mentions on | | --- | --- | | After sending a one-time mention, the outgoing message is preserved and the next composer stays empty. | After sending, the agent remains addressed and is restored in the next composer. | | ![Automatic mentions disabled: empty composer after send](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/6837/disabled-after-send.png) | ![Automatic mentions enabled: agent restored after send](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/6837/enabled-after-send.png) |
Original regression With automatic mentions disabled, sending `@Alia test` still repopulated `@Alia` in the cleared composer. ![Original regression: the sent agent reappears in the composer](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/6837/auto-mention-regression-before.png)
Signed-off-by: Taylor Ho Co-authored-by: Rizz <302abe414ca6e3134763d2539bfcf145aea2a63fe5f8455204ed602fd40cf381@buzz.block.builderlab.xyz> --- .../features/messages/ui/MessageComposer.tsx | 2 +- .../e2e/persistent-agent-audience.spec.ts | 36 ++++++++++++++++++- 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/desktop/src/features/messages/ui/MessageComposer.tsx b/desktop/src/features/messages/ui/MessageComposer.tsx index d4db6f5d17..c255f4a8ca 100644 --- a/desktop/src/features/messages/ui/MessageComposer.tsx +++ b/desktop/src/features/messages/ui/MessageComposer.tsx @@ -347,7 +347,7 @@ function MessageComposerImpl({ restoreAddressedAgentMentionsRef.current(pubkeys), onAddressedAgentsSendFailed: addressPulse.shakeMany, onAddressedAgentsSendSucceeded: (pubkeys, newlyPinnedPubkeys) => { - if (newlyPinnedPubkeys.length === 0) return; + if (!keepMentionedAgentsPinned || newlyPinnedPubkeys.length === 0) return; const sentChannelId = channelId; if (restoreAddressedAgentMentionsFrameRef.current !== null) { cancelAnimationFrame(restoreAddressedAgentMentionsFrameRef.current); diff --git a/desktop/tests/e2e/persistent-agent-audience.spec.ts b/desktop/tests/e2e/persistent-agent-audience.spec.ts index 9a02e2e1a8..12a6f07819 100644 --- a/desktop/tests/e2e/persistent-agent-audience.spec.ts +++ b/desktop/tests/e2e/persistent-agent-audience.spec.ts @@ -312,6 +312,40 @@ test("Tab inserts a one-time agent mention by default", async ({ page }) => { ).toHaveCount(0); }); +test("disabling automatic mentions leaves the composer empty after send", async ({ + page, +}) => { + await keepMentionedAgentsPinned(page); + await installAudienceFixtures(page); + await openGeneral(page); + + const composer = channelComposer(page); + const input = composer.getByTestId("message-input"); + await composer.getByTestId("message-insert-mention").click(); + await composer.getByTestId("mention-options-trigger").click(); + const preference = composer.getByTestId("mention-keep-agents-pinned-toggle"); + await expect(preference).toHaveAttribute("data-state", "checked"); + await preference.click(); + await expect(preference).toHaveAttribute("data-state", "unchecked"); + await input.press("Escape"); + + await input.fill("@Mor"); + await expect(composer.getByTestId("mention-autocomplete")).toBeVisible(); + await input.press("Tab"); + await input.type("test"); + await expect(input).toHaveText("@Morgarita test"); + await input.press("Enter"); + + await expect(input).toHaveText(""); + await expect(input.locator(".agent-mention-highlight")).toHaveCount(0); + await expect( + composer.getByTestId(`composer-address-lock-${AGENT_A}`), + ).toHaveCount(0); + await expect + .poll(() => readOutgoingMentionPubkeys(page, "@Morgarita test")) + .toContain(AGENT_A); +}); + test("primary+Shift+M addresses the default agent, then selects the highlighted agent", async ({ page, }) => { @@ -457,7 +491,7 @@ test("the mention button opens settings and can undo an address", async ({ await input.type("later"); await input.press("Enter"); - await expect(input).toHaveText("@Morgarita "); + await expect(input).toHaveText(""); await expect .poll(() => readOutgoingMentionPubkeys(page, "@Morgarita later")) .toContain(AGENT_A); From 8471049c430073474939336dfc6aa98272bc8762 Mon Sep 17 00:00:00 2001 From: klopez4212 Date: Wed, 26 Aug 2026 00:09:55 +0100 Subject: [PATCH 8/9] feat(desktop): add KLIPY GIF search to composers (#5554) ## Summary - Supersedes #1913 with a KLIPY-hosted URL implementation. - Adds KLIPY GIF search and trending results to desktop message and forum composers. - Keeps selected GIFs hosted by KLIPY; Buzz stores only the external URL and media metadata (no imeta tag, since relays only accept hash-backed local `/media/` entries). - Aligns the Emoji/GIF picker with Buzz's standard segmented control, theme surfaces, and motion behavior. ## Relay-to-provider boundary - The relay proxies KLIPY search/share so the `BUZZ_KLIPY_API_KEY` never reaches the desktop; the key stays server-side behind a redacted `Debug` impl. - The dedicated GIF `reqwest` client sets `redirect::Policy::none()`. Because the API key rides in the request path, following a provider `3xx` could replay a key-bearing URL to an attacker-chosen host (an SSRF/key-disclosure primitive). With redirects disabled, a `3xx` returns as a non-success status that the handlers map to a generic `502`; the `Location` target is never read or forwarded. - Admission reuses the established NIP-98, membership, replay, and per-pubkey rate-limit gates, with an upstream response-size cap and allowlisting so KLIPY error bodies never cross the relay boundary. ## Accessibility - Under `prefers-reduced-motion: reduce`, the picker grid renders a static provider poster (a normalized `jpg` asset) instead of the animated preview, or a named static placeholder when no poster is available. It reacts to preference changes while mounted. `no-preference` keeps the animated preview. - Selected GIFs carry their title through `ImetaMedia`'s `displayLabel`, so the composer thumbnail, preview dialog, editor, lightbox, and remove control all derive one non-empty accessible name instead of an empty `Attachment ` label. Ordinary hashed uploads keep their existing hash-derived names. --------- Signed-off-by: kenny lopez Signed-off-by: Kenny Lopez Signed-off-by: Will Pfleger Signed-off-by: Duncan Signed-off-by: Hayt <9e1c23a3fd83f61da34420e4e88ff1b16e45cafcc0cd9019eb07d4ecfa8ca9b0@buzz.block.builderlab.xyz> Co-authored-by: Princess Donut Co-authored-by: Duncan Co-authored-by: Will Pfleger Co-authored-by: Hayt <9e1c23a3fd83f61da34420e4e88ff1b16e45cafcc0cd9019eb07d4ecfa8ca9b0@buzz.block.builderlab.xyz> --- .env.example | 6 + crates/buzz-auth/src/rate_limit.rs | 22 + crates/buzz-relay/src/api/gifs.rs | 613 ++++++++++++++++++ crates/buzz-relay/src/api/mod.rs | 1 + crates/buzz-relay/src/config.rs | 53 ++ crates/buzz-relay/src/handlers/req.rs | 1 + crates/buzz-relay/src/nip11.rs | 86 ++- crates/buzz-relay/src/router.rs | 3 + crates/buzz-relay/src/state.rs | 5 + deploy/charts/buzz/README.md | 6 + .../charts/buzz/examples/secret-sample.yaml | 2 + deploy/charts/buzz/templates/deployment.yaml | 6 + deploy/charts/buzz/tests/secrets_test.yaml | 21 + deploy/charts/buzz/values.yaml | 1 + .../features/custom-emoji/ui/EmojiPicker.tsx | 5 +- .../src/features/forum/ui/ForumComposer.tsx | 1 + desktop/src/features/gifs/api.test.mjs | 179 +++++ desktop/src/features/gifs/api.ts | 185 ++++++ desktop/src/features/gifs/relay.ts | 142 ++++ .../src/features/gifs/ui/KlipyGifPicker.tsx | 161 +++++ .../messages/lib/imetaMediaMarkdown.test.mjs | 48 +- .../messages/lib/imetaMediaMarkdown.ts | 48 +- .../messages/ui/ComposerAttachments.tsx | 96 +-- .../messages/ui/ComposerEmojiPicker.tsx | 124 +++- .../features/messages/ui/MessageComposer.tsx | 56 +- .../messages/ui/MessageComposerToolbar.tsx | 4 + desktop/tests/e2e/channels.spec.ts | 9 + desktop/tests/e2e/composer-image-draw.spec.ts | 24 +- .../e2e/composer-tooltip-dismiss.spec.ts | 2 +- desktop/tests/e2e/messaging.spec.ts | 388 +++++++++++ desktop/tests/e2e/spoiler.spec.ts | 8 +- desktop/tests/e2e/video-attachment.spec.ts | 12 +- docs/gif-search.md | 41 ++ docs/multi-tenant-conformance.md | 2 +- 34 files changed, 2215 insertions(+), 146 deletions(-) create mode 100644 crates/buzz-relay/src/api/gifs.rs create mode 100644 desktop/src/features/gifs/api.test.mjs create mode 100644 desktop/src/features/gifs/api.ts create mode 100644 desktop/src/features/gifs/relay.ts create mode 100644 desktop/src/features/gifs/ui/KlipyGifPicker.tsx create mode 100644 docs/gif-search.md diff --git a/.env.example b/.env.example index cb503392b3..a6740f7a7d 100644 --- a/.env.example +++ b/.env.example @@ -59,9 +59,15 @@ RELAY_URL=ws://localhost:3000 # (use `just web` for Vite HMR instead). # BUZZ_WEB_DIR=./web/dist +# Optional relay-owned KLIPY key. When set, NIP-11 advertises GIF search and +# authenticated desktop clients use this relay as the metadata/search proxy. +# Keep the real value in your deployment's secret manager; never commit it. +# BUZZ_KLIPY_API_KEY= + # Shared Redis-backed admission limits. Defaults shown below; each value must # be a positive integer. # BUZZ_RATE_LIMIT_HUMAN_MESSAGES_PER_MIN=60 +# BUZZ_RATE_LIMIT_GIF_SEARCHES_PER_MIN=30 # BUZZ_RATE_LIMIT_HUMAN_API_CALLS_PER_MIN=300 # BUZZ_RATE_LIMIT_HUMAN_WS_EVENTS_PER_SEC=10 # BUZZ_RATE_LIMIT_AGENT_STANDARD_MESSAGES_PER_MIN=120 diff --git a/crates/buzz-auth/src/rate_limit.rs b/crates/buzz-auth/src/rate_limit.rs index 8fd42c50fb..9e64627404 100644 --- a/crates/buzz-auth/src/rate_limit.rs +++ b/crates/buzz-auth/src/rate_limit.rs @@ -60,6 +60,8 @@ pub enum LimitType { Messages, /// HTTP REST API calls. ApiCalls, + /// Relay-proxied GIF metadata searches. + GifSearches, /// All WebSocket events (broader than `Messages`). WsEvents, /// Concurrent WebSocket connections from a single IP address. @@ -72,6 +74,7 @@ impl LimitType { match self { Self::Messages => "msg", Self::ApiCalls => "api", + Self::GifSearches => "gif", Self::WsEvents => "ws", Self::IpConnections => "conn", } @@ -87,6 +90,10 @@ pub struct RateLimitConfig { /// Maximum messages per minute for human users. Default: 60. #[serde(default = "default_human_msg")] pub human_messages_per_min: u64, + /// Maximum relay-proxied GIF searches per minute for each pubkey. + /// Default: 30. + #[serde(default = "default_gif_searches")] + pub gif_searches_per_min: u64, /// Maximum HTTP API calls per minute for human users. Default: 300. #[serde(default = "default_human_api")] pub human_api_calls_per_min: u64, @@ -110,6 +117,9 @@ pub struct RateLimitConfig { fn default_human_msg() -> u64 { 60 } +fn default_gif_searches() -> u64 { + 30 +} fn default_human_api() -> u64 { 300 } @@ -133,6 +143,7 @@ impl Default for RateLimitConfig { fn default() -> Self { Self { human_messages_per_min: default_human_msg(), + gif_searches_per_min: default_gif_searches(), human_api_calls_per_min: default_human_api(), human_ws_events_per_sec: default_human_ws(), agent_standard_messages_per_min: default_agent_std_msg(), @@ -272,6 +283,17 @@ mod tests { assert!(key.ends_with(":msg")); } + #[test] + fn gif_searches_have_an_independent_quota_key() { + let ctx = fixture_ctx("relay-a.example"); + let keys = Keys::generate(); + let gif_key = rate_limit_key(&ctx, &keys.public_key(), &LimitType::GifSearches); + let api_key = rate_limit_key(&ctx, &keys.public_key(), &LimitType::ApiCalls); + + assert!(gif_key.ends_with(":gif")); + assert_ne!(gif_key, api_key); + } + #[test] fn rate_limit_key_isolates_communities_for_same_pubkey() { // The S1 cross-community isolation fence at the rate-limit key layer: diff --git a/crates/buzz-relay/src/api/gifs.rs b/crates/buzz-relay/src/api/gifs.rs new file mode 100644 index 0000000000..a8848af295 --- /dev/null +++ b/crates/buzz-relay/src/api/gifs.rs @@ -0,0 +1,613 @@ +//! Relay-owned KLIPY GIF metadata/search proxy. +//! +//! KLIPY requires a provider credential, but desktop applications cannot keep +//! build-time credentials secret. These narrow endpoints keep the key on the +//! operator's relay while returning only KLIPY-hosted media URLs and metadata; +//! GIF bytes are never downloaded, cached, or stored by Buzz. +//! +//! Search and share reporting are the only relay endpoints. Sending a selected +//! GIF is a normal message containing its CDN URL, and clients render that URL +//! through the existing image path. No GIF bytes transit the relay. + +use std::sync::Arc; +use std::time::Duration; + +use axum::{ + extract::State, + http::{header, HeaderMap, StatusCode}, + response::Json, +}; +use futures_util::StreamExt; +use serde::Deserialize; +use serde_json::Value; + +use crate::state::AppState; + +use buzz_auth::LimitType; + +use super::{api_error, bridge, internal_error, relay_members}; + +const KLIPY_API_ROOT: &str = "https://api.klipy.com/api/v1/"; +pub(crate) const SEARCH_PATH: &str = "/gifs/search"; +pub(crate) const SHARE_PATH: &str = "/gifs/share"; +const UPSTREAM_TIMEOUT: Duration = Duration::from_secs(10); +const MAX_UPSTREAM_RESPONSE_BYTES: usize = 2 * 1024 * 1024; + +/// Build the dedicated KLIPY client. Redirects are disabled: the API key rides +/// in the request path, so following a provider 3xx could replay a key-bearing +/// URL to an attacker-chosen host. With no redirect policy, a 3xx comes back as +/// a non-success status that the handlers map to a generic `502`, and the +/// `Location` target is never read or forwarded. +pub fn build_gif_http_client() -> reqwest::Client { + reqwest::Client::builder() + .timeout(UPSTREAM_TIMEOUT) + .redirect(reqwest::redirect::Policy::none()) + .build() + .expect("static GIF HTTP client configuration") +} + +#[derive(Debug, Deserialize)] +/// Client-owned search context forwarded to KLIPY by the relay. +pub struct SearchRequest { + /// Empty means trending; otherwise this is the user's search text. + query: String, + /// Stable anonymous installation identifier required by KLIPY. + customer_id: String, + /// Desktop locale used to localize provider results. + locale: String, +} + +#[derive(Debug, Deserialize)] +/// Client-owned share context forwarded to KLIPY by the relay. +pub struct ShareRequest { + /// Provider slug for the selected GIF. + slug: String, + /// Stable anonymous installation identifier required by KLIPY. + customer_id: String, +} + +fn validate_text( + name: &str, + value: &str, + max_chars: usize, + allow_empty: bool, +) -> Result<(), (StatusCode, Json)> { + let count = value.chars().count(); + if (!allow_empty && value.trim().is_empty()) || count > max_chars { + return Err(api_error( + StatusCode::BAD_REQUEST, + &format!( + "{name} must be {} through {max_chars} characters", + if allow_empty { 0 } else { 1 } + ), + )); + } + Ok(()) +} + +fn klipy_url( + api_key: &str, + path: &[&str], + query: &[(&str, &str)], +) -> Result)> { + let mut url = url::Url::parse(KLIPY_API_ROOT) + .map_err(|_| internal_error("invalid static KLIPY API root"))?; + { + let mut segments = url + .path_segments_mut() + .map_err(|_| internal_error("invalid static KLIPY API root"))?; + segments.pop_if_empty().push(api_key); + for segment in path { + segments.push(segment); + } + } + if !query.is_empty() { + url.query_pairs_mut().extend_pairs(query.iter().copied()); + } + Ok(url) +} + +fn klipy_share_request( + client: &reqwest::Client, + api_key: &str, + request: &ShareRequest, +) -> Result)> { + let url = klipy_url(api_key, &["gifs", "share", request.slug.trim()], &[])?; + Ok(client + .post(url) + .json(&serde_json::json!({ "customer_id": request.customer_id }))) +} + +async fn authenticate( + state: &Arc, + headers: &HeaderMap, + path: &str, + body: &[u8], +) -> Result<(buzz_core::TenantContext, nostr::PublicKey), (StatusCode, Json)> { + let raw_host = headers + .get(header::HOST) + .and_then(|value| value.to_str().ok()) + .unwrap_or(""); + let tenant = crate::tenant::bind_community(&state.db, raw_host) + .await + .map_err(|_| { + api_error( + StatusCode::NOT_FOUND, + "relay: no community is configured for this host", + ) + })?; + + let expected_url = bridge::nip98_expected_url(&state.config.relay_url, &tenant, path); + let (pubkey, event_id_bytes) = bridge::verify_bridge_auth_with_options( + headers, + "POST", + &expected_url, + Some(body), + true, + true, + )?; + bridge::enforce_http_admission(state, &tenant, &pubkey).await?; + bridge::check_nip98_replay(state, &tenant, event_id_bytes).await?; + relay_members::enforce_relay_membership( + state, + tenant.community(), + &pubkey.to_bytes(), + headers + .get("x-auth-tag") + .and_then(|value| value.to_str().ok()), + ) + .await?; + + Ok((tenant, pubkey)) +} + +async fn send_upstream( + request: reqwest::RequestBuilder, +) -> Result)> { + request + .timeout(UPSTREAM_TIMEOUT) + .send() + .await + .map_err(|error| { + tracing::warn!( + timeout = error.is_timeout(), + "KLIPY upstream request failed" + ); + api_error(StatusCode::BAD_GATEWAY, "GIF provider is unavailable") + }) +} + +async fn enforce_search_admission( + state: &AppState, + tenant: &buzz_core::TenantContext, + pubkey: &nostr::PublicKey, +) -> Result<(), (StatusCode, Json)> { + let limit = state.auth.config().rate_limits.gif_searches_per_min; + match crate::admission::check_principal( + state.admission_rate_limiter.as_ref(), + tenant, + pubkey, + LimitType::GifSearches, + 60, + limit, + ) + .await + { + Ok(()) => Ok(()), + Err(crate::admission::AdmissionError::Exceeded { reset_in_secs }) => { + metrics::counter!("buzz_gif_search_rejections_total", "reason" => "quota").increment(1); + Err(api_error( + StatusCode::TOO_MANY_REQUESTS, + &format!("rate-limited: GIF search quota exceeded; retry in {reset_in_secs}s"), + )) + } + Err(crate::admission::AdmissionError::Unavailable) => Err(api_error( + StatusCode::SERVICE_UNAVAILABLE, + "rate-limited: GIF search admission unavailable", + )), + } +} + +async fn limited_json(response: reqwest::Response) -> Result)> { + if response + .content_length() + .is_some_and(|length| length > MAX_UPSTREAM_RESPONSE_BYTES as u64) + { + return Err(api_error( + StatusCode::BAD_GATEWAY, + "GIF provider response was too large", + )); + } + + let mut body = Vec::new(); + let mut stream = response.bytes_stream(); + while let Some(chunk) = stream.next().await { + let chunk = chunk.map_err(|_| { + api_error( + StatusCode::BAD_GATEWAY, + "GIF provider response could not be read", + ) + })?; + if body.len().saturating_add(chunk.len()) > MAX_UPSTREAM_RESPONSE_BYTES { + return Err(api_error( + StatusCode::BAD_GATEWAY, + "GIF provider response was too large", + )); + } + body.extend_from_slice(&chunk); + } + + serde_json::from_slice(&body).map_err(|_| { + api_error( + StatusCode::BAD_GATEWAY, + "GIF provider returned an invalid response", + ) + }) +} + +fn successful_search_payload(upstream: &Value) -> Result)> { + if upstream.get("result").and_then(Value::as_bool) != Some(true) { + tracing::warn!("KLIPY search returned an unsuccessful result"); + return Err(api_error( + StatusCode::BAD_GATEWAY, + "GIF provider rejected the search request", + )); + } + let data = upstream.get("data").cloned().unwrap_or(Value::Null); + Ok(serde_json::json!({ "result": true, "data": data })) +} + +/// Search or browse trending KLIPY GIF metadata for an authenticated member. +pub async fn search( + State(state): State>, + headers: HeaderMap, + body: axum::body::Bytes, +) -> Result, (StatusCode, Json)> { + let Some(config) = state.config.klipy.as_ref() else { + return Err(api_error( + StatusCode::NOT_FOUND, + "GIF search is not configured", + )); + }; + let (tenant, pubkey) = authenticate(&state, &headers, SEARCH_PATH, &body).await?; + let request: SearchRequest = serde_json::from_slice(&body) + .map_err(|_| api_error(StatusCode::BAD_REQUEST, "invalid GIF search JSON"))?; + validate_text("query", &request.query, 200, true)?; + validate_text("customer_id", &request.customer_id, 128, false)?; + validate_text("locale", &request.locale, 32, false)?; + enforce_search_admission(&state, &tenant, &pubkey).await?; + + let endpoint = if request.query.trim().is_empty() { + "trending" + } else { + "search" + }; + let mut query = vec![ + ("page", "1"), + ("per_page", "24"), + ("customer_id", request.customer_id.as_str()), + ("locale", request.locale.as_str()), + ]; + if !request.query.trim().is_empty() { + query.push(("q", request.query.trim())); + } + let url = klipy_url(config.api_key(), &["gifs", endpoint], &query)?; + let response = send_upstream(state.gif_http_client.get(url)).await?; + if !response.status().is_success() { + tracing::warn!(status = response.status().as_u16(), "KLIPY search failed"); + return Err(api_error( + StatusCode::BAD_GATEWAY, + "GIF provider rejected the search request", + )); + } + + // Never forward the provider response wholesale. KLIPY may report an + // application-level failure with HTTP 200 and include request details in + // its error fields. Allowlist only successful result data so credentials + // and provider diagnostics cannot cross the relay boundary. + let upstream = limited_json(response).await?; + Ok(Json(successful_search_payload(&upstream)?)) +} + +/// Report a selected GIF to KLIPY so the provider can update Recents. +pub async fn share( + State(state): State>, + headers: HeaderMap, + body: axum::body::Bytes, +) -> Result)> { + let Some(config) = state.config.klipy.as_ref() else { + return Err(api_error( + StatusCode::NOT_FOUND, + "GIF search is not configured", + )); + }; + authenticate(&state, &headers, SHARE_PATH, &body).await?; + let request: ShareRequest = serde_json::from_slice(&body) + .map_err(|_| api_error(StatusCode::BAD_REQUEST, "invalid GIF share JSON"))?; + validate_text("slug", &request.slug, 200, false)?; + validate_text("customer_id", &request.customer_id, 128, false)?; + + let response = send_upstream(klipy_share_request( + &state.gif_http_client, + config.api_key(), + &request, + )?) + .await?; + if !response.status().is_success() { + tracing::warn!(status = response.status().as_u16(), "KLIPY share failed"); + return Err(api_error( + StatusCode::BAD_GATEWAY, + "GIF provider rejected the share request", + )); + } + + Ok(StatusCode::NO_CONTENT) +} + +#[cfg(test)] +mod tests { + use super::*; + use axum::{ + body::Body, + http::{Request, StatusCode}, + routing::get, + Router, + }; + use tower::ServiceExt; + + async fn unconfigured_test_state() -> Arc { + let mut config = crate::config::Config::from_env().expect("test config"); + config.klipy = None; + config.redis_url = "redis://127.0.0.1:1".to_string(); + + let pool = sqlx::postgres::PgPoolOptions::new() + .connect_lazy("postgres://buzz:buzz_dev@127.0.0.1:1/buzz") // sadscan:disable np.postgres.1 + .expect("lazy test database pool"); + let db = buzz_db::Db::from_pool(pool.clone()); + let redis_pool = deadpool_redis::Config::from_url(&config.redis_url) + .create_pool(Some(deadpool_redis::Runtime::Tokio1)) + .expect("lazy test Redis pool"); + let pubsub = Arc::new( + buzz_pubsub::PubSubManager::new(&config.redis_url, redis_pool.clone()) + .await + .expect("test pubsub"), + ); + let auth = buzz_auth::AuthService::new(config.auth.clone()); + let search = buzz_search::SearchService::new(pool.clone()); + let workflow_engine = Arc::new(buzz_workflow::WorkflowEngine::new( + db.clone(), + buzz_workflow::WorkflowConfig::default(), + )); + let media_storage = + buzz_media::MediaStorage::new(&config.media).expect("test media storage config"); + let (state, _audit_shutdown) = AppState::new( + config, + db, + redis_pool, + None::, + pubsub, + auth, + search, + workflow_engine, + nostr::Keys::generate(), + media_storage, + ); + Arc::new(state) + } + + #[tokio::test] + async fn search_route_returns_not_found_before_auth_when_unconfigured() { + let state = unconfigured_test_state().await; + let response = Router::new() + .route(SEARCH_PATH, axum::routing::post(search)) + .with_state(state) + .oneshot( + Request::post(SEARCH_PATH) + .body(Body::from("{}")) + .expect("search request"), + ) + .await + .expect("search response"); + + assert_eq!(response.status(), StatusCode::NOT_FOUND); + } + + #[tokio::test] + async fn limited_json_rejects_oversized_streamed_bodies() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind test server"); + let address = listener.local_addr().expect("test server address"); + let server = tokio::spawn(async move { + axum::serve( + listener, + Router::new().route( + "/oversized", + get(|| async { + ( + [(header::CONTENT_TYPE, "application/json")], + "x".repeat(MAX_UPSTREAM_RESPONSE_BYTES + 1), + ) + }), + ), + ) + .await + .expect("serve oversized response"); + }); + let response = reqwest::get(format!("http://{address}/oversized")) + .await + .expect("test upstream response"); + let (status, _) = limited_json(response) + .await + .expect_err("oversized body must be rejected"); + + server.abort(); + let _ = server.await; + assert_eq!(status, StatusCode::BAD_GATEWAY); + } + + #[test] + fn klipy_url_encodes_credentials_as_a_path_segment() { + let url = klipy_url( + "key/with spaces", + &["gifs", "search"], + &[("customer_id", "customer")], + ) + .expect("static URL is valid"); + + assert_eq!( + url.as_str(), + "https://api.klipy.com/api/v1/key%2Fwith%20spaces/gifs/search?customer_id=customer" + ); + } + + #[test] + fn klipy_share_request_uses_slug_path_and_customer_body() { + let request = ShareRequest { + slug: " ship/it ".to_string(), + customer_id: "customer-123".to_string(), + }; + let built = klipy_share_request(&reqwest::Client::new(), "secret-key", &request) + .expect("share request builds") + .build() + .expect("share request is valid"); + + assert_eq!(built.method(), reqwest::Method::POST); + assert_eq!( + built.url().as_str(), + "https://api.klipy.com/api/v1/secret-key/gifs/share/ship%2Fit" + ); + assert_eq!( + built.body().and_then(reqwest::Body::as_bytes), + Some(br#"{"customer_id":"customer-123"}"#.as_slice()) + ); + } + + #[test] + fn validation_bounds_provider_control_fields() { + assert!(validate_text("query", "", 200, true).is_ok()); + assert!(validate_text("customer_id", "", 128, false).is_err()); + assert!(validate_text("query", &"x".repeat(201), 200, true).is_err()); + } + + #[test] + fn successful_payload_strips_provider_errors_and_unknown_fields() { + let payload = successful_search_payload(&serde_json::json!({ + "result": true, + "data": { "data": [] }, + "errors": { "message": ["request used secret-key"] }, + "debug": "secret-key" + })) + .expect("successful payload"); + + assert_eq!( + payload, + serde_json::json!({ "result": true, "data": { "data": [] } }) + ); + } + + #[test] + fn unsuccessful_payload_is_rejected_without_provider_details() { + let (status, body) = successful_search_payload(&serde_json::json!({ + "result": false, + "errors": { "message": ["request used secret-key"] } + })) + .expect_err("unsuccessful provider payload must be rejected"); + + assert_eq!(status, StatusCode::BAD_GATEWAY); + let serialized = serde_json::to_string(&body.0).expect("serialize generic error"); + assert!(!serialized.contains("secret-key")); + } + + /// A provider 3xx must never cause a second connection, and the error + /// surfaced past the shared send/reject path must leak neither the API key + /// (carried in the request path) nor the redirect target. + /// + /// Mutation check: swapping `build_gif_http_client`'s redirect policy back + /// to the default makes the client follow the 302, the redirect listener + /// records a request, and this test fails on the `redirect_hits` assertion. + #[tokio::test] + async fn gif_client_refuses_provider_redirects_without_leaking_secrets() { + use std::sync::atomic::{AtomicUsize, Ordering}; + + const SECRET_KEY: &str = "super-secret-klipy-key"; + + // Second listener: the redirect target. It must never be reached. + let redirect_hits = Arc::new(AtomicUsize::new(0)); + let redirect_listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind redirect target"); + let redirect_addr = redirect_listener.local_addr().expect("redirect address"); + let redirect_hits_server = redirect_hits.clone(); + let redirect_server = tokio::spawn(async move { + axum::serve( + redirect_listener, + Router::new().route( + "/leaked", + get(move || { + redirect_hits_server.fetch_add(1, Ordering::SeqCst); + async { "reached the redirect target" } + }), + ), + ) + .await + .expect("serve redirect target"); + }); + + // Fake upstream: answers the key-bearing path with a 302 whose Location + // points at the second listener, exactly the disclosure vector. + let redirect_location = format!("http://{redirect_addr}/leaked"); + let upstream_listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind fake upstream"); + let upstream_addr = upstream_listener.local_addr().expect("upstream address"); + let location_header = redirect_location.clone(); + let upstream_server = tokio::spawn(async move { + axum::serve( + upstream_listener, + Router::new().route( + &format!("/{SECRET_KEY}/gifs/search"), + get(move || { + let location = location_header.clone(); + async move { + ( + StatusCode::FOUND, + [(header::LOCATION, location)], + "provider body naming the secret-key", + ) + } + }), + ), + ) + .await + .expect("serve fake upstream"); + }); + + let client = build_gif_http_client(); + let response = + send_upstream(client.get(format!("http://{upstream_addr}/{SECRET_KEY}/gifs/search"))) + .await + .expect("request completes without following the redirect"); + + // The redirect was not followed: the client surfaces the 3xx itself. + assert!(response.status().is_redirection()); + assert!(!response.status().is_success()); + assert_eq!(redirect_hits.load(Ordering::SeqCst), 0); + + // The shared reject path (both handlers gate on `!is_success`) returns a + // static generic error carrying no key and no redirect target. + let (status, body) = api_error( + StatusCode::BAD_GATEWAY, + "GIF provider rejected the search request", + ); + let serialized = serde_json::to_string(&body.0).expect("serialize generic error"); + assert_eq!(status, StatusCode::BAD_GATEWAY); + assert!(!serialized.contains(SECRET_KEY)); + assert!(!serialized.contains(&redirect_location)); + + upstream_server.abort(); + redirect_server.abort(); + let _ = upstream_server.await; + let _ = redirect_server.await; + } +} diff --git a/crates/buzz-relay/src/api/mod.rs b/crates/buzz-relay/src/api/mod.rs index 2a942bc803..204ec360c3 100644 --- a/crates/buzz-relay/src/api/mod.rs +++ b/crates/buzz-relay/src/api/mod.rs @@ -3,6 +3,7 @@ pub mod admin; pub mod bridge; pub mod events; +pub mod gifs; pub mod git; pub mod invites; pub mod media; diff --git a/crates/buzz-relay/src/config.rs b/crates/buzz-relay/src/config.rs index 037c6b1dd3..9fb96b7a19 100644 --- a/crates/buzz-relay/src/config.rs +++ b/crates/buzz-relay/src/config.rs @@ -46,6 +46,30 @@ pub struct JoinPolicyConfig { pub version: String, } +/// Optional KLIPY GIF-search integration owned by the relay operator. +/// +/// The API key deliberately stays private and its [`Debug`] implementation is +/// redacted so dumping [`Config`] cannot disclose it. +#[derive(Clone)] +pub struct KlipyConfig { + api_key: String, +} + +impl KlipyConfig { + /// Return the key only to the outbound KLIPY client. + pub(crate) fn api_key(&self) -> &str { + &self.api_key + } +} + +impl std::fmt::Debug for KlipyConfig { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("KlipyConfig") + .field("api_key", &"[REDACTED]") + .finish() + } +} + /// Maximum configured jitter, leaving ten seconds of the hard-drain budget for /// WebSocket close-frame delivery after the final delayed cancellation. pub const MAX_DRAIN_JITTER_MS: u64 = 20_000; @@ -218,6 +242,10 @@ pub struct Config { /// Default: `false`. Set via `BUZZ_ALLOW_NIP_OA_AUTH=true`. pub allow_nip_oa_auth: bool, + /// Relay-owned KLIPY integration. Unset means GIF search is not advertised + /// and its proxy routes return 404. + pub klipy: Option, + /// Media storage configuration (S3/MinIO). pub media: buzz_media::MediaConfig, /// Maximum concurrent media uploads handled by one relay process. @@ -319,6 +347,10 @@ fn rate_limit_config_from_env() -> Result, + /// Relay-owned GIF search integration. The descriptor is public and + /// provider-agnostic; provider credentials remain server-side. + #[serde(skip_serializing_if = "Option::is_none")] + pub gif: Option, /// Relay's own signing pubkey (NIP-11 `self` field, NIP-43). #[serde(rename = "self", skip_serializing_if = "Option::is_none")] pub relay_self: Option, } +/// Public capability descriptor for relay-proxied GIF search. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct GifDescriptor { + /// Provider identifier understood by Buzz clients. + pub provider: String, + /// Relay-relative authenticated metadata search endpoint. + pub search: String, + /// Relay-relative authenticated share-reporting endpoint. + pub share: String, +} + /// Protocol and resource limits advertised in the NIP-11 document. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct RelayLimitation { @@ -138,12 +153,18 @@ impl RelayInfo { /// gates on NIP-43 events — i.e. has a stable key AND enforces /// membership. NIP-43 events are verified against `self`, so it is a /// programmer error to advertise NIP-43 without a `relay_self`. + /// + /// `gif_provider` is a config-derived provider identifier. When present, + /// `build` advertises the provider-agnostic `buzz-gif` extension and the + /// relay-relative metadata search endpoint. It must never contain a + /// provider credential. pub fn build( relay_self: Option<&str>, icon: Option<&str>, advertise_nip43: bool, max_message_length: usize, pairing_relay_url: Option<&str>, + gif_provider: Option<&str>, ) -> Self { debug_assert!( !advertise_nip43 || relay_self.is_some(), @@ -155,6 +176,16 @@ impl RelayInfo { supported_nips.push(NIP_RELAY_MEMBERSHIP); } + let mut supported_extensions = vec!["nip-er".to_string()]; + let gif = gif_provider.map(|provider| { + supported_extensions.push("buzz-gif".to_string()); + GifDescriptor { + provider: provider.to_string(), + search: crate::api::gifs::SEARCH_PATH.to_string(), + share: crate::api::gifs::SHARE_PATH.to_string(), + } + }); + Self { name: "Buzz Relay".to_string(), description: "Buzz — private team communication relay".to_string(), @@ -162,12 +193,13 @@ impl RelayInfo { pubkey: None, contact: None, supported_nips, - supported_extensions: Some(vec!["nip-er".to_string()]), + supported_extensions: Some(supported_extensions), push: None, software: "https://github.com/block/buzz".to_string(), version: env!("CARGO_PKG_VERSION").to_string(), limitation: Some(relay_limitation(max_message_length)), pairing_relay_url: pairing_relay_url.map(str::to_string), + gif, relay_self: relay_self.map(|s| s.to_string()), } } @@ -236,7 +268,8 @@ fn push_descriptor( /// Centralised so the content-negotiated root handler and the dedicated /// `/info` endpoint can't drift apart. Every input to `RelayInfo::build` /// stays a pre-derived scalar: [`nip11_facts`] (config + keypair) plus the -/// host-scoped workspace icon. +/// host-scoped workspace icon. Optional provider capabilities are passed as +/// config-derived scalar identifiers; no provider credential enters NIP-11. pub(crate) async fn nip11_document(state: &crate::state::AppState, raw_host: &str) -> RelayInfo { let (relay_self, advertise_nip43) = nip11_facts(state); let icon = workspace_icon_for_host(state, raw_host).await; @@ -246,6 +279,7 @@ pub(crate) async fn nip11_document(state: &crate::state::AppState, raw_host: &st advertise_nip43, state.config.max_frame_bytes, state.config.pairing_relay_url.as_deref(), + state.config.klipy.as_ref().map(|_| "klipy"), ); let tenant_host = if state.config.push_gateway_delivery_url.is_some() { crate::tenant::bind_community(&state.db, raw_host) @@ -337,6 +371,7 @@ const _RELAY_INFO_BUILD_STATIC_INPUT_FENCE: fn( bool, usize, Option<&str>, + Option<&str>, ) -> RelayInfo = RelayInfo::build; #[cfg(test)] @@ -391,7 +426,7 @@ mod tests { #[test] fn build_advertises_buzz_repository_url() { - let info = RelayInfo::build(None, None, false, DEFAULT_MAX_FRAME_BYTES, None); + let info = RelayInfo::build(None, None, false, DEFAULT_MAX_FRAME_BYTES, None, None); assert_eq!(info.software, "https://github.com/block/buzz"); } @@ -403,6 +438,7 @@ mod tests { false, DEFAULT_MAX_FRAME_BYTES, Some("wss://pairing.buzz.xyz"), + None, ); let json = serde_json::to_value(&info).expect("serialize"); assert_eq!( @@ -411,11 +447,40 @@ mod tests { Some("wss://pairing.buzz.xyz") ); - let info = RelayInfo::build(None, None, false, DEFAULT_MAX_FRAME_BYTES, None); + let info = RelayInfo::build(None, None, false, DEFAULT_MAX_FRAME_BYTES, None, None); let json = serde_json::to_value(&info).expect("serialize"); assert!(json.get("pairing_relay_url").is_none()); } + #[test] + fn gif_descriptor_and_extension_are_config_gated_and_credential_free() { + let info = RelayInfo::build( + None, + None, + false, + DEFAULT_MAX_FRAME_BYTES, + None, + Some("klipy"), + ); + + let json = serde_json::to_value(&info).expect("serialize"); + assert_eq!(json["gif"]["provider"], "klipy"); + assert_eq!(json["gif"]["search"], "/gifs/search"); + assert_eq!(json["gif"]["share"], "/gifs/share"); + assert!(json["supported_extensions"] + .as_array() + .expect("extensions") + .contains(&serde_json::json!("buzz-gif"))); + assert!(!json.to_string().contains("api_key")); + + let unconfigured = RelayInfo::build(None, None, false, DEFAULT_MAX_FRAME_BYTES, None, None); + assert!(unconfigured.gif.is_none()); + assert!(!unconfigured + .supported_extensions + .expect("extensions") + .contains(&"buzz-gif".to_string())); + } + /// NIP-WP → NIP-11 mirror: a set workspace icon is served in the standard /// `icon` field; no icon (or a cleared, empty icon) omits the field /// entirely so the JSON matches pre-icon documents byte-for-byte. @@ -427,6 +492,7 @@ mod tests { false, DEFAULT_MAX_FRAME_BYTES, None, + None, ); assert_eq!( info.icon.as_deref(), @@ -439,7 +505,7 @@ mod tests { ); for icon in [None, Some("")] { - let info = RelayInfo::build(None, icon, false, DEFAULT_MAX_FRAME_BYTES, None); + let info = RelayInfo::build(None, icon, false, DEFAULT_MAX_FRAME_BYTES, None, None); assert!(info.icon.is_none()); let json = serde_json::to_value(&info).expect("serialize"); assert!( @@ -459,7 +525,7 @@ mod tests { #[test] fn max_message_length_uses_configured_frame_limit() { - let info = RelayInfo::build(None, None, false, 262_144, None); + let info = RelayInfo::build(None, None, false, 262_144, None, None); let limitation = info.limitation.expect("limitation"); assert_eq!(limitation.max_message_length, Some(262_144)); } @@ -490,7 +556,7 @@ mod tests { /// Open relay, ephemeral key — both `self` and NIP-43 are absent. #[test] fn build_open_relay_ephemeral_key_omits_self_and_nip43() { - let info = RelayInfo::build(None, None, false, DEFAULT_MAX_FRAME_BYTES, None); + let info = RelayInfo::build(None, None, false, DEFAULT_MAX_FRAME_BYTES, None, None); assert!(info.relay_self.is_none()); assert!(!info.supported_nips.contains(&NIP_RELAY_MEMBERSHIP)); } @@ -503,7 +569,7 @@ mod tests { #[test] fn build_open_relay_stable_key_advertises_self_but_not_nip43() { let pk = "0000000000000000000000000000000000000000000000000000000000000001"; - let info = RelayInfo::build(Some(pk), None, false, DEFAULT_MAX_FRAME_BYTES, None); + let info = RelayInfo::build(Some(pk), None, false, DEFAULT_MAX_FRAME_BYTES, None, None); assert_eq!(info.relay_self.as_deref(), Some(pk)); assert!(!info.supported_nips.contains(&NIP_RELAY_MEMBERSHIP)); } @@ -512,7 +578,7 @@ mod tests { #[test] fn build_membership_relay_advertises_self_and_nip43() { let pk = "0000000000000000000000000000000000000000000000000000000000000001"; - let info = RelayInfo::build(Some(pk), None, true, DEFAULT_MAX_FRAME_BYTES, None); + let info = RelayInfo::build(Some(pk), None, true, DEFAULT_MAX_FRAME_BYTES, None, None); assert_eq!(info.relay_self.as_deref(), Some(pk)); assert!(info.supported_nips.contains(&NIP_RELAY_MEMBERSHIP)); } @@ -523,6 +589,6 @@ mod tests { #[test] #[should_panic(expected = "advertise_nip43=true requires relay_self=Some")] fn build_nip43_without_self_panics_in_debug() { - let _ = RelayInfo::build(None, None, true, DEFAULT_MAX_FRAME_BYTES, None); + let _ = RelayInfo::build(None, None, true, DEFAULT_MAX_FRAME_BYTES, None, None); } } diff --git a/crates/buzz-relay/src/router.rs b/crates/buzz-relay/src/router.rs index 653e21f093..fc5396407c 100644 --- a/crates/buzz-relay/src/router.rs +++ b/crates/buzz-relay/src/router.rs @@ -72,6 +72,9 @@ pub fn build_router(state: Arc) -> Router { .route("/events", post(api::bridge::submit_event)) .route("/query", post(api::bridge::query_events)) .route("/count", post(api::bridge::count_events)) + // Relay-owned third-party GIF metadata proxy (NIP-98 auth). + .route(api::gifs::SEARCH_PATH, post(api::gifs::search)) + .route(api::gifs::SHARE_PATH, post(api::gifs::share)) .route( "/workflows/{workflow_id}/runs", get(api::workflows::workflow_runs), diff --git a/crates/buzz-relay/src/state.rs b/crates/buzz-relay/src/state.rs index 2f544e188c..ab3f1d8c7e 100644 --- a/crates/buzz-relay/src/state.rs +++ b/crates/buzz-relay/src/state.rs @@ -722,6 +722,9 @@ pub struct AppState { /// replace this with process-local caching; replay freshness must survive /// cross-pod routing. pub nip98_replay: Arc, + /// Shared HTTP client for relay-proxied GIF provider requests. Reusing the + /// connection pool avoids a fresh TLS handshake for every search/share. + pub gif_http_client: reqwest::Client, /// Shared Redis-backed admission limits for ordinary HTTP and WebSocket work. pub admission_rate_limiter: Arc, @@ -852,6 +855,7 @@ impl AppState { ); let nip98_replay: Arc = Arc::new(RedisNip98ReplayGuard::new(redis_pool.clone())); + let gif_http_client = crate::api::gifs::build_gif_http_client(); let admission_rate_limiter = Arc::new(RedisRateLimiter::new(redis_pool.clone())); let audit_enabled = audit_arc.is_some(); let state = Self { @@ -912,6 +916,7 @@ impl AppState { shutting_down: Arc::new(AtomicBool::new(false)), started_at: Instant::now(), nip98_replay, + gif_http_client, admission_rate_limiter, observer_rate_limiter: Arc::new(DashMap::new()), media_upload_rate_limiter: Arc::new(DashMap::new()), diff --git a/deploy/charts/buzz/README.md b/deploy/charts/buzz/README.md index e0645ef6fb..8a4b0c6d66 100644 --- a/deploy/charts/buzz/README.md +++ b/deploy/charts/buzz/README.md @@ -44,6 +44,12 @@ The chart is designed for ArgoCD and Flux. Both render charts with `helm templat Production deploys MUST use `secrets.existingSecret:`. The Secret is consumed for any keys present and ignored for keys missing — extras are harmless. +To enable relay-proxied KLIPY search, add `BUZZ_KLIPY_API_KEY` to that Secret. +The key stays in the relay pod; clients discover the public `buzz-gif` +extension and `gif` descriptor in NIP-11, then receive KLIPY-hosted media URLs. +See [`docs/gif-search.md`](../../../docs/gif-search.md) for the protocol and +security boundaries. + See: - [`examples/argocd-app.yaml`](examples/argocd-app.yaml) — ArgoCD Application diff --git a/deploy/charts/buzz/examples/secret-sample.yaml b/deploy/charts/buzz/examples/secret-sample.yaml index 42d3254d48..c615a0de31 100644 --- a/deploy/charts/buzz/examples/secret-sample.yaml +++ b/deploy/charts/buzz/examples/secret-sample.yaml @@ -12,6 +12,7 @@ # REDIS_URL — redis://... (required when replicaCount > 1) # BUZZ_S3_ACCESS_KEY # BUZZ_S3_SECRET_KEY +# BUZZ_KLIPY_API_KEY — omit to disable relay-proxied GIF search apiVersion: v1 kind: Secret metadata: @@ -25,3 +26,4 @@ stringData: REDIS_URL: "redis://:REPLACE@redis.buzz.svc.cluster.local:6379" BUZZ_S3_ACCESS_KEY: "REPLACE" BUZZ_S3_SECRET_KEY: "REPLACE" + BUZZ_KLIPY_API_KEY: "REPLACE" diff --git a/deploy/charts/buzz/templates/deployment.yaml b/deploy/charts/buzz/templates/deployment.yaml index 451ebb1cde..319ec7f159 100644 --- a/deploy/charts/buzz/templates/deployment.yaml +++ b/deploy/charts/buzz/templates/deployment.yaml @@ -215,6 +215,12 @@ spec: name: {{ include "buzz.envSecretName" . }} key: BUZZ_S3_SECRET_KEY optional: true + - name: BUZZ_KLIPY_API_KEY + valueFrom: + secretKeyRef: + name: {{ include "buzz.envSecretName" . }} + key: BUZZ_KLIPY_API_KEY + optional: true - name: BUZZ_HUDDLE_AUDIO_AVAILABLE value: {{ include "buzz.huddleAudioAvailable" . | quote }} diff --git a/deploy/charts/buzz/tests/secrets_test.yaml b/deploy/charts/buzz/tests/secrets_test.yaml index dca83ce27f..d313caf4d4 100644 --- a/deploy/charts/buzz/tests/secrets_test.yaml +++ b/deploy/charts/buzz/tests/secrets_test.yaml @@ -80,6 +80,27 @@ tests: optional: true template: templates/deployment.yaml + - it: Deployment env points BUZZ_KLIPY_API_KEY at existingSecret as optional + set: + relayUrl: wss://buzz.example.com + ownerPubkey: "0000000000000000000000000000000000000000000000000000000000000000" + externalPostgresql.url: postgres://u:p@h:5432/d + s3.endpoint: http://minio:9000 + s3.accessKey: a + s3.secretKey: s + secrets.existingSecret: "buzz-secrets" + asserts: + - contains: + path: spec.template.spec.containers[0].env + content: + name: BUZZ_KLIPY_API_KEY + valueFrom: + secretKeyRef: + name: buzz-secrets + key: BUZZ_KLIPY_API_KEY + optional: true + template: templates/deployment.yaml + - it: READ_DATABASE_URL stays optional against the chart-managed Secret set: relayUrl: wss://buzz.example.com diff --git a/deploy/charts/buzz/values.yaml b/deploy/charts/buzz/values.yaml index 738c50eec3..6c57a5c8ac 100644 --- a/deploy/charts/buzz/values.yaml +++ b/deploy/charts/buzz/values.yaml @@ -94,6 +94,7 @@ ownerPubkey: "" # REDIS_URL — full Redis URL with auth # BUZZ_S3_ACCESS_KEY — S3 access key # BUZZ_S3_SECRET_KEY — S3 secret key +# BUZZ_KLIPY_API_KEY — KLIPY GIF search key; omit to disable GIF search secrets: existingSecret: "" # Inline overrides (NOT recommended for production; they land in values). diff --git a/desktop/src/features/custom-emoji/ui/EmojiPicker.tsx b/desktop/src/features/custom-emoji/ui/EmojiPicker.tsx index 92d640afa9..ea7136db42 100644 --- a/desktop/src/features/custom-emoji/ui/EmojiPicker.tsx +++ b/desktop/src/features/custom-emoji/ui/EmojiPicker.tsx @@ -82,11 +82,14 @@ type EmojiPickerProps = { autoFocus?: boolean; /** Called with the chosen emoji as a string: `native` glyph or `:shortcode:`. */ onSelect: (emoji: string) => void; + /** Number of emoji columns. Defaults to the compact picker used elsewhere. */ + perLine?: number; }; export const EmojiPicker = React.memo(function EmojiPicker({ autoFocus = false, onSelect, + perLine = 8, }: EmojiPickerProps) { const customEmoji = useCustomEmoji(); const custom = React.useMemo( @@ -116,7 +119,7 @@ export const EmojiPicker = React.memo(function EmojiPicker({ onSelect(value); } }} - perLine={8} + perLine={perLine} previewPosition="bottom" set="native" skinTonePosition="search" diff --git a/desktop/src/features/forum/ui/ForumComposer.tsx b/desktop/src/features/forum/ui/ForumComposer.tsx index d79cce9c35..961716aaf4 100644 --- a/desktop/src/features/forum/ui/ForumComposer.tsx +++ b/desktop/src/features/forum/ui/ForumComposer.tsx @@ -610,6 +610,7 @@ export function ForumComposer({ ) : undefined } formattingDisabled={Boolean(disabled || isSubmissionPending)} + gifMediaController={media} isEmojiPickerOpen={isEmojiPickerOpen} isFormattingOpen={isFormattingOpen} isSending={Boolean(isSending || isSubmissionPending)} diff --git a/desktop/src/features/gifs/api.test.mjs b/desktop/src/features/gifs/api.test.mjs new file mode 100644 index 0000000000..60d44b29de --- /dev/null +++ b/desktop/src/features/gifs/api.test.mjs @@ -0,0 +1,179 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + klipyGifAttachment, + klipyGifFilename, + normalizeKlipyGifs, + relayKlipyCapability, +} from "./api.ts"; + +const GIF_ASSET = { + url: "https://static.klipy.com/example.gif", + width: 640, + height: 360, + size: 42, +}; + +test("normalizeKlipyGifs selects a compact preview and medium GIF", () => { + const [gif] = normalizeKlipyGifs([ + { + id: 7, + title: " Ship it ", + slug: "ship-it", + type: "gif", + file: { + md: { gif: GIF_ASSET }, + sm: { + webp: { + url: "https://static.klipy.com/preview.webp", + width: 220, + height: 124, + size: 12, + }, + }, + }, + }, + ]); + + assert.equal(gif.title, "Ship it"); + assert.equal(gif.original.url, GIF_ASSET.url); + assert.equal(gif.preview.url, "https://static.klipy.com/preview.webp"); + assert.equal(gif.poster, null); +}); + +test("normalizeKlipyGifs carries a static jpg poster when present", () => { + const [gif] = normalizeKlipyGifs([ + { + id: 8, + title: "Static poster", + slug: "static-poster", + type: "gif", + file: { + md: { gif: GIF_ASSET }, + sm: { + jpg: { + url: "https://static.klipy.com/poster.jpg", + width: 220, + height: 124, + size: 8, + }, + }, + }, + }, + ]); + + assert.equal(gif.poster?.url, "https://static.klipy.com/poster.jpg"); +}); + +test("normalizeKlipyGifs omits ads and malformed file records", () => { + const gifs = normalizeKlipyGifs([ + { id: 1, slug: "ad", type: "ad" }, + { id: 2, slug: "missing", type: "gif", file: {} }, + ]); + + assert.deepEqual(gifs, []); +}); + +test("relayKlipyCapability requires safe search and share endpoints", () => { + assert.deepEqual( + relayKlipyCapability({ + gif: { + provider: "klipy", + search: "/gifs/search", + share: "/gifs/share", + }, + supported_extensions: ["nip-er", "buzz-gif"], + }), + { searchPath: "/gifs/search", sharePath: "/gifs/share" }, + ); + assert.equal(relayKlipyCapability({}), null); + assert.equal( + relayKlipyCapability({ + gif: { + provider: "another", + search: "/gifs/search", + share: "/gifs/share", + }, + supported_extensions: ["buzz-gif"], + }), + null, + ); + assert.equal( + relayKlipyCapability({ + gif: { provider: "klipy", search: "/gifs/search" }, + supported_extensions: ["buzz-gif"], + }), + null, + ); + for (const path of [ + "https://attacker.example/search", + "//attacker.example/search", + "/\\attacker.example/search", + "/%5c%5cattacker.example/search", + "/gifs/../admin", + "/gifs/%2e%2e/admin", + "/gifs/search?redirect=https://attacker.example", + "/gifs/search#fragment", + ]) { + assert.equal( + relayKlipyCapability({ + gif: { + provider: "klipy", + search: path, + share: "/gifs/share", + }, + supported_extensions: ["buzz-gif"], + }), + null, + ); + assert.equal( + relayKlipyCapability({ + gif: { + provider: "klipy", + search: "/gifs/search", + share: path, + }, + supported_extensions: ["buzz-gif"], + }), + null, + ); + } +}); + +test("klipyGifFilename sanitizes provider slugs", () => { + const gif = { + id: 1, + original: GIF_ASSET, + poster: null, + preview: GIF_ASSET, + slug: " That's a wrap! ", + title: "That's a wrap", + }; + + const filename = klipyGifFilename(gif); + + assert.equal(filename, "that-s-a-wrap.gif"); +}); + +test("klipyGifAttachment references KLIPY media without an uploaded copy", () => { + const attachment = klipyGifAttachment({ + id: 1, + original: GIF_ASSET, + poster: null, + preview: GIF_ASSET, + slug: "ship-it", + title: "Ship it", + }); + + assert.deepEqual(attachment, { + dim: "640x360", + displayLabel: "Ship it", + filename: "ship-it.gif", + sha256: "", + size: 42, + type: "image/gif", + uploaded: 0, + url: GIF_ASSET.url, + }); +}); diff --git a/desktop/src/features/gifs/api.ts b/desktop/src/features/gifs/api.ts new file mode 100644 index 0000000000..1408eaeb3c --- /dev/null +++ b/desktop/src/features/gifs/api.ts @@ -0,0 +1,185 @@ +import type { ImetaMedia } from "@/features/messages/lib/imetaMediaMarkdown"; + +type KlipyAsset = { + height?: number; + size?: number; + url?: string; + width?: number; +}; + +type KlipyFileSet = { + gif?: KlipyAsset; + jpg?: KlipyAsset; + webp?: KlipyAsset; +}; + +type KlipyRawGif = { + file?: { + hd?: KlipyFileSet; + md?: KlipyFileSet; + sm?: KlipyFileSet; + xs?: KlipyFileSet; + }; + id?: number; + slug?: string; + title?: string; + type?: string; +}; + +export type KlipyResponse = { + data?: { + data?: KlipyRawGif[]; + }; + result?: boolean; +}; + +export type KlipyGif = { + id: number | null; + original: Required; + /** + * Static (non-animated) poster for the GIF, when KLIPY exposes a `jpg` + * asset. Rendered in place of the animated preview under + * `prefers-reduced-motion: reduce`. + */ + poster: Required | null; + preview: Required; + slug: string; + title: string; +}; + +export type RelayGifSearchInfo = { + gif?: { + provider?: string; + search?: string; + share?: string; + }; + supported_extensions?: string[]; +}; + +export type RelayKlipyCapability = { + searchPath: string; + sharePath: string; +}; + +function safeRelayPath(path: unknown): path is string { + return ( + typeof path === "string" && + path.startsWith("/") && + !path.startsWith("//") && + !path.includes("\\") && + !path.includes("%") && + !path.includes("?") && + !path.includes("#") && + !path.split("/").some((segment) => segment === "." || segment === "..") + ); +} + +/** The safe relay-relative KLIPY endpoints advertised by NIP-11, if any. */ +export function relayKlipyCapability( + info: RelayGifSearchInfo, +): RelayKlipyCapability | null { + const searchPath = info.gif?.search; + const sharePath = info.gif?.share; + if ( + info.supported_extensions?.includes("buzz-gif") === true && + info.gif?.provider === "klipy" && + safeRelayPath(searchPath) && + safeRelayPath(sharePath) + ) { + return { searchPath, sharePath }; + } + return null; +} + +function isCompleteAsset( + asset: KlipyAsset | undefined, +): asset is Required { + return ( + typeof asset?.url === "string" && + asset.url.length > 0 && + typeof asset.width === "number" && + typeof asset.height === "number" && + typeof asset.size === "number" + ); +} + +function firstCompleteAsset( + ...assets: Array +): Required | null { + return assets.find(isCompleteAsset) ?? null; +} + +/** + * Normalize KLIPY's mixed media response to GIF-only results. The API can + * interleave ad/content records without a file payload; those are intentionally + * omitted until Buzz has an explicit third-party ad surface. + */ +export function normalizeKlipyGifs(items: KlipyRawGif[]): KlipyGif[] { + const gifs: KlipyGif[] = []; + + for (const item of items) { + if (item.type !== "gif" || !item.file || !item.slug) continue; + + const original = firstCompleteAsset( + item.file.md?.gif, + item.file.hd?.gif, + item.file.sm?.gif, + item.file.xs?.gif, + ); + const preview = firstCompleteAsset( + item.file.sm?.webp, + item.file.sm?.gif, + item.file.xs?.webp, + item.file.xs?.gif, + item.file.md?.webp, + original ?? undefined, + ); + if (!original || !preview) continue; + + const poster = firstCompleteAsset( + item.file.sm?.jpg, + item.file.xs?.jpg, + item.file.md?.jpg, + item.file.hd?.jpg, + ); + + gifs.push({ + id: item.id ?? null, + original, + poster, + preview, + slug: item.slug, + title: item.title?.trim() || "GIF", + }); + } + + return gifs; +} + +export function klipyGifFilename(gif: KlipyGif): string { + const safeSlug = gif.slug + .toLowerCase() + .replace(/[^a-z0-9-]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 80); + return `${safeSlug || "klipy-gif"}.gif`; +} + +/** + * Represent a selected KLIPY GIF as externally hosted media. The empty hash + * marks it as content-only media: the outgoing builder appends the image URL + * to the message body but deliberately omits an imeta tag, since Buzz relays + * only accept verified local `/media/` entries in imeta. + */ +export function klipyGifAttachment(gif: KlipyGif): ImetaMedia { + return { + dim: `${gif.original.width}x${gif.original.height}`, + displayLabel: gif.title, + filename: klipyGifFilename(gif), + sha256: "", + size: gif.original.size, + type: "image/gif", + uploaded: 0, + url: gif.original.url, + }; +} diff --git a/desktop/src/features/gifs/relay.ts b/desktop/src/features/gifs/relay.ts new file mode 100644 index 0000000000..bdcffd4f9f --- /dev/null +++ b/desktop/src/features/gifs/relay.ts @@ -0,0 +1,142 @@ +import { + type KlipyGif, + type KlipyResponse, + normalizeKlipyGifs, + relayKlipyCapability, + type RelayKlipyCapability, + type RelayGifSearchInfo, +} from "@/features/gifs/api"; +import { relayHttpFromWs } from "@/shared/api/inviteHelpers"; +import { signRelayEvent } from "@/shared/api/tauri"; + +const KLIPY_CUSTOMER_ID_STORAGE_KEY_PREFIX = "buzz:klipy-customer-id:v1:"; +const NIP98_KIND = 27235; + +function customerId(relayUrl: string): string { + if (typeof window === "undefined") return globalThis.crypto.randomUUID(); + + try { + const storageKey = `${KLIPY_CUSTOMER_ID_STORAGE_KEY_PREFIX}${relayUrl}`; + const existing = window.localStorage.getItem(storageKey); + if (existing) return existing; + + const created = globalThis.crypto.randomUUID(); + window.localStorage.setItem(storageKey, created); + return created; + } catch { + // Storage can be unavailable in hardened webviews. Prefer an ephemeral ID + // over a process-wide fallback that would correlate unrelated relays. + return globalThis.crypto.randomUUID(); + } +} + +async function sha256Hex(text: string): Promise { + const digest = await crypto.subtle.digest( + "SHA-256", + new TextEncoder().encode(text), + ); + return Array.from(new Uint8Array(digest)) + .map((byte) => byte.toString(16).padStart(2, "0")) + .join(""); +} + +async function nip98PostHeader(url: string, body: string): Promise { + const authEvent = await signRelayEvent({ + kind: NIP98_KIND, + content: "", + tags: [ + ["u", url], + ["method", "POST"], + ["payload", await sha256Hex(body)], + ["nonce", crypto.randomUUID()], + ], + }); + return `Nostr ${btoa(JSON.stringify(authEvent))}`; +} + +const FRIENDLY_GIF_ERRORS: Record = { + relay_membership_required: "Join this community to search GIFs.", +}; + +function gifErrorMessage(error: string | undefined, status: number): string { + if (error && FRIENDLY_GIF_ERRORS[error]) return FRIENDLY_GIF_ERRORS[error]; + return error || `GIF request failed (${status})`; +} + +async function relayPost( + relayUrl: string, + path: string, + payload: Record, + signal?: AbortSignal, +): Promise { + const url = `${relayHttpFromWs(relayUrl).replace(/\/+$/, "")}${path}`; + const body = JSON.stringify(payload); + const response = await fetch(url, { + body, + headers: { + Authorization: await nip98PostHeader(url, body), + "Content-Type": "application/json", + }, + method: "POST", + signal, + }); + if (!response.ok) { + const json = (await response.json().catch(() => ({}))) as { + error?: string; + }; + throw new Error(gifErrorMessage(json.error, response.status)); + } + if (response.status === 204) return undefined as T; + return (await response.json()) as T; +} + +/** The selected relay's advertised KLIPY endpoints, when supported. */ +export async function relayKlipyEndpoints( + relayUrl: string, + signal?: AbortSignal, +): Promise { + const url = `${relayHttpFromWs(relayUrl).replace(/\/+$/, "")}/info`; + const response = await fetch(url, { + headers: { Accept: "application/nostr+json" }, + signal, + }); + if (!response.ok) + throw new Error(`Could not read relay capabilities (${response.status})`); + const info = (await response.json()) as RelayGifSearchInfo; + return relayKlipyCapability(info); +} + +/** Search KLIPY through the selected relay without exposing its provider key. */ +export async function fetchKlipyGifs( + relayUrl: string, + searchPath: string, + query: string, + signal?: AbortSignal, +): Promise { + const response = await relayPost( + relayUrl, + searchPath, + { + customer_id: customerId(relayUrl), + locale: navigator.language || "en-US", + query: query.trim(), + }, + signal, + ); + if (response.result === false) { + throw new Error("GIF search failed"); + } + return normalizeKlipyGifs(response.data?.data ?? []); +} + +/** Report a selected GIF so KLIPY can update the anonymous user's Recents. */ +export async function reportKlipyShare( + relayUrl: string, + sharePath: string, + slug: string, +): Promise { + await relayPost(relayUrl, sharePath, { + customer_id: customerId(relayUrl), + slug, + }); +} diff --git a/desktop/src/features/gifs/ui/KlipyGifPicker.tsx b/desktop/src/features/gifs/ui/KlipyGifPicker.tsx new file mode 100644 index 0000000000..6558cb24b8 --- /dev/null +++ b/desktop/src/features/gifs/ui/KlipyGifPicker.tsx @@ -0,0 +1,161 @@ +import { useQuery } from "@tanstack/react-query"; +import { LoaderCircle, Search } from "lucide-react"; +import { useReducedMotion } from "motion/react"; +import * as React from "react"; + +import type { KlipyGif } from "@/features/gifs/api"; +import { fetchKlipyGifs } from "@/features/gifs/relay"; +import { Input } from "@/shared/ui/input"; +import { Skeleton } from "@/shared/ui/skeleton"; + +type KlipyGifPickerProps = { + onSelect: (gif: KlipyGif) => void; + relayUrl: string; + searchPath: string; +}; + +const LOADING_SKELETONS = [ + "tall-a", + "short-a", + "short-b", + "tall-b", + "short-c", + "short-d", + "tall-c", + "short-e", + "short-f", + "tall-d", +] as const; + +export const KlipyGifPicker = React.memo(function KlipyGifPicker({ + onSelect, + relayUrl, + searchPath, +}: KlipyGifPickerProps) { + const [search, setSearch] = React.useState(""); + const [debouncedSearch, setDebouncedSearch] = React.useState(""); + const prefersReducedMotion = useReducedMotion() ?? false; + + React.useEffect(() => { + const timeout = window.setTimeout( + () => setDebouncedSearch(search.trim()), + 500, + ); + return () => window.clearTimeout(timeout); + }, [search]); + + const gifsQuery = useQuery({ + queryFn: ({ signal }) => + fetchKlipyGifs(relayUrl, searchPath, debouncedSearch, signal), + queryKey: ["klipy-gifs", relayUrl, searchPath, debouncedSearch], + retry: false, + staleTime: 5 * 60 * 1_000, + }); + + return ( +
+
+
+ + setSearch(event.target.value)} + placeholder="Search KLIPY" + type="search" + value={search} + /> + {gifsQuery.isFetching ? ( + + ) : null} +
+
+ +
+ {gifsQuery.isPending ? ( +
+ Loading GIFs + {LOADING_SKELETONS.map((id) => ( + + ))} +
+ ) : gifsQuery.isError ? ( +
+

+ {gifsQuery.error.message} +

+ +
+ ) : gifsQuery.data.length === 0 ? ( +
+ No GIFs found. +
+ ) : ( +
+ {gifsQuery.data.map((gif) => { + const staticPoster = prefersReducedMotion ? gif.poster : null; + const showAnimated = !prefersReducedMotion; + return ( + + ); + })} +
+ )} +
+ +
+ Powered by KLIPY +
+
+ ); +}); diff --git a/desktop/src/features/messages/lib/imetaMediaMarkdown.test.mjs b/desktop/src/features/messages/lib/imetaMediaMarkdown.test.mjs index 79c193fb19..e1af5be6bc 100644 --- a/desktop/src/features/messages/lib/imetaMediaMarkdown.test.mjs +++ b/desktop/src/features/messages/lib/imetaMediaMarkdown.test.mjs @@ -597,22 +597,31 @@ test("imetaMediaFromTags: entry without size leaves size 0", () => { assert.equal(out[0].size, 0); }); -test("buildImetaTags: omits x line when sha256 is empty", () => { +test("buildImetaTags: omits hashless external media entirely", () => { const tags = buildImetaTags([ { - url: "https://b/a.png", - type: "image/png", + url: "https://static.klipy.com/a.gif", + type: "image/gif", sha256: "", size: 1, uploaded: 0, }, ]); - assert.equal(tags.length, 1); - // No element starts with "x " or "x\t" — no empty x line emitted. - assert.ok( - !tags[0].some((part) => /^x[\s\t]/.test(part)), - `expected no x line, got ${JSON.stringify(tags[0])}`, - ); + assert.deepEqual(tags, []); +}); + +test("buildOutgoingMessage: hashless external media is content-only", () => { + const out = buildOutgoingMessage("", [ + { + url: "https://static.klipy.com/a.gif", + type: "image/gif", + sha256: "", + size: 1, + uploaded: 0, + }, + ]); + assert.equal(out.content, "\n![image](https://static.klipy.com/a.gif)"); + assert.equal(out.mediaTags, undefined); }); test("buildImetaTags: omits size line when size is 0", () => { @@ -632,31 +641,14 @@ test("buildImetaTags: omits size line when size is 0", () => { ); }); -test("round-trip: sparse imeta from legacy tags rebuilds without empty x/size", () => { - // Legacy / cross-client entry: only url + m. No x, no size. +test("round-trip: sparse legacy imeta is not re-emitted without a hash", () => { const legacyTags = [["imeta", "url https://b/legacy.png", "m image/png"]]; const projected = imetaMediaFromTags(legacyTags); assert.equal(projected.length, 1); assert.equal(projected[0].sha256, ""); assert.equal(projected[0].size, 0); - const rebuilt = buildImetaTags(projected); - assert.equal(rebuilt.length, 1); - // Neither "x " nor "size 0" leaked into the rebuilt tag. - assert.ok( - !rebuilt[0].some((part) => /^x[\s\t]/.test(part)), - `expected no x line, got ${JSON.stringify(rebuilt[0])}`, - ); - assert.ok( - !rebuilt[0].some((part) => /^size[\s\t]/.test(part)), - `expected no size line, got ${JSON.stringify(rebuilt[0])}`, - ); - // url and m survived. - assert.deepEqual(rebuilt[0], [ - "imeta", - "url https://b/legacy.png", - "m image/png", - ]); + assert.deepEqual(buildImetaTags(projected), []); }); const IMETA = ["imeta", "url https://blossom/abc.png", "m image/png"]; diff --git a/desktop/src/features/messages/lib/imetaMediaMarkdown.ts b/desktop/src/features/messages/lib/imetaMediaMarkdown.ts index fde8492289..f02b9906ca 100644 --- a/desktop/src/features/messages/lib/imetaMediaMarkdown.ts +++ b/desktop/src/features/messages/lib/imetaMediaMarkdown.ts @@ -79,29 +79,31 @@ export function imetaMediaFromTags( * Shared by the send path (initial post) and the edit path (full new tag set * on the edit event), so the two stay perfectly symmetric. * - * `url` and `m` are always emitted (NIP-92's only de-facto required fields; - * `m` carries a fallback in `imetaMediaFromTags`). All other fields are - * conditional — including `x` and `size` — because legacy and cross-client - * imeta entries can land without a sha256 or size, and our relay validator - * rejects literal `"x "` / `"size 0"` empties. NIP-92 itself treats every - * field except `url` as optional, so dropping them is spec-clean. + * `url`, `m`, and `x` are emitted for verified relay-hosted media. Entries + * without a hash represent external content (for example KLIPY GIFs); their + * markdown URL remains in the message body, but they are omitted from imeta + * because Buzz's relay validator requires a hash-backed local `/media/` path. + * Other fields remain conditional so legacy entries do not emit invalid + * literal `"size 0"` values. */ export function buildImetaTags( imetaMedia: ReadonlyArray, ): string[][] { - return imetaMedia.map((d) => [ - "imeta", - `url ${d.url}`, - `m ${d.type}`, - ...(d.sha256 ? [`x ${d.sha256}`] : []), - ...(typeof d.size === "number" && d.size > 0 ? [`size ${d.size}`] : []), - ...(d.dim ? [`dim ${d.dim}`] : []), - ...(d.blurhash ? [`blurhash ${d.blurhash}`] : []), - ...(d.thumb ? [`thumb ${d.thumb}`] : []), - ...(d.duration != null ? [`duration ${d.duration}`] : []), - ...(d.image ? [`image ${d.image}`] : []), - ...(d.filename ? [`filename ${d.filename}`] : []), - ]); + return imetaMedia + .filter((d) => d.sha256.length > 0) + .map((d) => [ + "imeta", + `url ${d.url}`, + `m ${d.type}`, + `x ${d.sha256}`, + ...(typeof d.size === "number" && d.size > 0 ? [`size ${d.size}`] : []), + ...(d.dim ? [`dim ${d.dim}`] : []), + ...(d.blurhash ? [`blurhash ${d.blurhash}`] : []), + ...(d.thumb ? [`thumb ${d.thumb}`] : []), + ...(d.duration != null ? [`duration ${d.duration}`] : []), + ...(d.image ? [`image ${d.image}`] : []), + ...(d.filename ? [`filename ${d.filename}`] : []), + ]); } const MEDIA_LINE_RE = @@ -329,9 +331,11 @@ export function buildOutgoingMessage( spoiler: spoileredMediaUrls.has(d.url), }); } - const mediaTags = - pendingImeta.length > 0 ? buildImetaTags(pendingImeta) : undefined; - return { content, mediaTags }; + const mediaTags = buildImetaTags(pendingImeta); + return { + content, + mediaTags: mediaTags.length > 0 ? mediaTags : undefined, + }; } /** diff --git a/desktop/src/features/messages/ui/ComposerAttachments.tsx b/desktop/src/features/messages/ui/ComposerAttachments.tsx index f46336b3e9..be0db49649 100644 --- a/desktop/src/features/messages/ui/ComposerAttachments.tsx +++ b/desktop/src/features/messages/ui/ComposerAttachments.tsx @@ -13,7 +13,6 @@ import { X, } from "lucide-react"; -import type { BlobDescriptor } from "@/shared/api/tauri"; import type { ImetaMedia } from "@/features/messages/lib/imetaMediaMarkdown"; import { rewriteRelayUrl } from "@/shared/lib/mediaUrl"; import { @@ -226,7 +225,7 @@ function composerMediaStyle(): React.CSSProperties { } type MediaAttachmentItemProps = { - attachment: BlobDescriptor; + attachment: ImetaMedia; isSpoilered: boolean; onEditSave?: (url: string, bytes: Uint8Array) => Promise; onRemove: (url: string) => void; @@ -266,6 +265,18 @@ const MediaAttachmentItem = React.forwardRef< const hash = shortHash(attachment.sha256); const isVideo = attachment.type.startsWith("video/"); + // One accessible name for every control/label in this item. Provider media + // (e.g. KLIPY GIFs) carries a `displayLabel` but no content hash; ordinary + // uploads keep their historical type-aware `Attachment ` / + // `Video attachment ` name; only genuinely hashless non-provider media + // falls back to a filename. + const mediaLabel = + attachment.displayLabel?.trim() || + (attachment.sha256 + ? isVideo + ? `Video attachment ${hash}` + : `Attachment ${hash}` + : attachment.filename?.trim() || `Attachment ${hash}`); const thumbUrl = attachment.thumb ? rewriteRelayUrl(attachment.thumb) : rewriteRelayUrl(attachment.url); @@ -275,7 +286,11 @@ const MediaAttachmentItem = React.forwardRef< ? rewriteRelayUrl(attachment.thumb) : undefined; - const canEdit = !isVideo && onEditSave !== undefined; + // Only Buzz-hosted uploads have a content hash. URL-only provider media + // must remain externally hosted instead of being copied into storage by the + // image editor's save path. + const canEdit = + !isVideo && onEditSave !== undefined && attachment.sha256.length === 64; const canRevert = !isVideo && onRevert !== undefined && originalUrl !== undefined; @@ -338,40 +353,41 @@ const MediaAttachmentItem = React.forwardRef< style={composerMediaStyle()} > - -
- {isVideo ? ( -
- {videoPosterUrl ? ( - {`Video - ) : ( -
- )} -
-
- -
+ + {isVideo ? ( +
+ {videoPosterUrl ? ( + + ) : ( +
+ )} +
+
+
- ) : ( - {`Attachment - )} - {isSpoilered ? ( -
- -
- ) : null} -
+
+ ) : ( + + )} + {isSpoilered ? ( +
+ +
+ ) : null} - Attachment {hash} preview + {mediaLabel} preview Full-size attachment preview. Press Escape or click outside to @@ -401,7 +417,7 @@ const MediaAttachmentItem = React.forwardRef< ) : null} {mode === "edit" && !isVideo ? ( ) : ( {`Attachment