From 9213090f6076bf3b7667b9b984752b3e47ef8f2f Mon Sep 17 00:00:00 2001 From: Cameron Hotchkies Date: Thu, 6 Aug 2026 08:04:39 -0700 Subject: [PATCH 01/61] test(desktop): await thread scroll anchor (#3174) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Why The focus/split E2E test could capture the thread root before its programmatic middle-thread scroll had settled, then incorrectly report a scroll-restoration failure. ## What - Poll until the requested middle-thread scroll position is applied - Require the captured anchor to intersect the thread viewport and differ from the root - Preserve the existing focus-to-split-to-focus viewport assertions ## Risk Assessment Low — test-only synchronization change with no production behavior changes. ## References - Original failure: https://github.com/block/buzz/actions/runs/30231271427/job/89870533541 - Buzz thread: buzz://message?channel=12dd513d-45fd-48ff-80ac-8596d2fcc9d3&id=87ce6024b4bf74bfac2fa75d9f7bbbcc8f8fe2df460afe534152c495929f51ba - Reproduced confidence: 20 consecutive targeted passes, full spec pass, `just desktop-ci`, and `just ci` Generated with Codex Signed-off-by: npub1x4hk035p3p9q39a3fcrd2fe30lpkrhr5dwe0cqzzjphxyyh8m0gsq4vqap <356f67c681884a0897b14e06d527317fc361dc746bb2fc0042906e6212e7dbd1@buzz.block.builderlab.xyz> Co-authored-by: npub1x4hk035p3p9q39a3fcrd2fe30lpkrhr5dwe0cqzzjphxyyh8m0gsq4vqap <356f67c681884a0897b14e06d527317fc361dc746bb2fc0042906e6212e7dbd1@buzz.block.builderlab.xyz> Co-authored-by: Wes --- desktop/tests/e2e/thread-focus-mode.spec.ts | 44 ++++++++++++++------- 1 file changed, 30 insertions(+), 14 deletions(-) diff --git a/desktop/tests/e2e/thread-focus-mode.spec.ts b/desktop/tests/e2e/thread-focus-mode.spec.ts index 8bb9a3b52b..36eb0630ba 100644 --- a/desktop/tests/e2e/thread-focus-mode.spec.ts +++ b/desktop/tests/e2e/thread-focus-mode.spec.ts @@ -30,17 +30,37 @@ async function seedLongThread(page: import("@playwright/test").Page) { }); } -async function topVisibleMessageId( +async function scrollToMiddleVisibleMessage( body: import("@playwright/test").Locator, + threadRootId: string, ): Promise { - return body.evaluate((element) => { - const top = element.getBoundingClientRect().top; - const row = Array.from( - element.querySelectorAll("[data-message-id]"), - ).find((candidate) => candidate.getBoundingClientRect().bottom > top); - if (!row?.dataset.messageId) throw new Error("No visible thread anchor"); - return row.dataset.messageId; - }); + let anchorId: string | null = null; + await expect + .poll(async () => { + anchorId = await body.evaluate((element) => { + const maxScrollTop = element.scrollHeight - element.clientHeight; + if (maxScrollTop <= 0) return null; + + const targetScrollTop = Math.floor(maxScrollTop * 0.4); + element.scrollTop = targetScrollTop; + element.dispatchEvent(new Event("scroll", { bubbles: true })); + + if (Math.abs(element.scrollTop - targetScrollTop) > 1) return null; + const bounds = element.getBoundingClientRect(); + const row = Array.from( + element.querySelectorAll("[data-message-id]"), + ).find((candidate) => { + const rect = candidate.getBoundingClientRect(); + return rect.bottom > bounds.top && rect.top < bounds.bottom; + }); + return row?.dataset.messageId ?? null; + }); + return anchorId !== null && anchorId !== threadRootId; + }) + .toBe(true); + + if (!anchorId) throw new Error("No visible middle-thread anchor"); + return anchorId; } /** @@ -181,11 +201,7 @@ test("focus and split preserve reading context and interaction ownership", async .toBe(true); await expect(channel).toHaveAttribute("inert", ""); - await body.evaluate((element) => { - element.scrollTop = element.scrollHeight * 0.4; - element.dispatchEvent(new Event("scroll", { bubbles: true })); - }); - const anchorId = await topVisibleMessageId(body); + const anchorId = await scrollToMiddleVisibleMessage(body, rootId); const focusModeToggle = page.getByRole("button", { name: "Show thread beside channel", From 6ca9641a9555e48f99b3ebccc123ca8c25648a45 Mon Sep 17 00:00:00 2001 From: klopez4212 Date: Thu, 6 Aug 2026 16:21:35 +0100 Subject: [PATCH 02/61] Refine agent runtime controls (#5026) ## Summary - replace ambiguous avatar play controls with centered Start and Restart pills - preserve avatar clipping while smoothly morphing actions into the running status dot - use accessible warning contrast and real restart behavior without a duplicate status badge ## Validation - `just ci` - focused Playwright coverage for morphing, shared geometry, and light/dark contrast Signed-off-by: kenny lopez --- .../agents/lib/managedAgentControlActions.ts | 2 +- .../agents/managedAgentRuntimeStatus.ts | 6 +- .../agents/ui/AgentRuntimeAvatarControl.tsx | 153 ++++++--- desktop/src/features/agents/ui/AgentsView.tsx | 4 + .../agents/ui/UnifiedAgentsSection.tsx | 51 ++- .../agents/ui/useManagedAgentActions.ts | 31 ++ .../profile/ui/MaskedAvatarBadgeFrame.tsx | 220 ++++++++++++- .../profile/ui/UserProfilePrimaryActions.tsx | 4 +- desktop/tests/e2e/agents.spec.ts | 94 ++++++ .../e2e/needs-restart-screenshots.spec.ts | 294 +++++++++++------- 10 files changed, 665 insertions(+), 194 deletions(-) diff --git a/desktop/src/features/agents/lib/managedAgentControlActions.ts b/desktop/src/features/agents/lib/managedAgentControlActions.ts index dbaaaba803..50a92e4f17 100644 --- a/desktop/src/features/agents/lib/managedAgentControlActions.ts +++ b/desktop/src/features/agents/lib/managedAgentControlActions.ts @@ -44,7 +44,7 @@ export function getManagedAgentPrimaryActionLabel(agent: ManagedAgent) { return "Stop"; } - return agent.status === "stopped" ? "Respawn" : "Spawn"; + return agent.status === "stopped" ? "Restart Agent" : "Start Agent"; } export function resolveManagedAgentChannelId( diff --git a/desktop/src/features/agents/managedAgentRuntimeStatus.ts b/desktop/src/features/agents/managedAgentRuntimeStatus.ts index a9f2734f21..c3a952f7d5 100644 --- a/desktop/src/features/agents/managedAgentRuntimeStatus.ts +++ b/desktop/src/features/agents/managedAgentRuntimeStatus.ts @@ -57,9 +57,9 @@ export const MANAGED_AGENT_PAIR_ACTION_LABELS: Record< ManagedAgentPairAction, string > = { - start: "Start", - stop: "Stop", - restart: "Restart", + start: "Start Agent", + stop: "Stop Agent", + restart: "Restart Agent", }; /** diff --git a/desktop/src/features/agents/ui/AgentRuntimeAvatarControl.tsx b/desktop/src/features/agents/ui/AgentRuntimeAvatarControl.tsx index 6f34ffba79..1b3c7a0574 100644 --- a/desktop/src/features/agents/ui/AgentRuntimeAvatarControl.tsx +++ b/desktop/src/features/agents/ui/AgentRuntimeAvatarControl.tsx @@ -1,7 +1,6 @@ -import { CircleAlert, Play } from "lucide-react"; +import { CircleAlert } from "lucide-react"; import { useReducedMotion } from "motion/react"; -import { PresenceDot } from "@/features/presence/ui/PresenceBadge"; import { type AvatarBadgeCurve, MaskedAvatarBadgeFrame, @@ -18,8 +17,10 @@ type AgentRuntimeAvatarControlProps = { errorLabel?: string | null; errorTestId?: string; isActive: boolean; + isRestarting?: boolean; isStarting: boolean; label: string; + requiresRestart?: boolean; startTestId: string; onOpenError?: () => void; onStart: () => void; @@ -29,6 +30,7 @@ const TAILWIND_SPACING = { "1": 4, "2": 8, "2.5": 10, + "3.5": 14, "6": 24, "11": 44, "24": 96, @@ -36,44 +38,58 @@ const TAILWIND_SPACING = { const AGENT_AVATAR_SIZE = TAILWIND_SPACING["24"]; const ACTION_BADGE_SIZE = TAILWIND_SPACING["11"]; -const ACTIVE_BADGE_SIZE = TAILWIND_SPACING["6"]; -const ACTION_BADGE_OFFSET = TAILWIND_SPACING["2.5"]; +const ACTION_BUTTON_HEIGHT = 36; +const START_ACTION_BADGE_WIDTH = 56; +const RESTART_ACTION_BADGE_WIDTH = 72; +const ACTIVE_BADGE_CUTOUT_SIZE = TAILWIND_SPACING["6"]; +const ACTIVE_DOT_SIZE = 18; +const ACTION_BADGE_OFFSET = TAILWIND_SPACING["3.5"] + TAILWIND_SPACING["1"]; const ACTIVE_BADGE_INSET = TAILWIND_SPACING["1"]; -const ACTIVE_DOT_CLASS_NAME = "h-4.5 w-4.5"; const PROFILE_STATUS_CUTOUT_RATIO = 1.25; function getBadgeCenter(badgeSize: number, outwardOffset: number) { return AGENT_AVATAR_SIZE + outwardOffset - badgeSize / 2; } -function getActionBadge(offset: number) { +function getActionBadge(width: number, height: number, offset: number) { + const centerY = getBadgeCenter(ACTION_BADGE_SIZE, offset); + const clearance = (ACTION_BADGE_SIZE - height) / 2; + return { cutout: { - cx: getBadgeCenter(ACTION_BADGE_SIZE, offset), - cy: getBadgeCenter(ACTION_BADGE_SIZE, offset), + // Keep the cutout on the avatar edge so the mask has the same soft, + // two-point join as the status dot. Unlike the status dot, center the + // primary action horizontally to make its purpose easier to spot. + cx: AGENT_AVATAR_SIZE / 2, + cy: centerY, r: ACTION_BADGE_SIZE / 2, }, shell: { - bottom: -offset, - height: ACTION_BADGE_SIZE, - right: -offset, - width: ACTION_BADGE_SIZE, + bottom: AGENT_AVATAR_SIZE - centerY - height / 2, + height, + right: (AGENT_AVATAR_SIZE - width) / 2, + width, }, + // Carry the vertical clearance around the end caps horizontally too, so + // the avatar gap stays even around the pill. + cutoutWidth: width + clearance * 2, } as const; } function getActiveBadge(inset: number) { + const center = getBadgeCenter(ACTIVE_BADGE_CUTOUT_SIZE, -inset); + return { cutout: { - cx: getBadgeCenter(ACTIVE_BADGE_SIZE, -inset), - cy: getBadgeCenter(ACTIVE_BADGE_SIZE, -inset), - r: (ACTIVE_BADGE_SIZE / 2) * PROFILE_STATUS_CUTOUT_RATIO, + cx: center, + cy: center, + r: (ACTIVE_BADGE_CUTOUT_SIZE / 2) * PROFILE_STATUS_CUTOUT_RATIO, }, shell: { - bottom: inset, - height: ACTIVE_BADGE_SIZE, - right: inset, - width: ACTIVE_BADGE_SIZE, + bottom: AGENT_AVATAR_SIZE - center - ACTIVE_DOT_SIZE / 2, + height: ACTIVE_DOT_SIZE, + right: AGENT_AVATAR_SIZE - center - ACTIVE_DOT_SIZE / 2, + width: ACTIVE_DOT_SIZE, }, } as const; } @@ -87,12 +103,26 @@ const ACTION_MASK_CURVE = { handleLengthRatio: 0.26, } satisfies AvatarBadgeCurve; -const ACTION_BADGE = getActionBadge(ACTION_BADGE_OFFSET); +const START_ACTION_BADGE = getActionBadge( + START_ACTION_BADGE_WIDTH, + ACTION_BUTTON_HEIGHT, + ACTION_BADGE_OFFSET, +); +const RESTART_ACTION_BADGE = getActionBadge( + RESTART_ACTION_BADGE_WIDTH, + ACTION_BUTTON_HEIGHT, + ACTION_BADGE_OFFSET, +); +const ERROR_BADGE = getActionBadge( + ACTION_BADGE_SIZE, + ACTION_BUTTON_HEIGHT, + ACTION_BADGE_OFFSET, +); const ACTIVE_BADGE = getActiveBadge(ACTIVE_BADGE_INSET); const MASK_TRANSITION = { - duration: 0.22, - ease: [0.23, 1, 0.32, 1], + duration: 0.3, + ease: [0.4, 0, 0.2, 1], } as const; export function AgentRuntimeAvatarControl({ @@ -101,45 +131,66 @@ export function AgentRuntimeAvatarControl({ errorLabel, errorTestId, isActive, + isRestarting = false, isStarting, label, + requiresRestart = false, startTestId, onOpenError, onStart, }: AgentRuntimeAvatarControlProps) { const shouldReduceMotion = useReducedMotion(); const trimmedAvatarUrl = avatarUrl?.trim() || null; - const actionLabel = isStarting ? `Starting ${label}` : `Start ${label}`; - const hasError = !isActive && !isStarting && Boolean(errorLabel); + const isRestartAction = requiresRestart || isRestarting; + const actionLabel = isRestarting + ? "Restarting Agent" + : isStarting + ? "Starting Agent" + : isRestartAction + ? "Restart Agent" + : "Start Agent"; + const actionText = isRestartAction ? "Restart" : "Start"; + const isPending = isStarting || isRestarting; + const showRunningDot = isActive && !isRestartAction; + const hasError = !isActive && !isPending && Boolean(errorLabel); const errorActionLabel = `${label} has a runtime error. Open runtime details.`; const transition = shouldReduceMotion ? { duration: 0 } : MASK_TRANSITION; - const badge = isActive ? ACTIVE_BADGE : ACTION_BADGE; + const actionBadge = isRestartAction + ? RESTART_ACTION_BADGE + : START_ACTION_BADGE; + const badge = showRunningDot + ? ACTIVE_BADGE + : hasError + ? ERROR_BADGE + : actionBadge; + const actionCutoutWidth = + showRunningDot || hasError ? undefined : actionBadge.cutoutWidth; return ( - {isActive ? ( + {showRunningDot ? ( - - + /> ) : ( )} } badgeBox={badge.shell} + badgeClassName={cn( + "transition-colors ease-in-out", + shouldReduceMotion ? "duration-0" : "duration-300", + showRunningDot + ? "bg-emerald-500" + : hasError + ? "bg-destructive" + : isRestartAction + ? "bg-amber-500/15" + : "bg-primary", + )} className="h-24 w-24" - curve={isActive ? STATUS_DOT_MASK_CURVE : ACTION_MASK_CURVE} + curve={showRunningDot ? STATUS_DOT_MASK_CURVE : ACTION_MASK_CURVE} cutout={badge.cutout} + cutoutWidth={actionCutoutWidth} maskTransition={transition} size={AGENT_AVATAR_SIZE} > diff --git a/desktop/src/features/agents/ui/AgentsView.tsx b/desktop/src/features/agents/ui/AgentsView.tsx index 720d6e62ad..f9ada91c2f 100644 --- a/desktop/src/features/agents/ui/AgentsView.tsx +++ b/desktop/src/features/agents/ui/AgentsView.tsx @@ -225,6 +225,7 @@ export function AgentsView() { isActionPending={isActionPending} isAgentsLoading={agents.managedAgentsQuery.isLoading} startingAgentPubkey={agents.startingAgentPubkey} + restartingAgentPubkey={agents.restartingAgentPubkey} startingPersonaIds={agents.startingPersonaIds} onOpenAgentProfile={(pubkey, options) => { openProfilePanel?.(pubkey, options); @@ -235,6 +236,9 @@ export function AgentsView() { onStartAgent={(pubkey) => { void agents.handleStart(pubkey); }} + onRestartAgent={(pubkey) => { + void agents.handleRestart(pubkey); + }} onStartPersona={(persona) => { void agents.handleStartPersona(persona); }} diff --git a/desktop/src/features/agents/ui/UnifiedAgentsSection.tsx b/desktop/src/features/agents/ui/UnifiedAgentsSection.tsx index cf39b0859e..212d9bc96e 100644 --- a/desktop/src/features/agents/ui/UnifiedAgentsSection.tsx +++ b/desktop/src/features/agents/ui/UnifiedAgentsSection.tsx @@ -10,7 +10,6 @@ import type { ProfilePanelOpenOptions } from "@/shared/context/ProfilePanelConte import { useFeedbackToasts } from "@/shared/hooks/useToastEffect"; import { useFileImportZone } from "@/shared/hooks/useFileImportZone"; import { Badge } from "@/shared/ui/badge"; -import { RestartDiffBadge } from "./RestartDiffBadge"; import { DropdownMenu, DropdownMenuContent, @@ -32,6 +31,7 @@ type UnifiedAgentsSectionProps = { agentsError: Error | null; isActionPending: boolean; isAgentsLoading: boolean; + restartingAgentPubkey: string | null; startingAgentPubkey: string | null; startingPersonaIds: ReadonlySet; onOpenAgentProfile: ( @@ -39,6 +39,7 @@ type UnifiedAgentsSectionProps = { options?: ProfilePanelOpenOptions, ) => void; onOpenPersonaProfile: (persona: AgentPersona) => void; + onRestartAgent: (pubkey: string) => void; onStartAgent: (pubkey: string) => void; onStartPersona: (persona: AgentPersona) => void; personas: AgentPersona[]; @@ -75,10 +76,12 @@ export function UnifiedAgentsSection(props: UnifiedAgentsSectionProps) { agentsError, isActionPending, isAgentsLoading, + restartingAgentPubkey, startingAgentPubkey, startingPersonaIds, onOpenAgentProfile, onOpenPersonaProfile, + onRestartAgent, onStartAgent, onStartPersona, personas, @@ -175,10 +178,12 @@ export function UnifiedAgentsSection(props: UnifiedAgentsSectionProps) { defaultModel={defaultModel} key={group.persona.id} persona={group.persona} + restartingAgentPubkey={restartingAgentPubkey} startingAgentPubkey={startingAgentPubkey} startingPersonaIds={startingPersonaIds} onOpenAgentProfile={onOpenAgentProfile} onOpenPersonaProfile={onOpenPersonaProfile} + onRestartAgent={onRestartAgent} onStartAgent={onStartAgent} onStartPersona={onStartPersona} /> @@ -199,9 +204,11 @@ export function UnifiedAgentsSection(props: UnifiedAgentsSectionProps) { defaultModel={defaultModel} groupKey="__unknown__" label="Unknown agents" + restartingAgentPubkey={restartingAgentPubkey} startingAgentPubkey={startingAgentPubkey} onToggle={toggle} onOpenAgentProfile={onOpenAgentProfile} + onRestartAgent={onRestartAgent} onStartAgent={onStartAgent} /> ) : null} @@ -212,9 +219,11 @@ export function UnifiedAgentsSection(props: UnifiedAgentsSectionProps) { defaultModel={defaultModel} groupKey="__ungrouped__" label="Custom agents" + restartingAgentPubkey={restartingAgentPubkey} startingAgentPubkey={startingAgentPubkey} onToggle={toggle} onOpenAgentProfile={onOpenAgentProfile} + onRestartAgent={onRestartAgent} onStartAgent={onStartAgent} /> ) : null} @@ -244,10 +253,12 @@ function AgentPersonaCard({ agent, defaultModel, persona, + restartingAgentPubkey, startingAgentPubkey, startingPersonaIds, onOpenAgentProfile, onOpenPersonaProfile, + onRestartAgent, onStartAgent, onStartPersona, }: { @@ -258,6 +269,7 @@ function AgentPersonaCard({ agent: ManagedAgent | undefined; defaultModel: string; persona: AgentPersona; + restartingAgentPubkey: string | null; startingAgentPubkey: string | null; startingPersonaIds: ReadonlySet; onOpenAgentProfile: ( @@ -265,6 +277,7 @@ function AgentPersonaCard({ options?: ProfilePanelOpenOptions, ) => void; onOpenPersonaProfile: (persona: AgentPersona) => void; + onRestartAgent: (pubkey: string) => void; onStartAgent: (pubkey: string) => void; onStartPersona: (persona: AgentPersona) => void; }) { @@ -299,13 +312,19 @@ function AgentPersonaCard({ errorLabel={friendlyError} errorTestId={`agent-runtime-error-${agent.pubkey}`} isActive={isActive} + isRestarting={restartingAgentPubkey === agent.pubkey} isStarting={startingAgentPubkey === agent.pubkey} label={title} + requiresRestart={agent.needsRestart} startTestId={`agent-runtime-start-${agent.pubkey}`} onOpenError={() => { onOpenAgentProfile(agent.pubkey, { tab: "runtime" }); }} - onStart={() => onStartAgent(agent.pubkey)} + onStart={() => + agent.needsRestart + ? onRestartAgent(agent.pubkey) + : onStartAgent(agent.pubkey) + } /> ) : ( Configuration missing - ) : agent?.needsRestart ? ( - ) : null } /> @@ -353,17 +367,21 @@ function AgentPersonaCard({ function StandaloneAgentCard({ agent, defaultModel, + restartingAgentPubkey, startingAgentPubkey, onOpenAgentProfile, + onRestartAgent, onStartAgent, }: { agent: ManagedAgent; defaultModel: string; + restartingAgentPubkey: string | null; startingAgentPubkey: string | null; onOpenAgentProfile: ( pubkey: string, options?: ProfilePanelOpenOptions, ) => void; + onRestartAgent: (pubkey: string) => void; onStartAgent: (pubkey: string) => void; }) { const title = agent.name; @@ -385,13 +403,19 @@ function StandaloneAgentCard({ errorLabel={friendlyError} errorTestId={`agent-runtime-error-${agent.pubkey}`} isActive={isActive} + isRestarting={restartingAgentPubkey === agent.pubkey} isStarting={startingAgentPubkey === agent.pubkey} label={title} + requiresRestart={agent.needsRestart} startTestId={`agent-runtime-start-${agent.pubkey}`} onOpenError={() => { onOpenAgentProfile(agent.pubkey, { tab: "runtime" }); }} - onStart={() => onStartAgent(agent.pubkey)} + onStart={() => + agent.needsRestart + ? onRestartAgent(agent.pubkey) + : onStartAgent(agent.pubkey) + } /> } avatarUrl={profileQuery.data?.avatarUrl} @@ -414,11 +438,6 @@ function StandaloneAgentCard({ Configuration missing - ) : agent.needsRestart ? ( - ) : null } /> @@ -498,9 +517,11 @@ function CollapsibleAgentGroup({ agents, collapsed, defaultModel, + restartingAgentPubkey, startingAgentPubkey, onToggle, onOpenAgentProfile, + onRestartAgent, onStartAgent, }: { groupKey: string; @@ -508,12 +529,14 @@ function CollapsibleAgentGroup({ agents: ManagedAgent[]; collapsed: ReadonlySet; defaultModel: string; + restartingAgentPubkey: string | null; startingAgentPubkey: string | null; onToggle: (key: string) => void; onOpenAgentProfile: ( pubkey: string, options?: ProfilePanelOpenOptions, ) => void; + onRestartAgent: (pubkey: string) => void; onStartAgent: (pubkey: string) => void; }) { const isCollapsed = collapsed.has(groupKey); @@ -539,8 +562,10 @@ function CollapsibleAgentGroup({ agent={agent} defaultModel={defaultModel} key={agent.pubkey} + restartingAgentPubkey={restartingAgentPubkey} startingAgentPubkey={startingAgentPubkey} onOpenAgentProfile={onOpenAgentProfile} + onRestartAgent={onRestartAgent} onStartAgent={onStartAgent} /> ))} diff --git a/desktop/src/features/agents/ui/useManagedAgentActions.ts b/desktop/src/features/agents/ui/useManagedAgentActions.ts index e1c2e9c9fc..6068ad1639 100644 --- a/desktop/src/features/agents/ui/useManagedAgentActions.ts +++ b/desktop/src/features/agents/ui/useManagedAgentActions.ts @@ -26,6 +26,7 @@ import { normalizePubkey } from "@/shared/lib/pubkey"; import { deleteManagedAgentWithRules, isManagedAgentActive, + respawnManagedAgentWithRules, startManagedAgentWithRules, stopManagedAgentWithRules, } from "../lib/managedAgentControlActions"; @@ -57,6 +58,9 @@ export function useManagedAgentActions() { ReadonlySet >(() => new Set()); const startingPersonaIdsRef = React.useRef(new Set()); + const [restartingAgentPubkey, setRestartingAgentPubkey] = React.useState< + string | null + >(null); const [logAgentPubkey, setLogAgentPubkey] = React.useState( null, ); @@ -174,6 +178,30 @@ export function useManagedAgentActions() { } } + async function handleRestart(pubkey: string) { + if (restartingAgentPubkey) return; + clearFeedback(); + setRestartingAgentPubkey(pubkey); + try { + const agent = managedAgents.find( + (candidate) => candidate.pubkey === pubkey, + ); + if (!agent) return; + await respawnManagedAgentWithRules({ + agent, + startManagedAgent: startMutation.mutateAsync, + stopManagedAgent: stopMutation.mutateAsync, + onStopped: () => clearActiveTurnsForAgentOnStop(agent.pubkey), + }); + } catch (error) { + setActionErrorMessage( + error instanceof Error ? error.message : "Failed to restart agent.", + ); + } finally { + setRestartingAgentPubkey(null); + } + } + function setPersonaStartPending(personaId: string, pending: boolean) { const next = new Set(startingPersonaIdsRef.current); if (pending) { @@ -387,6 +415,7 @@ export function useManagedAgentActions() { } const isPending = + restartingAgentPubkey !== null || createAgentMutation.isPending || startMutation.isPending || stopMutation.isPending || @@ -420,8 +449,10 @@ export function useManagedAgentActions() { actionErrorMessage, setActionErrorMessage, startingAgentPubkey, + restartingAgentPubkey, startingPersonaIds, handleStart, + handleRestart, handleStartPersona, handleStop, handleDelete, diff --git a/desktop/src/features/profile/ui/MaskedAvatarBadgeFrame.tsx b/desktop/src/features/profile/ui/MaskedAvatarBadgeFrame.tsx index 294a87e5e2..ea6e234493 100644 --- a/desktop/src/features/profile/ui/MaskedAvatarBadgeFrame.tsx +++ b/desktop/src/features/profile/ui/MaskedAvatarBadgeFrame.tsx @@ -43,12 +43,14 @@ type BadgeMotionTarget = { type MaskedAvatarBadgeFrameProps = { badge?: React.ReactNode; badgeBox?: AvatarBadgeBox; + badgeClassName?: string; children: React.ReactNode; className?: string; clipTestId?: string; cornerRadius?: number; curve?: AvatarBadgeCurve; cutout?: AvatarBadgeCircle; + cutoutWidth?: number; maskMode?: "clip-path" | "radial"; maskTransition?: React.ComponentProps["transition"]; size: number; @@ -323,6 +325,30 @@ function sampleArc( ); } +function sampleStableOuterBoundary( + avatar: AvatarBadgeCircle, + startAngle: number, + endAngle: number, + direction: 1 | -1, + largeArc: boolean, + segments: number, +) { + const outerBoundary = { ...avatar, r: avatar.r * 4 }; + + return [ + getPointOnCircle(outerBoundary, startAngle), + ...sampleArc( + outerBoundary, + startAngle, + endAngle, + direction, + largeArc, + segments - 2, + ), + getPointOnCircle(avatar, endAngle), + ]; +} + function toPolygonPoint(point: Point, size: number) { return `${toPercent(point.x / size)} ${toPercent(point.y / size)}`; } @@ -331,6 +357,7 @@ function getRoundedAvatarMaskPolygon( size: number, cutout: AvatarBadgeCircle, curve?: AvatarBadgeCurve, + stabilizeOuterBoundary = false, ) { const { avatar, @@ -361,14 +388,23 @@ function getRoundedAvatarMaskPolygon( avatarUpper, 12, ), - ...sampleArc( - avatar, - getAngle(avatar, avatarUpper), - getAngle(avatar, avatarLower), - -1, - true, - 96, - ), + ...(stabilizeOuterBoundary + ? sampleStableOuterBoundary( + avatar, + getAngle(avatar, avatarUpper), + getAngle(avatar, avatarLower), + -1, + true, + 96, + ) + : sampleArc( + avatar, + getAngle(avatar, avatarUpper), + getAngle(avatar, avatarLower), + -1, + true, + 96, + )), ...sampleCubic( avatarLower, getControlPoint(avatarLower, lowerAvatarTangent, lowerHandleLength), @@ -389,6 +425,141 @@ function getRoundedAvatarMaskPolygon( return `polygon(${points.map((point) => toPolygonPoint(point, size)).join(", ")})`; } +function getRoundedAvatarCapsuleMaskPolygon( + size: number, + cutout: AvatarBadgeCircle, + cutoutWidth: number, + curve?: AvatarBadgeCurve, + stabilizeOuterBoundary = false, +) { + const resolvedCurve = { ...DEFAULT_AVATAR_BADGE_CURVE, ...curve }; + const avatar = { + cx: size / 2, + cy: size / 2, + r: size / 2, + }; + const straightHalfWidth = Math.max(0, cutoutWidth / 2 - cutout.r); + const leftCap = { + cx: cutout.cx - straightHalfWidth, + cy: cutout.cy, + r: cutout.r, + }; + const rightCap = { + cx: cutout.cx + straightHalfWidth, + cy: cutout.cy, + r: cutout.r, + }; + const leftIntersection = getCircleIntersections(avatar, leftCap).reduce( + (leftmost, point) => (point.x < leftmost.x ? point : leftmost), + ); + const rightIntersection = getCircleIntersections(avatar, rightCap).reduce( + (rightmost, point) => (point.x > rightmost.x ? point : rightmost), + ); + const cutoutRoundingAngle = Math.min( + resolvedCurve.cutoutRoundingMaxAngle, + Math.max( + resolvedCurve.cutoutRoundingMinAngle, + resolvedCurve.cutoutRoundingLength / cutout.r, + ), + ); + const avatarLeft = getPointOnCircle( + avatar, + getAngle(avatar, leftIntersection) + resolvedCurve.avatarRoundingAngle, + ); + const avatarRight = getPointOnCircle( + avatar, + getAngle(avatar, rightIntersection) - resolvedCurve.avatarRoundingAngle, + ); + const cutoutLeft = getPointOnCircle( + leftCap, + getAngle(leftCap, leftIntersection) + cutoutRoundingAngle, + ); + const cutoutRight = getPointOnCircle( + rightCap, + getAngle(rightCap, rightIntersection) - cutoutRoundingAngle, + ); + const leftHandleLength = Math.min( + cutout.r * resolvedCurve.handleLengthRatio, + getDistance(cutoutLeft, avatarLeft) * resolvedCurve.handleDistanceRatio, + ); + const rightHandleLength = Math.min( + cutout.r * resolvedCurve.handleLengthRatio, + getDistance(avatarRight, cutoutRight) * resolvedCurve.handleDistanceRatio, + ); + const cutoutLeftTangent = getTangent(getAngle(leftCap, cutoutLeft), -1); + const avatarLeftTangent = getTangent(getAngle(avatar, avatarLeft), 1); + const avatarRightTangent = getTangent(getAngle(avatar, avatarRight), 1); + const cutoutRightTangent = getTangent(getAngle(rightCap, cutoutRight), -1); + const points = [ + cutoutLeft, + ...sampleCubic( + cutoutLeft, + getControlPoint(cutoutLeft, cutoutLeftTangent, leftHandleLength), + getControlPoint(avatarLeft, avatarLeftTangent, -leftHandleLength), + avatarLeft, + 12, + ), + ...(stabilizeOuterBoundary + ? sampleStableOuterBoundary( + avatar, + getAngle(avatar, avatarLeft), + getAngle(avatar, avatarRight), + 1, + true, + 96, + ) + : sampleArc( + avatar, + getAngle(avatar, avatarLeft), + getAngle(avatar, avatarRight), + 1, + true, + 96, + )), + ...sampleCubic( + avatarRight, + getControlPoint(avatarRight, avatarRightTangent, rightHandleLength), + getControlPoint(cutoutRight, cutoutRightTangent, -rightHandleLength), + cutoutRight, + 12, + ), + ...sampleArc( + rightCap, + getAngle(rightCap, cutoutRight), + -Math.PI / 2, + -1, + false, + 12, + ), + { x: leftCap.cx, y: cutout.cy - cutout.r }, + ...sampleArc( + leftCap, + -Math.PI / 2, + getAngle(leftCap, cutoutLeft), + -1, + false, + 11, + ), + ]; + + // Keep the capsule contour aligned with the circular status cutout's point + // order. Matching like-for-like edges prevents the polygon from folding + // across the avatar while Motion interpolates between the two shapes. + const joinSegments = 12; + const outerSegments = 96; + const outerEndIndex = joinSegments + outerSegments; + const rightJoinEndIndex = joinSegments * 2 + outerSegments; + const alignedPoints = [ + points[rightJoinEndIndex], + ...points.slice(outerEndIndex, rightJoinEndIndex).reverse(), + ...points.slice(joinSegments, outerEndIndex).reverse(), + ...points.slice(0, joinSegments).reverse(), + ...points.slice(rightJoinEndIndex + 1).reverse(), + ]; + + return `polygon(${alignedPoints.map((point) => toPolygonPoint(point, size)).join(", ")})`; +} + function getRoundedSquareMaskPolygon( size: number, cornerRadius: number, @@ -488,20 +659,36 @@ function getRoundedSquareMaskPolygon( export function MaskedAvatarBadgeFrame({ badge, badgeBox, + badgeClassName, children, className, clipTestId, cornerRadius, curve, cutout, + cutoutWidth, maskMode = "clip-path", maskTransition, size, }: MaskedAvatarBadgeFrameProps) { const shouldMask = Boolean(badge && badgeBox && cutout); + const stabilizeOuterBoundary = Boolean(maskTransition); const maskPolygon = cutout ? cornerRadius === undefined - ? getRoundedAvatarMaskPolygon(size, cutout, curve) + ? cutoutWidth && cutoutWidth > cutout.r * 2 + ? getRoundedAvatarCapsuleMaskPolygon( + size, + cutout, + cutoutWidth, + curve, + stabilizeOuterBoundary, + ) + : getRoundedAvatarMaskPolygon( + size, + cutout, + curve, + stabilizeOuterBoundary, + ) : getRoundedSquareMaskPolygon(size, cornerRadius, cutout, curve) : undefined; const radialMask = @@ -539,10 +726,18 @@ export function MaskedAvatarBadgeFrame({ data-testid={clipTestId} initial={false} style={{ - WebkitClipPath: radialMask ? undefined : maskPolygon, + // WebKit otherwise applies the prefixed path immediately while the + // unprefixed path is still animating, which briefly tears the avatar. + WebkitClipPath: + radialMask || maskTransition ? undefined : maskPolygon, WebkitMaskImage: radialMask, + backfaceVisibility: + maskTransition && !radialMask ? "hidden" : undefined, clipPath: radialMask ? undefined : maskPolygon, maskImage: radialMask, + transform: + maskTransition && !radialMask ? "translateZ(0)" : undefined, + willChange: maskTransition && !radialMask ? "clip-path" : undefined, }} transition={maskTransition} > @@ -551,7 +746,10 @@ export function MaskedAvatarBadgeFrame({ @@ -143,7 +143,7 @@ export function ProfilePersonaPrimaryActions({ diff --git a/desktop/tests/e2e/agents.spec.ts b/desktop/tests/e2e/agents.spec.ts index 3cbe097c05..befe2b5563 100644 --- a/desktop/tests/e2e/agents.spec.ts +++ b/desktop/tests/e2e/agents.spec.ts @@ -2437,6 +2437,100 @@ test("personas referenced by teams cannot be deleted", async ({ page }) => { ); }); +test("start pill morphs into the running dot without remounting the avatar", async ({ + page, +}) => { + const personaId = "custom:motion-auditor"; + const pubkey = "ab".repeat(32); + const activeDotSize = 18; + await page.emulateMedia({ reducedMotion: "no-preference" }); + await installMockBridge(page, { + personas: [ + { + avatarUrl: emojiAvatarDataUrl("✨", "#7657FF"), + displayName: "Motion Auditor", + id: personaId, + systemPrompt: "You audit motion continuity.", + }, + ], + managedAgents: [ + { + name: "Motion Auditor", + personaId, + pubkey, + status: "stopped", + }, + ], + }); + await gotoApp(page); + await page.getByTestId("open-agents-view").click(); + + const card = page.getByTestId(`persona-agent-row-${personaId}`); + const startButton = page.getByTestId(`agent-runtime-start-${pubkey}`); + const badge = startButton.locator("xpath=../.."); + const initialAvatar = await card + .getByAltText("Motion Auditor avatar") + .elementHandle(); + expect(initialAvatar).not.toBeNull(); + + const samplesPromise = badge.evaluate(async (element) => { + const samples: Array<{ + backgroundColor: string; + height: number; + width: number; + }> = []; + const startedAt = performance.now(); + + while (performance.now() - startedAt < 440) { + const bounds = element.getBoundingClientRect(); + samples.push({ + backgroundColor: getComputedStyle(element).backgroundColor, + height: bounds.height, + width: bounds.width, + }); + await new Promise((resolve) => + requestAnimationFrame(() => resolve()), + ); + } + + return samples; + }); + + await page.waitForTimeout(32); + await startButton.click(); + await expect( + page.getByTestId(`agent-runtime-active-${pubkey}`), + ).toBeVisible(); + const samples = await samplesPromise; + const finalAvatar = await card + .getByAltText("Motion Auditor avatar") + .elementHandle(); + + expect(samples[0]?.width).toBeCloseTo(56, 0); + expect(samples[0]?.height).toBeCloseTo(36, 0); + expect( + samples.some( + (sample) => + sample.width > activeDotSize && + sample.width < 56 && + sample.height > activeDotSize && + sample.height < 36, + ), + ).toBe(true); + expect(samples.at(-1)?.width).toBeCloseTo(activeDotSize, 0); + expect(samples.at(-1)?.height).toBeCloseTo(activeDotSize, 0); + expect(samples.at(-1)?.backgroundColor).not.toBe(samples[0]?.backgroundColor); + await expect( + page.getByTestId(`agent-runtime-active-${pubkey}`).locator("xpath=../.."), + ).toHaveClass(/bg-emerald-500/); + expect( + await initialAvatar?.evaluate( + (before, after) => before === after, + finalAvatar, + ), + ).toBe(true); +}); + test("duplicate instances move from the agents gallery into the agent profile", async ({ page, }) => { diff --git a/desktop/tests/e2e/needs-restart-screenshots.spec.ts b/desktop/tests/e2e/needs-restart-screenshots.spec.ts index 1a695844fb..508a5d91e9 100644 --- a/desktop/tests/e2e/needs-restart-screenshots.spec.ts +++ b/desktop/tests/e2e/needs-restart-screenshots.spec.ts @@ -3,8 +3,8 @@ * overlay work). * * Exercises: - * - Agent grid card and list-row badges at all three badge sites. - * - Hover tooltip with itemised before→after diff (capped at 6 + "and N more"). + * - Agent grid restart actions without a duplicate status badge. + * - Profile badge tooltip with itemised before→after diff. * - Runtime-tab banner with full uncapped diff list. * - Side-panel badge visible on the default (Info) tab — not only Runtime. * - DOM validity: tooltip trigger has no + + + + ) : step === "done" ? ( +
+
+ +
+

Identity received securely

+
+ ) : step === "error" ? ( +
+ +

{error}

+ +
+ ) : ( +
+ +

+ {step === "receiving" + ? "Receiving identity from mobile device..." + : "Starting pairing..."} +

+
+ )} + + {step === "loading" || (step === "qr" && qrUri) ? ( + + ) : null} + {step === "qr" && error ? ( +

+ {error} +

+ ) : null} + {step === "qr" || step === "loading" ? ( +

+ On your phone, open Settings → Send identity to desktop. This code + expires shortly and works once. +

+ ) : null} + + ); +} diff --git a/desktop/src/features/onboarding/ui/MachineOnboardingFlow.tsx b/desktop/src/features/onboarding/ui/MachineOnboardingFlow.tsx index 693d1af058..c0cc2d8f79 100644 --- a/desktop/src/features/onboarding/ui/MachineOnboardingFlow.tsx +++ b/desktop/src/features/onboarding/ui/MachineOnboardingFlow.tsx @@ -10,6 +10,12 @@ import { } from "@/shared/api/tauriIdentity"; import type { IdentityStorage } from "@/shared/api/types"; import { Button } from "@/shared/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogTitle, +} from "@/shared/ui/dialog"; import { StartupWindowDragRegion } from "@/shared/ui/StartupWindowDragRegion"; import { BackupStep } from "./BackupStep"; import { DefaultConfigStep } from "./DefaultConfigStep"; @@ -20,12 +26,14 @@ import { useEncryptedBackupSession, } from "./EncryptedBackupCreator"; import { IdentityKeyHelpDialog } from "./IdentityKeyHelpDialog"; +import { IdentityRecoveryPairing } from "./IdentityRecoveryPairing"; import { LandingBees } from "./LandingBees"; import { NostrKeyImportForm, type NostrKeyImportStage, } from "./NostrKeyImportForm"; import { + ONBOARDING_INK_ICON_CLASS, ONBOARDING_LANDING_CTA_CLASS, ONBOARDING_SECONDARY_CTA_CLASS, OnboardingChrome, @@ -53,6 +61,7 @@ export type PostOnboardingNavigation = { export function MachineOnboardingFlow({ complete, continueWithIdentity, + continueWithRecoveredIdentity, identityLost, initialPage, queryClient, @@ -60,6 +69,7 @@ export function MachineOnboardingFlow({ }: { complete: (pubkey?: string) => void; continueWithIdentity: (pubkey: string) => void; + continueWithRecoveredIdentity: (pubkey: string) => void; identityLost: boolean; initialPage?: MachineOnboardingPage; queryClient: QueryClient; @@ -79,6 +89,10 @@ export function MachineOnboardingFlow({ const [identityWasImported, setIdentityWasImported] = React.useState(false); const [keyImportStage, setKeyImportStage] = React.useState("key-entry"); + const [keyImportDialog, setKeyImportDialog] = React.useState< + "backup" | "phone" | null + >(null); + const [phoneRecoveryStep, setPhoneRecoveryStep] = React.useState("loading"); const [selectedPubkey, setSelectedPubkey] = React.useState( null, ); @@ -128,6 +142,26 @@ export function MachineOnboardingFlow({ } }, [queryClient]); + const loadRecoveredIdentity = React.useCallback(async () => { + setIsPending(true); + setError(null); + try { + const identity = await getIdentity(); + continueWithRecoveredIdentity(identity.pubkey); + queryClient.setQueryData(["identity"], identity); + setIdentityWasImported(true); + setSelectedPubkey(identity.pubkey); + setIdentityStorage(identity.storage); + setPage("setup"); + } catch (cause) { + setError( + cause instanceof Error ? cause.message : "Failed to load identity", + ); + } finally { + setIsPending(false); + } + }, [continueWithRecoveredIdentity, queryClient]); + const replaceLostIdentity = React.useCallback(async () => { const confirmed = window.confirm( "This will create a new identity and abandon your previous key. This cannot be undone. Continue?", @@ -243,6 +277,7 @@ export function MachineOnboardingFlow({ className={`${ONBOARDING_SECONDARY_CTA_CLASS} px-5`} disabled={isPending} onClick={() => { + setKeyImportDialog(null); setKeyImportStage("key-entry"); setPage("key-import"); }} @@ -265,7 +300,7 @@ export function MachineOnboardingFlow({ > {keyImportStage === "backup-password" ? "Unlock your account" - : identityLost - ? "Re-import your key" - : "Enter your private key"} + : "Enter your private key"} -

- {keyImportStage === "backup-password" - ? "Enter your backup password to unlock your key and restore your identity." - : identityLost - ? "Your identity is no longer in the system keyring. Re-import your nsec to restore it." - : "If you already have a Buzz account, enter your private key below to get started."} -

+
+ {keyImportStage === "backup-password" ? ( + "Enter your backup password to restore your identity." + ) : ( +

+ Paste your private key to sign in to Buzz. You can also + use a{" "} + + , or{" "} + + . +

+ )} +
- void replaceLostIdentity() - : () => setPage("identity") - } - onImport={importExistingIdentity} - onStageChange={setKeyImportStage} - variant="spotlight" - /> +
+ { + setKeyImportStage("key-entry"); + if (identityLost) { + return; + } + setPage("identity"); + }} + onImport={importExistingIdentity} + onStageChange={setKeyImportStage} + showBack={!identityLost} + variant="spotlight" + /> + {identityLost && keyImportStage === "key-entry" ? ( + + ) : null} +
+ { + if (!open) setKeyImportDialog(null); + }} + open={keyImportDialog === "backup"} + > + +
+ + Restore from a backup file + + + Choose the encrypted backup file you saved from Buzz. + + setKeyImportDialog(null)} + onImport={importExistingIdentity} + showBack={false} + variant="spotlight" + /> +
+
+
+ { + if (!open) setKeyImportDialog(null); + }} + open={keyImportDialog === "phone"} + > + +
+ + {identityLost + ? "Recover from your phone" + : "Use your Buzz identity"} + + + {phoneRecoveryStep === "loading" || + phoneRecoveryStep === "qr" + ? "Scan this code with a signed-in Buzz phone." + : "Confirm the code before sharing your identity."} + +
+ +
+
+
+
) : page === "backup" ? ( backupSubview === "password" ? ( diff --git a/desktop/src/features/onboarding/ui/NostrKeyImportForm.tsx b/desktop/src/features/onboarding/ui/NostrKeyImportForm.tsx index 59e5bfdb0b..a424236eb6 100644 --- a/desktop/src/features/onboarding/ui/NostrKeyImportForm.tsx +++ b/desktop/src/features/onboarding/ui/NostrKeyImportForm.tsx @@ -1,5 +1,5 @@ import * as React from "react"; -import { Check, Eye, EyeOff, KeyRound } from "lucide-react"; +import { Check, Eye, EyeOff, FileKey2, KeyRound } from "lucide-react"; import { cn } from "@/shared/lib/cn"; import { nsecToNpub } from "@/shared/lib/nostrUtils"; @@ -16,7 +16,10 @@ import { ONBOARDING_PRIMARY_CTA_CLASS, ONBOARDING_SECONDARY_CTA_CLASS, } from "./OnboardingChrome"; -import { BackupPasswordTimeline } from "./BackupPasswordTimeline"; +import { + BackupFileUnlockPreview, + BackupPasswordTimeline, +} from "./BackupPasswordTimeline"; import { OnboardingFooter } from "./OnboardingFooter"; const NOSTR_KEY_FILE_MAX_BYTES = 1024; @@ -30,6 +33,11 @@ type NostrKeyImportFormProps = { onBack: () => void; onImport: (nsec: string, password?: string) => Promise; onStageChange?: (stage: NostrKeyImportStage) => void; + showBack?: boolean; + /** Restrict this instance to selecting a backup file instead of typing a key. */ + mode?: "key" | "backup"; + /** Dialogs keep their actions inside the surface instead of the onboarding dock. */ + footerMode?: "onboarding" | "inline"; /** "spotlight" is the first-launch treatment: glowy centered input, no drop zone, pill buttons. */ variant?: "default" | "spotlight"; }; @@ -48,6 +56,9 @@ export function NostrKeyImportForm({ onBack, onImport, onStageChange, + showBack = true, + mode = "key", + footerMode = "onboarding", variant = "default", }: NostrKeyImportFormProps) { const [nsecInput, setNsecInput] = React.useState(""); @@ -55,6 +66,7 @@ export function NostrKeyImportForm({ const [isImporting, setIsImporting] = React.useState(false); const [importError, setImportError] = React.useState(null); const [isDragging, setIsDragging] = React.useState(false); + const dragDepthRef = React.useRef(0); const [isRevealed, setIsRevealed] = React.useState(false); const inputRef = React.useRef(null); const passphraseInputRef = React.useRef(null); @@ -89,6 +101,7 @@ export function NostrKeyImportForm({ previewNpub === null && trimmedInput.length >= 5; const errorMessage = importError ?? externalErrorMessage; + const Footer = footerMode === "inline" ? "div" : OnboardingFooter; React.useLayoutEffect(() => { if (isPasswordStage) { @@ -102,6 +115,39 @@ export function NostrKeyImportForm({ onStageChange?.(isPasswordStage ? "backup-password" : "key-entry"); }, [isPasswordStage, onStageChange]); + React.useEffect(() => { + if (mode !== "backup" || isPasswordStage || isInteractionDisabled) { + dragDepthRef.current = 0; + setIsDragging(false); + return; + } + + const handleDragEnter = (event: DragEvent) => { + if (!event.dataTransfer?.types.includes("Files")) return; + dragDepthRef.current += 1; + setIsDragging(true); + }; + const handleDragLeave = () => { + dragDepthRef.current = Math.max(0, dragDepthRef.current - 1); + if (dragDepthRef.current === 0) setIsDragging(false); + }; + const handleDragEnd = () => { + dragDepthRef.current = 0; + setIsDragging(false); + }; + + window.addEventListener("dragenter", handleDragEnter); + window.addEventListener("dragleave", handleDragLeave); + window.addEventListener("drop", handleDragEnd); + window.addEventListener("dragend", handleDragEnd); + return () => { + window.removeEventListener("dragenter", handleDragEnter); + window.removeEventListener("dragleave", handleDragLeave); + window.removeEventListener("drop", handleDragEnd); + window.removeEventListener("dragend", handleDragEnd); + }; + }, [isInteractionDisabled, isPasswordStage, mode]); + const openFilePicker = React.useCallback(() => { if (isInteractionDisabled) { return; @@ -194,12 +240,27 @@ export function NostrKeyImportForm({ return (
{ + if (mode !== "backup" || isPasswordStage) return; + event.preventDefault(); + if (!isInteractionDisabled) { + event.dataTransfer.dropEffect = "copy"; + } + }} + onDrop={(event) => { + if (mode !== "backup" || isPasswordStage) return; + event.preventDefault(); + setIsDragging(false); + if (!isInteractionDisabled) { + void handleFiles(event.dataTransfer.files); + } + }} onSubmit={(event) => { event.preventDefault(); void handleSubmit(); }} > - {!isPasswordStage ? ( + {!isPasswordStage && mode === "key" ? (
+ {isDragging ? ( +
+ + +
+ ) : null} + + ) : null} + + {!isPasswordStage && mode === "key" && variant !== "spotlight" ? ( +
+ {mode === "key" || isPasswordStage ? ( + + ) : null} - - + {showBack || isPasswordStage ? ( + + ) : null} +
); } diff --git a/desktop/src/shared/api/tauri.ts b/desktop/src/shared/api/tauri.ts index 67ba582a1d..6e29f77c14 100644 --- a/desktop/src/shared/api/tauri.ts +++ b/desktop/src/shared/api/tauri.ts @@ -1126,8 +1126,6 @@ export async function nip44DecryptFromSelf( return invokeTauri("nip44_decrypt_from_self", { ciphertext }); } -// ── NIP-AB device pairing ─────────────────────────────────────────────────── - export async function startPairing(): Promise { return invokeTauri("start_pairing"); } diff --git a/desktop/src/shared/api/tauriPairing.ts b/desktop/src/shared/api/tauriPairing.ts new file mode 100644 index 0000000000..6fdf779446 --- /dev/null +++ b/desktop/src/shared/api/tauriPairing.ts @@ -0,0 +1,5 @@ +import { invokeTauri } from "@/shared/api/tauri"; + +export async function startIdentityRecoveryPairing(): Promise { + return invokeTauri("start_identity_recovery_pairing"); +} diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 1987e00ff1..abf74078da 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -12763,9 +12763,20 @@ export function maybeInstallE2eTauriMocks() { } return "nostrpair://8f4b8db31967ce14fef970a1ff1e8eecf19a430aa1c83875e2f5be68dcac0f1a?relay=wss%3A%2F%2Frelay.example.com&secret=87d5a8cfd5807a0cb44f728b67d88d6dcb8daf99be137c158f21a50c1e913c0a&v=1"; } + case "start_identity_recovery_pairing": { + const delayMs = activeConfig?.mock?.pairingStartDelayMs ?? 0; + if (delayMs > 0) { + await new Promise((resolve) => window.setTimeout(resolve, delayMs)); + } + return `nostrpair://8f4b8db31967ce14fef970a1ff1e8eecf19a430aa1c83875e2f5be68dcac0f1a?relay=wss%3A%2F%2Frelay.example.com&secret=87d5a8cfd5807a0cb44f728b67d88d6dcb8daf99be137c158f21a50c1e913c0a&v=1&mode=recover`; + } case "cancel_pairing": case "confirm_pairing_sas": return null; + case "complete_identity_recovery_pairing": + mockIdentityLostCleared = true; + await emit("pairing-complete", {}); + return null; // ── NIP-IA identity archival ──────────────────────────────────────── // These mocks drive the archive-button gate matrix in // tests/e2e/identity-archive.spec.ts. Defaults keep the button hidden diff --git a/desktop/tests/e2e/identity-lost.spec.ts b/desktop/tests/e2e/identity-lost.spec.ts index 71c663ab4f..2ab41cb52d 100644 --- a/desktop/tests/e2e/identity-lost.spec.ts +++ b/desktop/tests/e2e/identity-lost.spec.ts @@ -52,7 +52,7 @@ test("normal first launch uses the already-persisted identity", async ({ test("lost boot opens onboarding gate directly on the key-import page", async ({ page, -}) => { +}, testInfo) => { await installMockBridge( page, { identityLost: true }, @@ -62,13 +62,44 @@ test("lost boot opens onboarding gate directly on the key-import page", async ({ await expect(page.getByTestId("machine-onboarding-gate")).toBeVisible(); await expect( - page.getByRole("heading", { name: "Re-import your key" }), + page.getByRole("heading", { name: "Enter your private key" }), ).toBeVisible(); + await page.waitForTimeout(1_000); + await page.screenshot({ + path: testInfo.outputPath("desktop-private-key-recovery.png"), + }); }); -test("importing a key from lost mode shows the relaunch-required screen", async ({ +test("lost boot keeps the pairing-code action stable while generating", async ({ page, }) => { + await installMockBridge( + page, + { identityLost: true, pairingStartDelayMs: 2_500 }, + { skipOnboardingSeed: true }, + ); + await page.goto("/"); + + await page.getByTestId("nostr-import-phone-link").click(); + const copyButton = page.getByTestId("copy-identity-recovery-code"); + await expect(copyButton).toBeVisible(); + await expect(copyButton).toBeDisabled(); + await expect(copyButton).toHaveText("Generating pairing code..."); + const loadingButton = await copyButton.elementHandle(); + + await expect(copyButton).toBeEnabled(); + await expect(copyButton).toHaveText("Copy pairing code"); + expect( + await copyButton.evaluate( + (button, loading) => button === loading, + loadingButton, + ), + ).toBe(true); +}); + +test("lost boot offers phone recovery with a single-use QR", async ({ + page, +}, testInfo) => { await installMockBridge( page, { identityLost: true }, @@ -76,8 +107,254 @@ test("importing a key from lost mode shows the relaunch-required screen", async ); await page.goto("/"); + await page.getByTestId("nostr-import-phone-link").click(); + await expect(page.getByTestId("identity-recovery-pairing")).toBeVisible(); + await expect(page.getByTestId("identity-recovery-qr")).toBeVisible(); await expect( - page.getByRole("heading", { name: "Re-import your key" }), + page.getByText("Scan this code with a signed-in Buzz phone."), + ).toBeVisible(); + await expect( + page.getByText("On your phone, open Settings → Send identity to desktop."), + ).toBeVisible(); + await page.waitForTimeout(1_000); // Let the onboarding entrance motion settle. + await page.screenshot({ + path: testInfo.outputPath("desktop-phone-recovery-qr.png"), + fullPage: true, + }); + + const copyButton = page.getByTestId("copy-identity-recovery-code"); + await expect(copyButton).toHaveText("Copy pairing code"); + await page.context().grantPermissions(["clipboard-read", "clipboard-write"]); + await copyButton.click(); + await expect(copyButton).toHaveText("Copied"); + + const copiedPayload = await page.evaluate(() => { + const log = ( + window as Window & { + __BUZZ_E2E_COMMAND_LOG__?: Array<{ + command: string; + payload: Record | null; + }>; + } + ).__BUZZ_E2E_COMMAND_LOG__; + return log?.findLast(({ command }) => command === "copy_text_to_clipboard") + ?.payload; + }); + expect(copiedPayload?.text).toMatch(/^nostrpair:\/\/.+&mode=recover$/); + + const commands = await page.evaluate( + () => + ( + window as Window & { + __BUZZ_E2E_COMMAND_PAYLOADS__?: Array<{ command: string }>; + } + ).__BUZZ_E2E_COMMAND_PAYLOADS__ ?? [], + ); + expect( + commands.some( + (entry) => entry.command === "start_identity_recovery_pairing", + ), + ).toBe(true); +}); + +test("phone recovery uses the desktop pairing card semantics", async ({ + page, +}) => { + await installMockBridge( + page, + { identityLost: true }, + { skipOnboardingSeed: true }, + ); + await page.goto("/"); + + await page.getByTestId("nostr-import-phone-link").click(); + const card = page.getByTestId("identity-recovery-pairing"); + const qrContainer = card.getByTestId("identity-recovery-qr-container"); + const qrCode = card.getByTestId("identity-recovery-qr"); + const copyButton = card.getByTestId("copy-identity-recovery-code"); + await expect(qrCode).toBeVisible(); + await expect(qrCode).toHaveAttribute("data-qr-matrix-size", "57"); + await expect(qrCode.locator("[data-qr-finder-pattern]")).toHaveCount(3); + await expect(qrCode.locator(".buzz-qr-cell-reveal").first()).toHaveCSS( + "animation-name", + "buzz-qr-cell-reveal", + ); + const qrBox = await qrContainer.boundingBox(); + const copyBox = await copyButton.boundingBox(); + expect(qrBox).not.toBeNull(); + expect(copyBox).not.toBeNull(); + expect(Math.abs((copyBox?.x ?? 0) - (qrBox?.x ?? 0))).toBeLessThan(1); + expect(Math.abs((copyBox?.width ?? 0) - (qrBox?.width ?? 0))).toBeLessThan(1); + + await page.evaluate(async () => { + await window.__TAURI_INTERNALS__?.invoke?.("plugin:event|emit", { + event: "pairing-sas-received", + payload: { sas: "123456" }, + }); + }); + + await expect( + card.getByText("Does this code match your phone?"), + ).toBeVisible(); + await expect( + page.getByText("Confirm the code before sharing your identity."), + ).toBeVisible(); + await expect( + card.getByText( + "This gives this desktop permanent access to your Buzz identity. Only continue if you trust it.", + ), + ).toBeVisible(); + await expect( + card.getByText(/On your phone, open Settings/), + ).not.toBeVisible(); + await expect(card.getByTestId("identity-recovery-sas")).toHaveText("123 456"); + await expect(card.getByTestId("confirm-identity-recovery-sas")).toHaveText( + "Codes match", + ); + await expect(card.getByTestId("deny-identity-recovery-sas")).toHaveText( + "Cancel", + ); + const cancelBox = await card + .getByTestId("deny-identity-recovery-sas") + .boundingBox(); + const confirmBox = await card + .getByTestId("confirm-identity-recovery-sas") + .boundingBox(); + expect(cancelBox).not.toBeNull(); + expect(confirmBox).not.toBeNull(); + expect((cancelBox?.y ?? 0) - (confirmBox?.y ?? 0)).toBeGreaterThan( + confirmBox?.height ?? 0, + ); +}); + +test("canceling recovery uses the standard pairing cancellation state", async ({ + page, +}) => { + await installMockBridge( + page, + { identityLost: true }, + { skipOnboardingSeed: true }, + ); + await page.goto("/"); + await page.getByTestId("nostr-import-phone-link").click(); + await expect(page.getByTestId("identity-recovery-qr")).toBeVisible(); + + await page.evaluate(async () => { + await window.__TAURI_INTERNALS__?.invoke?.("plugin:event|emit", { + event: "pairing-sas-received", + payload: { sas: "123456" }, + }); + }); + await page.getByTestId("deny-identity-recovery-sas").click(); + + await expect( + page.getByText("The codes didn't match. Pairing was canceled."), + ).toBeVisible(); + await expect(page.getByRole("button", { name: "Try again" })).toBeVisible(); + await expect + .poll(() => + page.evaluate( + () => + (window.__BUZZ_E2E_COMMAND_LOG__ ?? []).filter( + ({ command }) => command === "cancel_pairing", + ).length, + ), + ) + .toBeGreaterThan(0); +}); + +test("phone recovery continues to harness setup without creating or restarting", async ({ + page, +}) => { + await installMockBridge( + page, + { identityLost: true }, + { skipOnboardingSeed: true }, + ); + await page.goto("/"); + await page.getByTestId("nostr-import-phone-link").click(); + await expect(page.getByTestId("identity-recovery-qr")).toBeVisible(); + + await page.evaluate(async () => { + await window.__TAURI_INTERNALS__?.invoke?.( + "complete_identity_recovery_pairing", + ); + }); + + await expect( + page.getByRole("heading", { name: "Set up your agent harnesses" }), + ).toBeVisible(); + await expect(page.getByTestId("relaunch-required")).toHaveCount(0); + await expect( + page.getByRole("heading", { + name: "Your unique identity key has been created", + }), + ).toHaveCount(0); +}); + +test("recovery turns relay failures into actionable copy", async ({ page }) => { + await installMockBridge( + page, + { identityLost: true }, + { skipOnboardingSeed: true }, + ); + await page.goto("/"); + await page.getByTestId("nostr-import-phone-link").click(); + await expect(page.getByTestId("identity-recovery-qr")).toBeVisible(); + + await page.evaluate(async () => { + await window.__TAURI_INTERNALS__?.invoke?.("plugin:event|emit", { + event: "pairing-error", + payload: { message: "failed to send sas-confirm" }, + }); + }); + + await expect( + page.getByText( + "This pairing code expired or lost its connection. Create a new code and try again.", + ), + ).toBeVisible(); + await expect(page.getByRole("button", { name: "Try again" })).toBeVisible(); +}); + +test("desktop refreshes recovery codes before the relay expires them", async ({ + page, +}) => { + await page.clock.install(); + await installMockBridge( + page, + { identityLost: true }, + { skipOnboardingSeed: true }, + ); + await page.goto("/"); + await page.getByTestId("nostr-import-phone-link").click(); + await expect(page.getByTestId("identity-recovery-qr")).toBeVisible(); + + const recoveryStarts = () => + page.evaluate( + () => + (window.__BUZZ_E2E_COMMAND_LOG__ ?? []).filter( + ({ command }) => command === "start_identity_recovery_pairing", + ).length, + ); + await expect.poll(recoveryStarts).toBe(1); + + await page.clock.fastForward(90_000); + await expect.poll(recoveryStarts).toBe(2); + await expect(page.getByTestId("identity-recovery-qr")).toBeVisible(); +}); + +test("importing a key from lost mode shows the relaunch-required screen", async ({ + page, +}) => { + await installMockBridge( + page, + { identityLost: true }, + { skipOnboardingSeed: true }, + ); + await page.goto("/"); + await expect( + page.getByRole("heading", { name: "Enter your private key" }), ).toBeVisible(); const importedNsec = nsecEncode(hexToBytes(TEST_IDENTITIES.alice.privateKey)); @@ -97,9 +374,8 @@ test("start-new-identity from lost mode persists the ephemeral key after confirm { skipOnboardingSeed: true }, ); await page.goto("/"); - await expect( - page.getByRole("heading", { name: "Re-import your key" }), + page.getByRole("heading", { name: "Enter your private key" }), ).toBeVisible(); page.on("dialog", (dialog) => dialog.accept()); @@ -131,9 +407,8 @@ test("cancelling start-new-identity in lost mode stays on the import screen", as { skipOnboardingSeed: true }, ); await page.goto("/"); - await expect( - page.getByRole("heading", { name: "Re-import your key" }), + page.getByRole("heading", { name: "Enter your private key" }), ).toBeVisible(); page.on("dialog", (dialog) => dialog.dismiss()); @@ -141,7 +416,7 @@ test("cancelling start-new-identity in lost mode stays on the import screen", as // Still on the import screen — no navigation, no persist await expect( - page.getByRole("heading", { name: "Re-import your key" }), + page.getByRole("heading", { name: "Enter your private key" }), ).toBeVisible(); await expect(page.getByTestId("relaunch-required")).toHaveCount(0); }); @@ -159,7 +434,7 @@ test("locked boot shows the keyring-locked screen without the onboarding gate or await expect(page.getByTestId("keyring-locked")).toBeVisible(); await expect(page.getByTestId("onboarding-gate")).toHaveCount(0); await expect( - page.getByRole("heading", { name: "Re-import your key" }), + page.getByRole("heading", { name: "Enter your private key" }), ).toHaveCount(0); }); diff --git a/desktop/tests/e2e/onboarding.spec.ts b/desktop/tests/e2e/onboarding.spec.ts index 57e812091c..0f2e8ce617 100644 --- a/desktop/tests/e2e/onboarding.spec.ts +++ b/desktop/tests/e2e/onboarding.spec.ts @@ -620,6 +620,99 @@ test("completed users skip the loading gate while profile is still settling", as await expectHomeView(page); }); +test("fresh existing-identity path leads with private-key recovery", async ({ + page, +}) => { + await installMockBridge(page, undefined, { + skipCommunitySeed: true, + skipOnboardingSeed: true, + }); + await page.goto("/"); + + await page.getByRole("button", { name: "Use an existing key" }).click(); + await expect( + page.getByRole("heading", { name: "Enter your private key" }), + ).toBeVisible(); + await expect( + page.getByText("Paste your private key to sign in to Buzz."), + ).toBeVisible(); + await expect(page.getByTestId("nostr-import-card")).toBeVisible(); + await expect(page.getByTestId("nostr-import-file-button")).toHaveText( + "backup file", + ); + await expect(page.getByTestId("nostr-import-phone-link")).toHaveText( + "recover from your phone", + ); + await expect(page.getByTestId("identity-recovery-pairing")).toHaveCount(0); + + await page.getByTestId("nostr-import-file-button").click(); + const backupDialog = page.getByTestId("backup-recovery-dialog"); + await expect(backupDialog).toBeVisible(); + await expect( + backupDialog.getByRole("heading", { name: "Restore from a backup file" }), + ).toBeVisible(); + await expect( + backupDialog.getByTestId("nostr-import-backup-picker"), + ).toBeVisible(); + const unlockPreview = backupDialog.getByTestId("backup-file-unlock-preview"); + await expect(unlockPreview).toBeVisible(); + await expect(unlockPreview.locator("span")).toHaveCount(17); + await expect( + unlockPreview.getByTestId("backup-file-key-dots").locator("span"), + ).toHaveCount(9); + await expect( + unlockPreview.getByTestId("backup-file-unlock-preview-icon"), + ).toBeVisible(); + await expect( + backupDialog.getByTestId("nostr-import-backup-drop"), + ).toHaveCount(0); + await backupDialog + .getByTestId("nostr-import-backup-picker") + .evaluate((element) => { + const dataTransfer = new DataTransfer(); + dataTransfer.items.add( + new File(["backup"], "identity.ncryptsec", { type: "text/plain" }), + ); + element.dispatchEvent( + new DragEvent("dragenter", { + bubbles: true, + cancelable: true, + dataTransfer, + }), + ); + }); + const backupDrop = backupDialog.getByTestId("nostr-import-backup-drop"); + await expect(backupDrop).toHaveAttribute("data-dragging", "true"); + await expect(backupDrop).toContainText("Drop your backup file here"); + const [backupDropBox, backupFileSectionBox] = await Promise.all([ + backupDrop.boundingBox(), + unlockPreview.boundingBox(), + ]); + expect(backupDropBox?.width).toBeGreaterThan( + backupFileSectionBox?.width ?? 0, + ); + await expect( + backupDialog.getByTestId("nostr-import-backup-picker"), + ).toBeVisible(); + await backupDrop.evaluate((element) => { + element.dispatchEvent( + new DragEvent("dragleave", { bubbles: true, cancelable: true }), + ); + }); + await expect(backupDrop).toHaveCount(0); + await expect(page.getByTestId("nostr-import-card")).toBeVisible(); + await backupDialog.getByRole("button", { name: "Close" }).click(); + + await page.getByTestId("nostr-import-phone-link").click(); + const phoneDialog = page.getByTestId("phone-recovery-dialog"); + await expect(phoneDialog).toBeVisible(); + await expect( + phoneDialog.getByRole("heading", { name: "Use your Buzz identity" }), + ).toBeVisible(); + await expect(phoneDialog.getByTestId("identity-recovery-qr")).toBeVisible(); + await expect(page.getByTestId("nostr-import-card")).toBeVisible(); +}); + test("first-launch key import continues to machine setup", async ({ page }) => { await installMockBridge(page, undefined, { skipCommunitySeed: true, @@ -707,8 +800,10 @@ test("first-launch import accepts an .ncryptsec backup file", async ({ // exactly the identity.ncryptsec our own save dialog produced. The accept // attribute is asserted explicitly because setInputFiles bypasses it — the // OS picker is what filters on it in real use. - await expect(page.getByTestId("nostr-import-file-button")).toBeVisible(); - const fileInput = page.getByTestId("nostr-import-file-input"); + await page.getByTestId("nostr-import-file-button").click(); + const fileInput = page + .getByTestId("backup-recovery-dialog") + .getByTestId("nostr-import-file-input"); await expect(fileInput).toHaveAttribute( "accept", ".key,.ncryptsec,text/plain", @@ -719,44 +814,87 @@ test("first-launch import accepts an .ncryptsec backup file", async ({ mimeType: "text/plain", name: "not-a-backup.txt", }); - await expect(page.getByTestId("nostr-import-feedback")).toContainText( - /too large to be a key backup/i, - ); + await expect( + page + .getByTestId("backup-recovery-dialog") + .getByTestId("nostr-import-feedback"), + ).toContainText(/too large to be a key backup/i); // Spec-vector blob the mock bridge accepts with the mock passphrase. const mockNcryptsec = "ncryptsec1qgg9947rlpvqu76pj5ecreduf9jxhselq2nae2kghhvd5g7dgjtcxfqtd67p9m0w57lspw8gsq6yphnm8623nsl8xn9j4jdzz84zm3frztj3z7s35vpzmqf6ksu8r89qk5z2zxfmu5gv8th8wclt0h4p"; - await fileInput.setInputFiles({ - buffer: Buffer.from(`${mockNcryptsec}\n`), - mimeType: "text/plain", - name: "identity.ncryptsec", + // File contents advance to the password stage inside the same dialog. + const backupDialog = page.getByTestId("backup-recovery-dialog"); + const backupFileSection = backupDialog.getByTestId( + "nostr-import-backup-file-section", + ); + const backupFileSectionHeight = await backupFileSection.evaluate((element) => + Number.parseFloat(getComputedStyle(element).height), + ); + expect(backupFileSectionHeight).toBe(312); + const backupPicker = backupDialog.getByTestId("nostr-import-backup-picker"); + await backupPicker.evaluate((element) => { + const dataTransfer = new DataTransfer(); + dataTransfer.items.add( + new File(["backup"], "identity.ncryptsec", { type: "text/plain" }), + ); + element.dispatchEvent( + new DragEvent("dragenter", { + bubbles: true, + cancelable: true, + dataTransfer, + }), + ); }); + const backupDrop = backupDialog.getByTestId("nostr-import-backup-drop"); + await expect(backupDrop).toBeVisible(); + await backupDrop.evaluate((element, contents) => { + const dataTransfer = new DataTransfer(); + dataTransfer.items.add( + new File([contents], "identity.ncryptsec", { type: "text/plain" }), + ); + element.dispatchEvent( + new DragEvent("drop", { + bubbles: true, + cancelable: true, + dataTransfer, + }), + ); + }, `${mockNcryptsec}\n`); - // File contents advance to the same focused password stage as manual input. await expect( - page.getByRole("heading", { name: "Unlock your account" }), + backupDialog.getByTestId("backup-password-timeline"), ).toBeVisible(); - await expect(page.getByTestId("backup-password-timeline")).toBeVisible(); - await expect(page.getByTestId("nostr-import-passphrase")).toBeFocused(); + const passphraseSection = backupDialog.getByTestId( + "nostr-import-passphrase-section", + ); + await expect(passphraseSection).toBeVisible(); + const passphraseSectionHeight = await passphraseSection.evaluate((element) => + Number.parseFloat(getComputedStyle(element).height), + ); + expect(passphraseSectionHeight).toBe(backupFileSectionHeight); + await expect( + backupDialog.getByTestId("nostr-import-passphrase"), + ).toBeFocused(); - // Back first returns to key/file selection instead of leaving import. - await page.getByRole("button", { name: "Back", exact: true }).click(); + // Back first returns to backup-file selection instead of closing the dialog. + await backupDialog.getByRole("button", { name: "Back", exact: true }).click(); await expect( - page.getByRole("heading", { name: "Enter your private key" }), + backupDialog.getByRole("heading", { name: "Restore from a backup file" }), + ).toBeVisible(); + await expect( + backupDialog.getByTestId("nostr-import-backup-picker"), ).toBeVisible(); - await expect(page.getByTestId("nostr-import-card")).toBeVisible(); - await expect(page.getByTestId("nostr-import-file-button")).toBeVisible(); - await expect(page.getByTestId("nostr-import-nsec-input")).toHaveValue(""); await fileInput.setInputFiles({ buffer: Buffer.from(`${mockNcryptsec}\n`), mimeType: "text/plain", name: "identity.ncryptsec", }); - await page + await backupDialog .getByTestId("nostr-import-passphrase") .fill("mock horse battery staple lake orbit"); - await page.getByTestId("nostr-import-submit").click(); + await backupDialog.getByTestId("nostr-import-submit").click(); await expect(page.getByTestId("onboarding-page-2")).toBeVisible(); await expect(page.getByTestId("machine-onboarding-gate")).toBeVisible(); diff --git a/mobile/lib/app.dart b/mobile/lib/app.dart index 057594dfad..d5ae326afa 100644 --- a/mobile/lib/app.dart +++ b/mobile/lib/app.dart @@ -147,8 +147,11 @@ class App extends HookConsumerWidget { } } -Widget _buildSettingsPage(BuildContext context) => - const SettingsPage(profileHeader: SettingsProfileHeader()); +Widget _buildSettingsPage(BuildContext context) => SettingsPage( + profileHeader: const SettingsProfileHeader(), + identityRecoveryPageBuilder: (_) => + const PairingPage(addingCommunity: true, identityRecoveryOnly: true), +); class _SplashScreen extends StatelessWidget { const _SplashScreen(); diff --git a/mobile/lib/features/pairing/pairing_page.dart b/mobile/lib/features/pairing/pairing_page.dart index 85b781052d..7061180b12 100644 --- a/mobile/lib/features/pairing/pairing_page.dart +++ b/mobile/lib/features/pairing/pairing_page.dart @@ -25,8 +25,13 @@ class PairingPage extends HookConsumerWidget { /// When true, the pairing page is being used to add a new community /// (user is already authenticated with at least one community). final bool addingCommunity; + final bool identityRecoveryOnly; - const PairingPage({super.key, this.addingCommunity = false}); + const PairingPage({ + super.key, + this.addingCommunity = false, + this.identityRecoveryOnly = false, + }); @override Widget build(BuildContext context, WidgetRef ref) { @@ -51,6 +56,13 @@ class PairingPage extends HookConsumerWidget { Future handleScannerResult(String? code) async { if (code != null && context.mounted) { + if (identityRecoveryOnly && + Uri.tryParse(code)?.queryParameters['mode'] != 'recover') { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Scan a desktop recovery code.')), + ); + return; + } await ref.read(pairingProvider.notifier).pair(code); } } @@ -91,7 +103,7 @@ class PairingPage extends HookConsumerWidget { onPressed: () => Navigator.of(context).pop(), ), title: Text( - 'Add Community', + identityRecoveryOnly ? 'Send to Desktop' : 'Add Community', style: isVerifyingSas ? null : context.textTheme.titleMedium?.copyWith( @@ -114,6 +126,7 @@ class PairingPage extends HookConsumerWidget { child: _SasVerificationView( sasCode: pairingState.sasCode ?? '------', confirmed: pairingState.userConfirmedSas, + sendsIdentityToDesktop: pairingState.sendsIdentityToDesktop, onConfirm: () => ref.read(pairingProvider.notifier).confirmSas(), onDeny: () => ref.read(pairingProvider.notifier).denySas(), @@ -146,7 +159,7 @@ class PairingPage extends HookConsumerWidget { onConnect: () { final code = codeController.text.trim(); if (code.isNotEmpty) { - ref.read(pairingProvider.notifier).pair(code); + unawaited(handleScannerResult(code)); } }, ), @@ -182,12 +195,14 @@ class PairingPage extends HookConsumerWidget { class _SasVerificationView extends StatelessWidget { final String sasCode; final bool confirmed; + final bool sendsIdentityToDesktop; final VoidCallback onConfirm; final VoidCallback onDeny; const _SasVerificationView({ required this.sasCode, required this.confirmed, + required this.sendsIdentityToDesktop, required this.onConfirm, required this.onDeny, }); @@ -242,7 +257,9 @@ class _SasVerificationView extends StatelessWidget { const SizedBox(height: Grid.lg), Text( - 'You are about to transfer your Buzz identity\nto this device. Only confirm if you initiated\nthis pairing from your desktop.', + sendsIdentityToDesktop + ? 'This sends your full Buzz identity to the desktop\nand grants it permanent access. Only confirm a\ndesktop you trust and a recovery you started.' + : 'You are about to transfer your Buzz identity\nto this device. Only confirm if you initiated\nthis pairing from your desktop.', textAlign: TextAlign.center, style: context.textTheme.bodySmall?.copyWith( color: context.colors.onSurfaceVariant, diff --git a/mobile/lib/features/pairing/pairing_provider.dart b/mobile/lib/features/pairing/pairing_provider.dart index 6a6c57a673..5adde987eb 100644 --- a/mobile/lib/features/pairing/pairing_provider.dart +++ b/mobile/lib/features/pairing/pairing_provider.dart @@ -36,12 +36,14 @@ class PairingState { final String? errorMessage; final String? sasCode; final bool userConfirmedSas; + final bool sendsIdentityToDesktop; const PairingState({ this.status = PairingStatus.idle, this.errorMessage, this.sasCode, this.userConfirmedSas = false, + this.sendsIdentityToDesktop = false, }); PairingState copyWith({ @@ -49,11 +51,14 @@ class PairingState { String? errorMessage, String? sasCode, bool? userConfirmedSas, + bool? sendsIdentityToDesktop, }) => PairingState( status: status ?? this.status, errorMessage: errorMessage ?? this.errorMessage, sasCode: sasCode ?? this.sasCode, userConfirmedSas: userConfirmedSas ?? this.userConfirmedSas, + sendsIdentityToDesktop: + sendsIdentityToDesktop ?? this.sendsIdentityToDesktop, ); } @@ -111,10 +116,14 @@ class PairingNotifier extends Notifier { // transition immediately and process any buffered payload. if (_sasConfirmReceived) { state = state.copyWith(status: PairingStatus.transferring); - final pending = _pendingPayload; - if (pending != null) { - _pendingPayload = null; - _handlePayload(pending); + if (_sendIdentityToSource) { + _sendIdentityPayload(); + } else { + final pending = _pendingPayload; + if (pending != null) { + _pendingPayload = null; + _handlePayload(pending); + } } return; } @@ -149,6 +158,7 @@ class PairingNotifier extends Notifier { _sasConfirmReceived = false; _userConfirmedSas = false; _pendingPayload = null; + _sendIdentityToSource = false; } // ── NIP-AB pairing flow ───────────────────────────────────────────────── @@ -163,6 +173,7 @@ class PairingNotifier extends Notifier { Uint8List? _conversationKey; bool _sasConfirmReceived = false; bool _userConfirmedSas = false; + bool _sendIdentityToSource = false; Map? _pendingPayload; // buffered until user confirms SAS final Set _processedEventIds = {}; // NIP-AB §Duplicate Event Handling @@ -174,6 +185,8 @@ class PairingNotifier extends Notifier { final qr = parseNostrpairUri(uri); _sourcePubkey = qr.sourcePubkey; _sessionSecret = qr.sessionSecret; + _sendIdentityToSource = + Uri.parse(uri).queryParameters['mode'] == 'recover'; final relayWsUrl = qr.relays.first; @@ -234,6 +247,7 @@ class PairingNotifier extends Notifier { state = PairingState( status: PairingStatus.confirmingSas, sasCode: formatSas(sasCode), + sendsIdentityToDesktop: _sendIdentityToSource, ); // 9. Start 120s session timeout. @@ -359,6 +373,9 @@ class PairingNotifier extends Notifier { case 'abort': _handleAbort(msg); _processedEventIds.add(eventId); + case 'complete': + _handleComplete(msg); + _processedEventIds.add(eventId); } } catch (e) { // Silently discard invalid events per NIP-AB §Event Validation. @@ -400,15 +417,44 @@ class PairingNotifier extends Notifier { if (_userConfirmedSas) { _userConfirmedSas = false; state = state.copyWith(status: PairingStatus.transferring); - final pending = _pendingPayload; - if (pending != null) { - _pendingPayload = null; - _handlePayload(pending); + if (_sendIdentityToSource) { + _sendIdentityPayload(); + } else { + final pending = _pendingPayload; + if (pending != null) { + _pendingPayload = null; + _handlePayload(pending); + } } } // Otherwise stay in confirmingSas — user must still confirm via confirmSas(). } + void _sendIdentityPayload() { + final nsec = ref.read(relayConfigProvider).nsec; + if (nsec == null || nsec.isEmpty) { + _sendAbort('protocol_error'); + _cleanup(); + state = const PairingState( + status: PairingStatus.error, + errorMessage: 'No identity is available on this phone.', + ); + return; + } + final content = _encryptMessage({ + 'type': 'payload', + 'payload_type': 'nsec', + 'payload': nsec, + }); + _publishEvent( + kind: 24134, + content: content, + tags: [ + ['p', _sourcePubkey!], + ], + ); + } + void _handlePayload(Map msg) { // Only accept payload after the transcript hash was verified. if (!_sasConfirmReceived) return; @@ -436,6 +482,22 @@ class PairingNotifier extends Notifier { _processPayload(payloadType, payload); } + void _handleComplete(Map msg) { + if (!_sendIdentityToSource || state.status != PairingStatus.transferring) { + return; + } + if (msg['success'] != true) { + _cleanup(); + state = const PairingState( + status: PairingStatus.error, + errorMessage: 'Desktop could not store the identity.', + ); + return; + } + _cleanup(); + state = const PairingState(status: PairingStatus.success); + } + void _handleAbort(Map msg) { final reason = msg['reason'] as String? ?? 'unknown'; _cleanup(); diff --git a/mobile/lib/features/settings/settings_page.dart b/mobile/lib/features/settings/settings_page.dart index d53f39a672..066dd1fd3d 100644 --- a/mobile/lib/features/settings/settings_page.dart +++ b/mobile/lib/features/settings/settings_page.dart @@ -24,9 +24,14 @@ part 'settings_page/appearance_section.dart'; part 'settings_page/connection_section.dart'; class SettingsPage extends HookConsumerWidget { - const SettingsPage({super.key, required this.profileHeader}); + const SettingsPage({ + super.key, + required this.profileHeader, + required this.identityRecoveryPageBuilder, + }); final Widget profileHeader; + final WidgetBuilder identityRecoveryPageBuilder; @override Widget build(BuildContext context, WidgetRef ref) { @@ -67,7 +72,9 @@ class SettingsPage extends HookConsumerWidget { children: [ profileHeader, const _AppearanceSection(), - const _ConnectionSection(), + _ConnectionSection( + identityRecoveryPageBuilder: identityRecoveryPageBuilder, + ), const _RemoveCommunitySection(), ], ), diff --git a/mobile/lib/features/settings/settings_page/connection_section.dart b/mobile/lib/features/settings/settings_page/connection_section.dart index 19da784a63..631f870abc 100644 --- a/mobile/lib/features/settings/settings_page/connection_section.dart +++ b/mobile/lib/features/settings/settings_page/connection_section.dart @@ -1,7 +1,9 @@ part of '../settings_page.dart'; class _ConnectionSection extends ConsumerWidget { - const _ConnectionSection(); + const _ConnectionSection({required this.identityRecoveryPageBuilder}); + + final WidgetBuilder identityRecoveryPageBuilder; @override Widget build(BuildContext context, WidgetRef ref) { @@ -16,7 +18,18 @@ class _ConnectionSection extends ConsumerWidget { title: 'Connected to', subtitle: config.baseUrl, ), - if (nsec != null && nsec.isNotEmpty) _IdentityRow(nsec: nsec), + if (nsec != null && nsec.isNotEmpty) ...[ + _IdentityRow(nsec: nsec), + AppListRow( + icon: LucideIcons.scanQrCode, + title: 'Send identity to desktop', + subtitle: 'Scan a recovery code shown by Buzz Desktop', + trailing: const _RowChevron(), + onTap: () => Navigator.of(context).push( + MaterialPageRoute(builder: identityRecoveryPageBuilder), + ), + ), + ], ], ); } diff --git a/mobile/test/features/pairing/pairing_page_test.dart b/mobile/test/features/pairing/pairing_page_test.dart index 678be9dfe7..e8f34a6f71 100644 --- a/mobile/test/features/pairing/pairing_page_test.dart +++ b/mobile/test/features/pairing/pairing_page_test.dart @@ -179,6 +179,69 @@ void main() { expect(scanButton.onPressed, isNull); expect(pairingCodeButton.onPressed, isNull); }); + + testWidgets('recovery entry rejects ordinary nostrpair codes', ( + tester, + ) async { + final notifier = _RecordingPairingNotifier(); + await tester.pumpWidget( + WidgetHelpers.testable( + overrides: [pairingProvider.overrideWith(() => notifier)], + child: const PairingPage( + addingCommunity: true, + identityRecoveryOnly: true, + ), + ), + ); + + await _expandPairingCode(tester); + await tester.enterText(find.byType(TextField), 'nostrpair://ordinary'); + await tester.tap(find.text('Connect')); + await tester.pump(); + + expect(find.text('Scan a desktop recovery code.'), findsOneWidget); + expect(notifier.pairedCodes, isEmpty); + }); + + testWidgets('recovery entry accepts mode=recover codes', (tester) async { + final notifier = _RecordingPairingNotifier(); + await tester.pumpWidget( + WidgetHelpers.testable( + overrides: [pairingProvider.overrideWith(() => notifier)], + child: const PairingPage( + addingCommunity: true, + identityRecoveryOnly: true, + ), + ), + ); + + await _expandPairingCode(tester); + const code = 'nostrpair://desktop?mode=recover'; + await tester.enterText(find.byType(TextField), code); + await tester.tap(find.text('Connect')); + await tester.pump(); + + expect(notifier.pairedCodes, [code]); + }); + + testWidgets('recovery SAS warns about permanent desktop access', ( + tester, + ) async { + await tester.pumpWidget( + ProviderScope( + overrides: [ + pairingProvider.overrideWith( + () => _ConfirmingSasPairingNotifier(sendsIdentityToDesktop: true), + ), + ], + child: MaterialApp(theme: AppTheme.dark(), home: const PairingPage()), + ), + ); + + expect(find.textContaining('full Buzz identity'), findsOneWidget); + expect(find.textContaining('permanent access'), findsOneWidget); + expect(find.text('Codes Match'), findsOneWidget); + }); }); } @@ -227,12 +290,37 @@ class _ConnectingPairingNotifier extends Notifier void denySas() {} } +class _RecordingPairingNotifier extends Notifier + implements PairingNotifier { + final pairedCodes = []; + + @override + PairingState build() => const PairingState(); + + @override + Future pair(String rawInput) async => pairedCodes.add(rawInput); + + @override + void reset() {} + + @override + void confirmSas() {} + + @override + void denySas() {} +} + class _ConfirmingSasPairingNotifier extends Notifier implements PairingNotifier { + _ConfirmingSasPairingNotifier({this.sendsIdentityToDesktop = false}); + + final bool sendsIdentityToDesktop; + @override - PairingState build() => const PairingState( + PairingState build() => PairingState( status: PairingStatus.confirmingSas, sasCode: '123456', + sendsIdentityToDesktop: sendsIdentityToDesktop, ); @override diff --git a/mobile/test/features/pairing/pairing_provider_test.dart b/mobile/test/features/pairing/pairing_provider_test.dart index 6f49f71921..c14599bbef 100644 --- a/mobile/test/features/pairing/pairing_provider_test.dart +++ b/mobile/test/features/pairing/pairing_provider_test.dart @@ -2,9 +2,14 @@ import 'dart:convert'; import 'package:flutter_test/flutter_test.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:nostr/nostr.dart' as nostr; +import 'package:buzz/features/pairing/pairing_crypto.dart'; import 'package:buzz/features/pairing/pairing_provider.dart'; import 'package:buzz/features/pairing/pairing_socket.dart'; import 'package:buzz/shared/auth/auth.dart'; +import 'package:buzz/shared/crypto/ecdh.dart'; +import 'package:buzz/shared/crypto/nip44.dart'; +import 'package:buzz/shared/relay/relay.dart'; /// Tests for [PairingNotifier]'s legacy `buzz://` payload parsing and /// SSRF-prevention validation. @@ -180,6 +185,115 @@ void main() { container.read(pairingProvider.notifier).reset(); expect(container.read(pairingProvider).status, PairingStatus.idle); }); + + group('desktop identity recovery', () { + const sourceSecret = + '09b3065e3570a3a4054660dccd66e12774a99a904fdb0ca02dbc6c3136249506'; + const sessionSecretHex = + 'abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789'; + late _ControllableSocket socket; + late PairingNotifier notifier; + late String recoveryCode; + + setUp(() { + final source = nostr.Keys(sourceSecret); + recoveryCode = + 'nostrpair://${source.public}' + '?secret=$sessionSecretHex' + '&relay=wss%3A%2F%2Fpairing.buzz.xyz&v=1&mode=recover'; + notifier = PairingNotifier( + socketFactory: + ({ + required wsUrl, + required ephemeralPrivkey, + required onMessage, + required onDisconnected, + }) { + socket = _ControllableSocket( + ephemeralPrivkey: ephemeralPrivkey, + onMessage: onMessage, + onDisconnected: onDisconnected, + ); + return socket; + }, + ); + container = ProviderContainer( + overrides: [ + pairingProvider.overrideWith(() => notifier), + relayConfigProvider.overrideWith(_RecoveryRelayConfig.new), + ], + ); + container.read(pairingProvider); + notifier = container.read(pairingProvider.notifier); + }); + + test('recovery URI enables phone-to-desktop transfer', () async { + await notifier.pair(recoveryCode); + + final state = container.read(pairingProvider); + expect(state.status, PairingStatus.confirmingSas); + expect(state.sendsIdentityToDesktop, isTrue); + expect(state.sasCode, hasLength(6)); + }); + + test( + 'matching SAS sends nsec and successful completion finishes', + () async { + await notifier.pair(recoveryCode); + notifier.confirmSas(); + expect(container.read(pairingProvider).userConfirmedSas, isTrue); + + socket.sendSourceMessage( + sourceSecret: sourceSecret, + sessionSecretHex: sessionSecretHex, + message: {'type': 'sas-confirm'}, + includeTranscriptHash: true, + ); + + expect( + container.read(pairingProvider).status, + PairingStatus.transferring, + ); + final sentMessages = socket.decryptedPublishedMessages(sourceSecret); + expect( + sentMessages.any( + (message) => + message['type'] == 'payload' && + message['payload_type'] == 'nsec' && + message['payload'] == _RecoveryRelayConfig.nsec, + ), + isTrue, + ); + + socket.sendSourceMessage( + sourceSecret: sourceSecret, + sessionSecretHex: sessionSecretHex, + message: {'type': 'complete', 'success': true}, + ); + expect(container.read(pairingProvider).status, PairingStatus.success); + }, + ); + + test('desktop storage failure surfaces an error', () async { + await notifier.pair(recoveryCode); + notifier.confirmSas(); + socket.sendSourceMessage( + sourceSecret: sourceSecret, + sessionSecretHex: sessionSecretHex, + message: {'type': 'sas-confirm'}, + includeTranscriptHash: true, + ); + socket.sendSourceMessage( + sourceSecret: sourceSecret, + sessionSecretHex: sessionSecretHex, + message: {'type': 'complete', 'success': false}, + ); + + final state = container.read(pairingProvider); + expect(state.status, PairingStatus.error); + expect(state.errorMessage, contains('could not store')); + }); + }); }); } @@ -241,3 +355,92 @@ class _DisconnectingSocket extends PairingSocket { disconnectCallback(Exception('Connection closed')); } } + +class _RecoveryRelayConfig extends RelayConfigNotifier { + static final nsec = nostr.Keys( + '1111111111111111111111111111111111111111111111111111111111111111', + ).nsec; + + @override + RelayConfig build() => RelayConfig(baseUrl: 'https://relay.test', nsec: nsec); +} + +class _ControllableSocket extends PairingSocket { + final String ephemeralPrivkey; + final void Function(List message) relayMessageCallback; + final List> published = []; + bool _connected = false; + int _eventSequence = 0; + + _ControllableSocket({ + required this.ephemeralPrivkey, + required super.onMessage, + required super.onDisconnected, + }) : relayMessageCallback = onMessage, + super(wsUrl: 'ws://unused', ephemeralPrivkey: ephemeralPrivkey); + + @override + bool get isConnected => _connected; + + @override + Future connect() async => _connected = true; + + @override + void subscribe(String subId, int kind, String pubkeyHex) {} + + @override + void publishEvent(Map event) => published.add(event); + + @override + void dispose() => _connected = false; + + List> decryptedPublishedMessages(String sourceSecret) { + final key = getConversationKey( + sourceSecret, + nostr.Keys(ephemeralPrivkey).public, + ); + return published + .map( + (event) => + jsonDecode(nip44Decrypt(key, event['content'] as String)) + as Map, + ) + .toList(); + } + + void sendSourceMessage({ + required String sourceSecret, + required String sessionSecretHex, + required Map message, + bool includeTranscriptHash = false, + }) { + final source = nostr.Keys(sourceSecret); + final targetPubkey = nostr.Keys(ephemeralPrivkey).public; + final sessionSecret = hexToBytes(sessionSecretHex); + final body = Map.from(message); + if (includeTranscriptHash) { + final shared = ecdhSharedSecret(sourceSecret, targetPubkey); + final (_, sasInput) = deriveSas(shared, sessionSecret); + body['transcript_hash'] = bytesToHex( + deriveTranscriptHash( + deriveSessionId(sessionSecret), + hexToBytes(source.public), + hexToBytes(targetPubkey), + sasInput, + sessionSecret, + ), + ); + } + final key = getConversationKey(sourceSecret, targetPubkey); + final event = nostr.Event.from( + kind: 24134, + content: nip44Encrypt(key, jsonEncode(body)), + tags: [ + ['p', targetPubkey], + ], + secretKey: sourceSecret, + createdAt: 1_700_000_000 + _eventSequence++, + ); + relayMessageCallback(['EVENT', 'pair', event.toMap()]); + } +} diff --git a/mobile/test/features/settings/theme_picker_page_test.dart b/mobile/test/features/settings/theme_picker_page_test.dart index 010db98ba3..6b166c8efa 100644 --- a/mobile/test/features/settings/theme_picker_page_test.dart +++ b/mobile/test/features/settings/theme_picker_page_test.dart @@ -172,7 +172,10 @@ void main() { testWidgets('settings hides accent navigation for Buzz', (tester) async { await _pumpPicker( tester, - const SettingsPage(profileHeader: SizedBox.shrink()), + SettingsPage( + profileHeader: const SizedBox.shrink(), + identityRecoveryPageBuilder: (_) => const SizedBox.shrink(), + ), prefs: {'buzz_color_scheme': 'buzz', 'buzz_accent_color': 4}, ); @@ -184,7 +187,10 @@ void main() { ) async { await _pumpPicker( tester, - const SettingsPage(profileHeader: SizedBox.shrink()), + SettingsPage( + profileHeader: const SizedBox.shrink(), + identityRecoveryPageBuilder: (_) => const SizedBox.shrink(), + ), prefs: { 'buzz_theme_mode': 'light', 'buzz_color_scheme': 'github-light', From c777d4fb9af4c3f66009ee3216650d9ea30310d7 Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Thu, 6 Aug 2026 16:57:12 -0400 Subject: [PATCH 05/61] chore(hooks): run desktop typecheck in pre-push (#5110) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The local pre-push gate ran biome (`desktop-check`) and node:test (`desktop-test`) for desktop changes but never `tsc`, so TypeScript errors surface no earlier than CI's `desktop-core` job (`just desktop-build` = `tsc && vite build`). A branch with type errors passes every local hook today. This adds a `desktop-typecheck` pre-push command running `just desktop-typecheck` (`tsc --noEmit`) with the same glob/exclude as `desktop-check`, and updates the hook documentation in `AGENTS.md`. CI is unchanged — it already typechecks via `desktop-build`. Signed-off-by: Will Pfleger --- AGENTS.md | 9 +++++---- lefthook.yml | 11 ++++++++--- 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 571871c3a4..2d3939bbb3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -100,10 +100,11 @@ Run `just test` for integration tests if you touched `buzz-relay`, formatting via `stage_fixed`. Pre-commit runs fix variants in parallel (Rust fmt, Tauri Rust fmt, desktop biome fix, web biome fix, mobile dart format). Auto-fixable issues are fixed and re-staged; unfixable lint issues block the -commit. **Pre-push hooks** run clippy (workspace + Tauri) and fast unit tests -in parallel (Rust, desktop JS, Tauri Rust, mobile Flutter) — no overlap with -pre-commit. Builds are CI-only. Run `just fix-all` to auto-fix all formatting -in one shot. Run `just ci` for the full local gate. Run `just hooks` to +commit. **Pre-push hooks** run clippy (workspace + Tauri), desktop TypeScript +typechecking (`tsc --noEmit`), and fast unit tests in parallel (Rust, desktop +JS, Tauri Rust, mobile Flutter) — no overlap with pre-commit. Builds are +CI-only. Run `just fix-all` to auto-fix all formatting in one shot. Run +`just ci` for the full local gate. Run `just hooks` to re-install hooks after env changes. Before agents run Git or hooks, activate the repo's Hermit environment (`. ./bin/activate-hermit`); do not rewrite hook commands to compensate for an unconfigured shell `PATH`. diff --git a/lefthook.yml b/lefthook.yml index 75d205722f..5b992f19af 100644 --- a/lefthook.yml +++ b/lefthook.yml @@ -2,9 +2,10 @@ # .github/workflows/ci.yml — keep the two in sync. Deliberate deviations: # - The `.github/workflows/ci.yml` path CI adds to its `rust`/`mobile` filters # is omitted; a CI-workflow-only edit doesn't need a local test run. -# - `desktop-check`/`desktop-test` don't trigger on `rust` changes, though CI's -# Desktop Core job does. Those commands are pure TS (biome + node:test) with -# no Rust dependency, so the extra trigger would be spurious locally. +# - `desktop-check`/`desktop-typecheck`/`desktop-test` don't trigger on `rust` +# changes, though CI's Desktop Core job does. Those commands are pure TS +# (biome + tsc + node:test) with no Rust dependency, so the extra trigger +# would be spurious locally. # - Deletion-only surface changes do not trigger local hooks: lefthook 2.1.x # drops deleted paths from push-file discovery (`extractFiles` existence # check, repository.go). CI's dorny/paths-filter catches deletions. @@ -57,6 +58,10 @@ pre-push: glob: ["desktop/**", "pnpm-lock.yaml"] exclude: ["desktop/src-tauri/**"] run: just desktop-check + desktop-typecheck: + glob: ["desktop/**", "pnpm-lock.yaml"] + exclude: ["desktop/src-tauri/**"] + run: just desktop-typecheck desktop-test: glob: ["desktop/**", "pnpm-lock.yaml"] exclude: ["desktop/src-tauri/**"] From b08c8b126cee8de424eb0c03af22a45ff9a1e8a7 Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Thu, 6 Aug 2026 17:21:02 -0400 Subject: [PATCH 06/61] fix(desktop): prevent sidebar prefs from reverting on stale-localStorage boot (#5086) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes the bug where running a dev build with stale localStorage would publish outdated channel sections, sort preferences, starred channels, and muted channels to the relay, clobbering the DMG installation's live state. ## Root cause All four sidebar-preference sync managers (`channelSectionsSync`, `channelSortSync`, `channelStarsSync`, `channelMutesSync`) collapsed five distinct fetch outcomes — no event, timeout, error, auth-race empty result, decrypt/parse failure — into a single `null`. Each hook's boot effect treated `null` as "no remote exists" and seed-published whatever was in localStorage, stamped at `max(now, lastRemoteCreatedAt+1)` with `lastRemoteCreatedAt` reset to 0 on every boot. A dev build with stale localStorage therefore re-signed old state as newer, and the DMG's live subscription applied it. ## Two guards **1. Tri-state fetch result** (`found | absent | failed`) — decrypt failure on an existing event reports `failed` and records `event.created_at`, so seed-publish is blocked even when the payload is unreadable. **2. Persisted head watermark** (`sidebarSyncWatermark.ts`) — keyed `{blobType, pubkey, normalizedRelayUrl}`, written to localStorage on every observed remote event (before decrypt on all paths: initial fetch, live subscription, `fetchOwnBlobBeforePublish`), hydrated at construction. Any session that has ever seen a remote blob skips seed-publish on the next boot even when the fetch returns empty. Relay URLs are normalised via `shared/lib/normalizeRelayUrl` (also used by profile storage) so the same relay written two ways never produces two keys. **Bootstrap owns the seed.** Each manager exposes `bootstrap(localStore)` that fetches, records the raw head, and delegates the decision to the single `runBootstrap` policy: hold on `failed` or `absent + prior watermark`, seed on genuine first-sync (`absent + zero watermark + non-empty local`), `apply-remote` when a blob was found. Hooks only act on `apply-remote`; they cannot publish during bootstrap. First-time sync is unchanged: successful EOSE with no event, zero watermark, and non-empty local state still seeds. ## LWW baseline preservation `fetchOwnBlobBeforePublish` for sections/sort snapshots the watermark before `recordRemoteHead` advances it, then compares the fetched event against the snapshot — advancing first would make `remote.createdAt > lastRemoteCreatedAt` always false and silently kill the whole-blob LWW merge. Stars/mutes merge per-entry via `mergeStores`, so no snapshot is needed there. ## Relay lifecycle All four hooks require a defined `relayUrl` (plumbed from `communitiesHook.activeCommunity?.relayUrl` in `AppShell.tsx`); while it is undefined no manager is constructed and no boot/live/reconnect effect binds. All effects depend on `[pubkey, relayUrl]`, so community switches tear down and rebind. `destroy()` cancels pending publishes without flushing — flushing would race community switching and could publish relay A's state to relay B via the shared `relayClient` singleton. Pending debounce-window edits are intentionally dropped: stars/mutes entries survive via per-entry merge on the next publish; a dropped sections/sort edit is lost because bootstrap whole-blob-replaces from remote on return. Known trade-off: a first boot with the relay unreachable holds (never seeds) until the user's next explicit edit — preferred over risking a stale seed-publish. ## Files - `sidebarSyncWatermark.ts` — watermark persistence + `runBootstrap` policy (tri-state `FetchResult`, `readWatermark`, `advanceWatermark`) - `shared/lib/normalizeRelayUrl.ts` — relay-URL normalisation shared by watermark keys and profile storage - `channelSectionsSync.ts`, `channelSortSync.ts`, `channelStarsSync.ts`, `channelMutesSync.ts` — tri-state fetch, pre-decrypt `recordRemoteHead` on all paths, sections/sort watermark snapshot for LWW, `bootstrap()`, cancel-without-flush `destroy()` - `useChannelSections.ts`, `useChannelSortPreference.ts`, `useChannelStars.ts`, `useChannelMutes.ts` — act on `bootstrap()` results, gate on `relayUrl`, `[pubkey, relayUrl]` deps on all effects - `AppShell.tsx` — passes `activeCommunity?.relayUrl` to `useChannelMutes` and `useChannelStars` - `sidebarSyncTestHelpers.mjs` — shared fake-window/localStorage/Tauri mocks for the four manager suites - Test suites — mutation-sensitive coverage: `failed→hold`, `absent+watermark→hold`, first-sync seeds, undecryptable head recorded on all paths, relay-A/B watermark isolation, watermark restart round-trip, sections/sort LWW baseline --------- Signed-off-by: Will Pfleger Co-authored-by: Duncan --- desktop/src/app/AppShell.tsx | 2 + .../profile/lib/selfProfileStorage.ts | 12 +- .../sidebar/lib/channelMutesSync.test.mjs | 198 ++++++++++ .../features/sidebar/lib/channelMutesSync.ts | 107 +++-- .../sidebar/lib/channelSectionsStorage.ts | 2 +- .../sidebar/lib/channelSectionsSync.test.mjs | 364 +++++++++++------- .../sidebar/lib/channelSectionsSync.ts | 104 +++-- .../sidebar/lib/channelSortPreference.ts | 2 +- .../sidebar/lib/channelSortSync.test.mjs | 326 ++++++++++------ .../features/sidebar/lib/channelSortSync.ts | 98 +++-- .../sidebar/lib/channelStarsSync.test.mjs | 202 ++++++++++ .../features/sidebar/lib/channelStarsSync.ts | 107 +++-- .../sidebar/lib/sidebarSyncTestHelpers.mjs | 85 ++++ .../sidebar/lib/sidebarSyncWatermark.test.mjs | 253 ++++++++++++ .../sidebar/lib/sidebarSyncWatermark.ts | 135 +++++++ .../features/sidebar/lib/useChannelMutes.ts | 40 +- .../sidebar/lib/useChannelSections.ts | 26 +- .../sidebar/lib/useChannelSortPreference.ts | 25 +- .../features/sidebar/lib/useChannelStars.ts | 40 +- desktop/src/shared/lib/normalizeRelayUrl.ts | 8 + 20 files changed, 1665 insertions(+), 471 deletions(-) create mode 100644 desktop/src/features/sidebar/lib/channelMutesSync.test.mjs create mode 100644 desktop/src/features/sidebar/lib/channelStarsSync.test.mjs create mode 100644 desktop/src/features/sidebar/lib/sidebarSyncTestHelpers.mjs create mode 100644 desktop/src/features/sidebar/lib/sidebarSyncWatermark.test.mjs create mode 100644 desktop/src/features/sidebar/lib/sidebarSyncWatermark.ts create mode 100644 desktop/src/shared/lib/normalizeRelayUrl.ts diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx index f765b843b3..147ab57381 100644 --- a/desktop/src/app/AppShell.tsx +++ b/desktop/src/app/AppShell.tsx @@ -173,9 +173,11 @@ export function AppShell() { const identityQuery = useIdentityQuery(); const { mutedChannelIds, muteChannel, unmuteChannel } = useChannelMutes( identityQuery.data?.pubkey, + communitiesHook.activeCommunity?.relayUrl, ); const { starredChannelIds, starChannel, unstarChannel } = useChannelStars( identityQuery.data?.pubkey, + communitiesHook.activeCommunity?.relayUrl, ); usePersonaSync( identityQuery.data?.pubkey, diff --git a/desktop/src/features/profile/lib/selfProfileStorage.ts b/desktop/src/features/profile/lib/selfProfileStorage.ts index dbc4f88760..02e083ae1d 100644 --- a/desktop/src/features/profile/lib/selfProfileStorage.ts +++ b/desktop/src/features/profile/lib/selfProfileStorage.ts @@ -11,16 +11,10 @@ * prevents one community's cached identity from bleeding into another. */ -const STORAGE_KEY_PREFIX = "buzz-self-profile.v1"; +export { normalizeRelayUrl } from "@/shared/lib/normalizeRelayUrl"; +import { normalizeRelayUrl } from "@/shared/lib/normalizeRelayUrl"; -/** - * Normalizes a relay URL for use in storage keys. - * Trim, strip trailing slashes, lowercase — ensures equivalent URLs map to - * the same key regardless of formatting differences. - */ -export function normalizeRelayUrl(relayUrl: string): string { - return relayUrl.trim().replace(/\/+$/, "").toLowerCase(); -} +const STORAGE_KEY_PREFIX = "buzz-self-profile.v1"; /** * Dispatched on window after a successful writeSelfProfileCache so that any diff --git a/desktop/src/features/sidebar/lib/channelMutesSync.test.mjs b/desktop/src/features/sidebar/lib/channelMutesSync.test.mjs new file mode 100644 index 0000000000..845e5a5acc --- /dev/null +++ b/desktop/src/features/sidebar/lib/channelMutesSync.test.mjs @@ -0,0 +1,198 @@ +import assert from "node:assert/strict"; +import test, { mock } from "node:test"; + +import { relayClient } from "@/shared/api/relayClient"; +import { ChannelMuteSyncManager } from "./channelMutesSync.ts"; +import { + makeFakeWindow, + installFakeWindow, +} from "./sidebarSyncTestHelpers.mjs"; + +const RELAY = "wss://r.test"; +const RELAY_KEY = encodeURIComponent(RELAY); + +function makeStore(channels = {}) { + return { version: 1, channels }; +} + +// ─── destroy() must cancel pending publish, not flush ───────────────────────── + +// Regression guard for the community-switch cross-relay publish vector: +// mute a channel in relay A → destroy() called (relayUrl dep change) → +// no publish should fire. +test("destroy: cancels pending publish without flushing to the relay", () => { + const publishCalls = []; + mock.method(relayClient, "fetchEvents", () => Promise.resolve([])); + mock.method(relayClient, "publishEvent", (...args) => { + publishCalls.push(args); + return Promise.resolve(); + }); + const fw = makeFakeWindow(); + const restore = installFakeWindow(fw); + try { + const manager = new ChannelMuteSyncManager("pk-test", RELAY); + manager.publishMutes(makeStore({ ch1: { muted: true, updatedAt: 100 } })); + manager.destroy(); + assert.equal(publishCalls.length, 0); + assert.equal(manager.getPendingMuteStore(), null); + } finally { + restore(); + mock.reset(); + } +}); + +test("destroy: aborts in-flight doPublish after fetchOwnBlobBeforePublish resolves", async () => { + let releaseFetch = null; + const publishCalls = []; + mock.method( + relayClient, + "fetchEvents", + () => + new Promise((res) => { + releaseFetch = () => res([]); + }), + ); + mock.method(relayClient, "publishEvent", (...args) => { + publishCalls.push(args); + return Promise.resolve(); + }); + const fw = makeFakeWindow(); + const restore = installFakeWindow(fw); + try { + const manager = new ChannelMuteSyncManager("pk-race", RELAY); + manager.publishMutes(makeStore({ ch1: { muted: true, updatedAt: 100 } })); + fw._fireTimer(); + manager.destroy(); + releaseFetch(); + await new Promise((r) => setTimeout(r, 0)); + assert.equal(publishCalls.length, 0); + } finally { + restore(); + mock.reset(); + } +}); + +test("destroy: is safe to call with no pending publish", () => { + const fw = makeFakeWindow(); + const restore = installFakeWindow(fw); + try { + const manager = new ChannelMuteSyncManager("pk-no-pending", RELAY); + assert.doesNotThrow(() => manager.destroy()); + } finally { + restore(); + } +}); + +// ─── Boot seed-publish guard (the revert-fix regression suite) ───────────────── + +// 1. fetch failed → hold, pendingStore null (mutation: remove failed guard → seed queued) +test("revert-fix: fetch failed (error) does not trigger seed-publish via bootstrap", async () => { + mock.method(relayClient, "fetchEvents", () => + Promise.reject(new Error("relay timeout")), + ); + mock.method(relayClient, "publishEvent", () => Promise.resolve()); + const fw = makeFakeWindow(); + const restore = installFakeWindow(fw); + try { + const manager = new ChannelMuteSyncManager("pk-fail", RELAY); + const result = await manager.bootstrap( + makeStore({ ch1: { muted: true, updatedAt: 1 } }), + ); + assert.equal(result.action, "hold"); + assert.equal(manager.getPendingMuteStore(), null); + } finally { + restore(); + mock.reset(); + } +}); + +// 2. absent + prior watermark → hold, pendingStore null (mutation: clear watermark → seed queued) +test("revert-fix: absent fetch with prior watermark blocks seed-publish via bootstrap", async () => { + mock.method(relayClient, "fetchEvents", () => Promise.resolve([])); + mock.method(relayClient, "publishEvent", () => Promise.resolve()); + const fw = makeFakeWindow(); + fw.localStorage.setItem( + `buzz-sync-watermark.v1:channel-mutes:pk-stale:${RELAY_KEY}`, + "1700000000", + ); + const restore = installFakeWindow(fw); + try { + const manager = new ChannelMuteSyncManager("pk-stale", RELAY); + assert.ok( + Number( + fw.localStorage.getItem( + `buzz-sync-watermark.v1:channel-mutes:pk-stale:${RELAY_KEY}`, + ) ?? "0", + ) > 0, + ); + const result = await manager.bootstrap( + makeStore({ ch1: { muted: true, updatedAt: 1 } }), + ); + assert.equal(result.action, "hold"); + assert.equal(manager.getPendingMuteStore(), null); + } finally { + restore(); + mock.reset(); + } +}); + +// 3. absent + zero watermark + non-empty → seed queued (mutation: remove seed call → pendingStore null) +test("revert-fix: absent fetch with zero watermark seeds via bootstrap (first-sync preserved)", async () => { + mock.method(relayClient, "fetchEvents", () => Promise.resolve([])); + mock.method(relayClient, "publishEvent", () => Promise.resolve()); + const fw = makeFakeWindow(); + const restore = installFakeWindow(fw); + try { + const manager = new ChannelMuteSyncManager("pk-fresh", RELAY); + assert.equal( + fw.localStorage.getItem( + `buzz-sync-watermark.v1:channel-mutes:pk-fresh:${RELAY_KEY}`, + ), + null, + ); + const result = await manager.bootstrap( + makeStore({ ch1: { muted: true, updatedAt: 1 } }), + ); + assert.equal(result.action, "hold"); + assert.ok(manager.getPendingMuteStore() !== null); + } finally { + restore(); + mock.reset(); + } +}); + +// 4. relay-A / relay-B watermark isolation +// Mutation: using pubkey-only key (no relay) makes relay A's head suppress relay B's first-sync. +test("revert-fix: relay-A watermark does not suppress first-sync seed on relay-B", async () => { + const relayA = "wss://a.relay.test"; + const relayB = "wss://b.relay.test"; + mock.method(relayClient, "fetchEvents", () => Promise.resolve([])); + mock.method(relayClient, "publishEvent", () => Promise.resolve()); + const fw = makeFakeWindow(); + fw.localStorage.setItem( + `buzz-sync-watermark.v1:channel-mutes:pk-iso:${encodeURIComponent(relayA)}`, + "1700000100", + ); + const restore = installFakeWindow(fw); + try { + const managerB = new ChannelMuteSyncManager("pk-iso", relayB); + assert.equal( + fw.localStorage.getItem( + `buzz-sync-watermark.v1:channel-mutes:pk-iso:${encodeURIComponent(relayB)}`, + ), + null, + "relay B watermark must be independent of relay A head", + ); + const result = await managerB.bootstrap( + makeStore({ ch1: { muted: true, updatedAt: 1 } }), + ); + assert.equal(result.action, "hold"); + assert.ok( + managerB.getPendingMuteStore() !== null, + "first-sync seed on relay B must not be blocked by relay A watermark", + ); + } finally { + restore(); + mock.reset(); + } +}); diff --git a/desktop/src/features/sidebar/lib/channelMutesSync.ts b/desktop/src/features/sidebar/lib/channelMutesSync.ts index 0a0d2bb9f6..5e8a17e74d 100644 --- a/desktop/src/features/sidebar/lib/channelMutesSync.ts +++ b/desktop/src/features/sidebar/lib/channelMutesSync.ts @@ -11,8 +11,15 @@ import { parseMutePayload, type ChannelMuteStore, } from "./channelMutesStorage"; +import { + advanceWatermark, + readWatermark, + runBootstrap, + type FetchResult, +} from "./sidebarSyncWatermark"; const D_TAG = "channel-mutes"; +const BLOB_TYPE = D_TAG; const DEBOUNCE_MS = 2_000; export type RemoteMutes = { @@ -34,16 +41,20 @@ async function decryptAndParse(event: RelayEvent): Promise { export class ChannelMuteSyncManager { private pubkey: string; + private relayUrl: string; private debounceTimer: number | null = null; - private lastRemoteCreatedAt = 0; + private lastRemoteCreatedAt: number; private pendingStore: ChannelMuteStore | null = null; private lastPublishedStore: ChannelMuteStore | null = null; + private destroyed = false; - constructor(pubkey: string) { + constructor(pubkey: string, relayUrl: string) { this.pubkey = pubkey; + this.relayUrl = relayUrl; + this.lastRemoteCreatedAt = readWatermark(pubkey, BLOB_TYPE, relayUrl); } - async fetchRemoteMutes(): Promise { + async fetchRemoteMutes(): Promise> { try { const events = await relayClient.fetchEvents({ kinds: [KIND_CHANNEL_MUTES], @@ -51,19 +62,31 @@ export class ChannelMuteSyncManager { "#d": [D_TAG], limit: 1, }); - if (events.length === 0) return null; - if (events[0].pubkey !== this.pubkey) return null; - const result = await decryptAndParse(events[0]); - if (result) { - this.lastRemoteCreatedAt = Math.max( - this.lastRemoteCreatedAt, - result.createdAt, - ); + if (events.length === 0 || events[0].pubkey !== this.pubkey) { + return { status: "absent" }; + } + const event = events[0]; + this.recordRemoteHead(event.created_at); + const result = await decryptAndParse(event); + if (!result) { + return { status: "failed", createdAt: event.created_at }; } - return result; + return { + status: "found", + data: result, + createdAt: result.createdAt, + eventId: result.eventId, + }; } catch { - return null; + return { status: "failed" }; + } + } + + private recordRemoteHead(createdAt: number): void { + if (createdAt > this.lastRemoteCreatedAt) { + this.lastRemoteCreatedAt = createdAt; } + advanceWatermark(this.pubkey, BLOB_TYPE, this.relayUrl, createdAt); } cancelPendingMutePublish(): void { @@ -99,12 +122,11 @@ export class ChannelMuteSyncManager { limit: 1, }); if (events.length === 0 || events[0].pubkey !== this.pubkey) return store; - const remote = await decryptAndParse(events[0]); + const event = events[0]; + // Record the raw head before decrypt on the pre-publish path too. + this.recordRemoteHead(event.created_at); + const remote = await decryptAndParse(event); if (!remote) return store; - this.lastRemoteCreatedAt = Math.max( - this.lastRemoteCreatedAt, - remote.createdAt, - ); return mergeStores(store, remote.store); } catch { return store; @@ -132,6 +154,10 @@ export class ChannelMuteSyncManager { private async doPublish(store: ChannelMuteStore): Promise { try { const merged = await this.fetchOwnBlobBeforePublish(store); + // Guard: manager may have been destroyed while fetchOwnBlobBeforePublish + // was awaited (community switch during in-flight fetch). If so, abort + // before touching the relay. + if (this.destroyed) return; if (this.isIdenticalToLastPublished(merged)) { this.pendingStore = null; return; @@ -154,15 +180,13 @@ export class ChannelMuteSyncManager { ["t", D_TAG], // relay discoverability; not used in our filters ], }); + if (this.destroyed) return; await relayClient.publishEvent( event, "Timed out publishing channel mutes.", "Failed to publish channel mutes.", ); - this.lastRemoteCreatedAt = Math.max( - this.lastRemoteCreatedAt, - event.created_at, - ); + this.recordRemoteHead(event.created_at); this.lastPublishedStore = merged; this.pendingStore = null; } catch (error) { @@ -182,12 +206,11 @@ export class ChannelMuteSyncManager { }, (event: RelayEvent) => { if (event.pubkey !== this.pubkey) return; + // Record the raw head before decrypt so an undecryptable live event + // still advances the watermark and blocks future seed-publish. + this.recordRemoteHead(event.created_at); void decryptAndParse(event).then((result) => { if (result) { - this.lastRemoteCreatedAt = Math.max( - this.lastRemoteCreatedAt, - result.createdAt, - ); onUpdate(result); } }); @@ -195,14 +218,30 @@ export class ChannelMuteSyncManager { ); } + /** + * Fetches the remote blob on first mount, records the remote head, and + * delegates the seed/hold/apply-remote decision to `runBootstrap`. + */ + async bootstrap(localStore: ChannelMuteStore) { + const fetchResult = await this.fetchRemoteMutes(); + return runBootstrap({ + fetchResult, + lastHead: this.lastRemoteCreatedAt, + localStore, + isLocalNonEmpty: (s) => Object.keys(s.channels).length > 0, + publishFn: (s) => this.publishMutes(s), + }); + } + destroy(): void { - if (this.debounceTimer !== null && this.pendingStore !== null) { - window.clearTimeout(this.debounceTimer); - this.debounceTimer = null; - void this.doPublish(this.pendingStore); - } else if (this.debounceTimer !== null) { - window.clearTimeout(this.debounceTimer); - this.debounceTimer = null; - } + // Cancel any pending publish and mark this manager as destroyed so any + // in-flight doPublish() calls abort before reaching relayClient. + // Pending debounce-window changes are intentionally dropped: flushing + // could publish relay A's state to relay B via the shared relayClient + // singleton. Local entries survive because the apply/publish paths merge + // per-entry via mergeStores, so no local work is permanently lost. + this.destroyed = true; + this.cancelPendingMutePublish(); + this.pendingStore = null; } } diff --git a/desktop/src/features/sidebar/lib/channelSectionsStorage.ts b/desktop/src/features/sidebar/lib/channelSectionsStorage.ts index 0d6b5768b6..3900c40c18 100644 --- a/desktop/src/features/sidebar/lib/channelSectionsStorage.ts +++ b/desktop/src/features/sidebar/lib/channelSectionsStorage.ts @@ -1,4 +1,4 @@ -import { normalizeRelayUrl } from "@/features/profile/lib/selfProfileStorage"; +import { normalizeRelayUrl } from "@/shared/lib/normalizeRelayUrl"; const STORAGE_KEY_PREFIX = "buzz-channel-sections.v1"; diff --git a/desktop/src/features/sidebar/lib/channelSectionsSync.test.mjs b/desktop/src/features/sidebar/lib/channelSectionsSync.test.mjs index 5dad6c8673..904ac1f3f2 100644 --- a/desktop/src/features/sidebar/lib/channelSectionsSync.test.mjs +++ b/desktop/src/features/sidebar/lib/channelSectionsSync.test.mjs @@ -3,6 +3,11 @@ import test, { mock } from "node:test"; import { relayClient } from "@/shared/api/relayClient"; import { ChannelSectionSyncManager } from "./channelSectionsSync.ts"; +import { + makeFakeWindow, + installFakeWindow, + installTauriMock, +} from "./sidebarSyncTestHelpers.mjs"; function makeStore(overrides = {}) { return { @@ -13,198 +18,265 @@ function makeStore(overrides = {}) { }; } +function makeSectionsStore(sections = []) { + return { version: 1, sections, assignments: {} }; +} + +const RELAY = "wss://r.test"; +const RELAY_KEY = encodeURIComponent(RELAY); + // ─── destroy() must cancel pending publish, not flush ───────────────────────── // Regression guard for the community-switch cross-relay publish vector: // edit sections in relay A → destroy() is called (relayUrl dep change) → -// no publish should fire. The scoped localStorage write is durable; when the -// user returns to relay A the seed-publish path handles it. +// no publish should fire. test("destroy: cancels pending publish without flushing to the relay", () => { - const publishCalls = []; mock.method(relayClient, "fetchEvents", () => Promise.resolve([])); + const publishCalls = []; mock.method(relayClient, "publishEvent", (...args) => { publishCalls.push(args); return Promise.resolve(); }); - - // Simulate the timer scheduler with a manual clock so we can advance it. - let timerCallback = null; - const originalSetTimeout = globalThis.window?.setTimeout; - const originalClearTimeout = globalThis.window?.clearTimeout; - - // Inject a fake window.setTimeout/clearTimeout if needed. - const fakeTimers = []; - let nextId = 1; - if (typeof globalThis.window === "undefined") { - globalThis.window = {}; - } - globalThis.window.setTimeout = (fn, _ms) => { - const id = nextId++; - fakeTimers.push({ id, fn }); - timerCallback = fn; - return id; - }; - globalThis.window.clearTimeout = (id) => { - const idx = fakeTimers.findIndex((t) => t.id === id); - if (idx !== -1) { - fakeTimers.splice(idx, 1); - timerCallback = null; - } - }; - + const fw = makeFakeWindow(); + const restore = installFakeWindow(fw); try { - const manager = new ChannelSectionSyncManager("pk-test"); - const store = makeStore({ - sections: [{ id: "s1", name: "Work", order: 0 }], - }); - - // Queue a publish — this sets the debounce timer. - manager.publishSections(store); - assert.ok(timerCallback !== null, "debounce timer should be set"); - - // Destroy before the debounce fires — simulates community switch. - manager.destroy(); - - // Timer must be cleared and no publish should fire now. - assert.ok( - timerCallback === null, - "debounce timer should be cleared on destroy", - ); - - // Advance time by invoking the callback that was cleared — it shouldn't exist. - // If clearTimeout didn't work, try firing whatever was captured before destroy. - // (There's nothing to fire after a correct destroy.) - assert.equal( - publishCalls.length, - 0, - "no publish event should have been sent after destroy", + const manager = new ChannelSectionSyncManager("pk-test", RELAY); + manager.publishSections( + makeStore({ sections: [{ id: "s1", name: "Work", order: 0 }] }), ); + assert.ok(fw._hasTimer(), "debounce timer should be set"); + manager.destroy(); + assert.ok(!fw._hasTimer(), "debounce timer should be cleared on destroy"); + assert.equal(publishCalls.length, 0); + assert.equal(manager.getPendingStore(), null); } finally { - // Restore timer functions. - if (originalSetTimeout !== undefined) { - globalThis.window.setTimeout = originalSetTimeout; - } - if (originalClearTimeout !== undefined) { - globalThis.window.clearTimeout = originalClearTimeout; - } + restore(); mock.reset(); } }); -// Regression guard for the timer-fired race: debounce fires → doPublish starts -// awaiting fetchOwnBlobBeforePublish → destroy() is called (relayUrl dep -// change) → publishEvent must never be called even though the timer already -// fired and cleared itself before destroy() ran. +// Regression guard for the timer-fired race: debounce fires → doPublish awaits +// fetchOwnBlobBeforePublish → destroy() called → publishEvent must not fire. test("destroy: aborts in-flight doPublish after fetchOwnBlobBeforePublish resolves", async () => { - // fetchEvents is held until we release it — simulates the latency window. let releaseFetch = null; const publishCalls = []; - - mock.method(relayClient, "fetchEvents", () => { - return new Promise((resolve) => { - // resolve with empty so fetchOwnBlobBeforePublish returns the local store - releaseFetch = () => resolve([]); - }); - }); + mock.method( + relayClient, + "fetchEvents", + () => + new Promise((res) => { + releaseFetch = () => res([]); + }), + ); mock.method(relayClient, "publishEvent", (...args) => { publishCalls.push(args); return Promise.resolve(); }); - - if (typeof globalThis.window === "undefined") { - globalThis.window = {}; - } - let capturedCallback = null; - let nextId = 1; - const origSetTimeout = globalThis.window.setTimeout; - const origClearTimeout = globalThis.window.clearTimeout; - globalThis.window.setTimeout = (fn, _ms) => { - capturedCallback = fn; - return nextId++; - }; - globalThis.window.clearTimeout = (_id) => { - capturedCallback = null; - }; - + const fw = makeFakeWindow(); + const restore = installFakeWindow(fw); try { - const manager = new ChannelSectionSyncManager("pk-race"); - const store = makeStore({ - sections: [{ id: "s1", name: "Work", order: 0 }], - }); - - // Queue the publish — captures the debounce callback. - manager.publishSections(store); - assert.ok(capturedCallback !== null, "debounce timer should be set"); - - // Fire the debounce manually — this starts doPublish() and nulls - // debounceTimer inside publishSections' callback, leaving the async - // doPublish running and awaiting fetchOwnBlobBeforePublish. - const timerFn = capturedCallback; - capturedCallback = null; // timer cleared itself inside the callback - timerFn(); - - // Now destroy() — debounceTimer is already null (timer fired), so only - // the destroyed flag can stop doPublish. + const manager = new ChannelSectionSyncManager("pk-race", RELAY); + manager.publishSections( + makeStore({ sections: [{ id: "s1", name: "Work", order: 0 }] }), + ); + fw._fireTimer(); // starts doPublish, which is now awaiting fetchOwnBlobBeforePublish manager.destroy(); - - // Release the held fetchEvents — fetchOwnBlobBeforePublish resolves with - // the local store, then doPublish should check destroyed and abort. releaseFetch(); - - // Drain microtasks so doPublish fully runs through to its abort point. - await new Promise((resolve) => setTimeout(resolve, 0)); - + await new Promise((r) => setTimeout(r, 0)); assert.equal( publishCalls.length, 0, - "publishEvent must not be called after destroy() even when timer already fired", + "publishEvent must not fire after destroy", ); } finally { - globalThis.window.setTimeout = origSetTimeout; - globalThis.window.clearTimeout = origClearTimeout; + restore(); mock.reset(); } }); test("destroy: is safe to call with no pending publish", () => { - const manager = new ChannelSectionSyncManager("pk-no-pending"); - // Should not throw even with nothing queued. - assert.doesNotThrow(() => manager.destroy()); + const fw = makeFakeWindow(); + const restore = installFakeWindow(fw); + try { + const manager = new ChannelSectionSyncManager("pk-no-pending", RELAY); + assert.doesNotThrow(() => manager.destroy()); + } finally { + restore(); + } +}); + +// ─── Boot seed-publish guard (the revert-fix regression suite) ──────────────── +// Wiring tests 1-3 drive the production bootstrap() path; policy tested once +// in sidebarSyncWatermark.test.mjs. + +// 1. fetch failed → hold, pendingStore null (mutation: remove failed guard → seed queued) +test("revert-fix: fetch failed (error) does not trigger seed-publish via bootstrap", async () => { + mock.method(relayClient, "fetchEvents", () => + Promise.reject(new Error("relay timeout")), + ); + mock.method(relayClient, "publishEvent", () => Promise.resolve()); + const fw = makeFakeWindow(); + const restore = installFakeWindow(fw); + try { + const manager = new ChannelSectionSyncManager("pk-fail", RELAY); + const result = await manager.bootstrap( + makeSectionsStore([{ id: "s1", name: "Work", order: 0 }]), + ); + assert.equal(result.action, "hold"); + assert.equal(manager.getPendingStore(), null); + } finally { + restore(); + mock.reset(); + } }); -test("destroy: cancelPendingPublish clears pendingStore", () => { - let timerCallback = null; - let nextId = 1; - if (typeof globalThis.window === "undefined") { - globalThis.window = {}; +// 2. absent + prior watermark → hold, pendingStore null (mutation: clear watermark → seed queued) +test("revert-fix: absent fetch with prior watermark blocks seed-publish via bootstrap", async () => { + mock.method(relayClient, "fetchEvents", () => Promise.resolve([])); + mock.method(relayClient, "publishEvent", () => Promise.resolve()); + const fw = makeFakeWindow(); + fw.localStorage.setItem( + `buzz-sync-watermark.v1:channel-sections:pk-stale:${RELAY_KEY}`, + "1700000000", + ); + const restore = installFakeWindow(fw); + try { + const manager = new ChannelSectionSyncManager("pk-stale", RELAY); + assert.ok( + Number( + fw.localStorage.getItem( + `buzz-sync-watermark.v1:channel-sections:pk-stale:${RELAY_KEY}`, + ) ?? "0", + ) > 0, + ); + const result = await manager.bootstrap( + makeSectionsStore([{ id: "s1", name: "Work", order: 0 }]), + ); + assert.equal(result.action, "hold"); + assert.equal(manager.getPendingStore(), null); + } finally { + restore(); + mock.reset(); } - const orig = globalThis.window.setTimeout; - const origClear = globalThis.window.clearTimeout; - globalThis.window.setTimeout = (fn, _ms) => { - timerCallback = fn; - return nextId++; - }; - globalThis.window.clearTimeout = (_id) => { - timerCallback = null; - }; +}); +// 3. absent + zero watermark + non-empty → seed queued (mutation: remove seed call → pendingStore null) +test("revert-fix: absent fetch with zero watermark seeds via bootstrap (first-sync preserved)", async () => { + mock.method(relayClient, "fetchEvents", () => Promise.resolve([])); + mock.method(relayClient, "publishEvent", () => Promise.resolve()); + const fw = makeFakeWindow(); + const restore = installFakeWindow(fw); try { - const manager = new ChannelSectionSyncManager("pk-pending-null"); - const store = makeStore({ - sections: [{ id: "s1", name: "Test", order: 0 }], - }); - manager.publishSections(store); - assert.deepEqual(manager.getPendingStore(), store); + const manager = new ChannelSectionSyncManager("pk-fresh", RELAY); + const result = await manager.bootstrap( + makeSectionsStore([{ id: "s1", name: "Work", order: 0 }]), + ); + assert.equal(result.action, "hold"); + assert.ok(manager.getPendingStore() !== null); + } finally { + restore(); + mock.reset(); + } +}); - manager.destroy(); +// 4. LWW baseline: newer decryptable pre-publish event still wins after an +// undecryptable head was recorded. +// Mutation test: headBeforeFetch → this.lastRemoteCreatedAt makes comparison +// 200>200=false → local wins instead of remote → wrong content encrypted. +test("revert-fix: sections LWW — newer decryptable pre-publish event selected after undecryptable head recorded", async () => { + const REMOTE_ID = "remote-section-from-relay"; + let callCount = 0; + mock.method(relayClient, "fetchEvents", () => { + callCount++; + return Promise.resolve([ + { + pubkey: "pk-lww", + content: callCount === 1 ? "bad-cipher" : "good-cipher", + created_at: callCount === 1 ? 100 : 200, + id: `evt-${callCount}`, + }, + ]); + }); + mock.method(relayClient, "publishEvent", () => Promise.resolve()); + const fw = makeFakeWindow(); + const restore = installFakeWindow(fw); + const tauri = installTauriMock( + JSON.stringify({ + version: 1, + sections: [{ id: REMOTE_ID, name: "Remote", order: 0 }], + assignments: {}, + }), + ); + try { + const manager = new ChannelSectionSyncManager("pk-lww", RELAY); + await manager.fetchRemoteSections(); + assert.ok( + Number( + fw.localStorage.getItem( + `buzz-sync-watermark.v1:channel-sections:pk-lww:${RELAY_KEY}`, + ) ?? "0", + ) >= 100, + ); + manager.publishSections( + makeSectionsStore([{ id: "local-s", name: "Local", order: 0 }]), + ); + fw._fireTimer(); + await new Promise((r) => setTimeout(r, 20)); + const pt = tauri.capturedPlaintext(); + assert.ok(pt !== null, "nip44EncryptToSelf must have been called"); + assert.ok( + JSON.parse(pt).sections?.some((s) => s.id === REMOTE_ID), + `remote sections must win LWW merge — got: ${pt}`, + ); + } finally { + tauri.restore(); + restore(); + mock.reset(); + } +}); + +// 5. live-sub: undecryptable event on live path records head before decrypt +// Mutation test: removing recordRemoteHead before decrypt in the live callback +// leaves watermark at 0 after a live event. +test("revert-fix: undecryptable live event advances watermark before decrypt attempt", async () => { + let liveCallback = null; + mock.method(relayClient, "subscribeLive", (_filter, onEvent) => { + liveCallback = onEvent; + return Promise.resolve(async () => {}); + }); + const fw = makeFakeWindow(); + const restore = installFakeWindow(fw); + try { + const manager = new ChannelSectionSyncManager("pk-live", RELAY); assert.equal( - manager.getPendingStore(), + fw.localStorage.getItem( + `buzz-sync-watermark.v1:channel-sections:pk-live:${RELAY_KEY}`, + ), null, - "pendingStore must be null after destroy", + "watermark starts absent", + ); + await manager.subscribeToSections(() => {}); + assert.ok( + liveCallback !== null, + "subscribeLive must have captured the callback", + ); + liveCallback({ + pubkey: "pk-live", + content: "!bad-cipher!", + created_at: 1700005555, + id: "live-evt-1", + }); + await new Promise((r) => setTimeout(r, 0)); + assert.ok( + Number( + fw.localStorage.getItem( + `buzz-sync-watermark.v1:channel-sections:pk-live:${RELAY_KEY}`, + ) ?? "0", + ) >= 1700005555, + "live undecryptable event must advance the watermark before decrypt is attempted", ); - assert.ok(timerCallback === null, "timer must be cleared after destroy"); } finally { - globalThis.window.setTimeout = orig; - globalThis.window.clearTimeout = origClear; + restore(); + mock.reset(); } }); diff --git a/desktop/src/features/sidebar/lib/channelSectionsSync.ts b/desktop/src/features/sidebar/lib/channelSectionsSync.ts index 70930c26f6..858b62430f 100644 --- a/desktop/src/features/sidebar/lib/channelSectionsSync.ts +++ b/desktop/src/features/sidebar/lib/channelSectionsSync.ts @@ -11,8 +11,15 @@ import { type ChannelSection, type ChannelSectionStore, } from "./channelSectionsStorage"; +import { + advanceWatermark, + readWatermark, + runBootstrap, + type FetchResult, +} from "./sidebarSyncWatermark"; const D_TAG = "channel-sections"; +const BLOB_TYPE = D_TAG; const DEBOUNCE_MS = 2_000; export type RemoteSections = { @@ -36,17 +43,22 @@ async function decryptAndParse( export class ChannelSectionSyncManager { private pubkey: string; + private relayUrl: string; private debounceTimer: number | null = null; - private lastRemoteCreatedAt = 0; + private lastRemoteCreatedAt: number; private pendingStore: ChannelSectionStore | null = null; private lastPublishedStore: ChannelSectionStore | null = null; private destroyed = false; - constructor(pubkey: string) { + constructor(pubkey: string, relayUrl: string) { this.pubkey = pubkey; + this.relayUrl = relayUrl; + // Hydrate from localStorage so we never seed-publish if a remote blob has + // been seen in a prior session. + this.lastRemoteCreatedAt = readWatermark(pubkey, BLOB_TYPE, relayUrl); } - async fetchRemoteSections(): Promise { + async fetchRemoteSections(): Promise> { try { const events = await relayClient.fetchEvents({ kinds: [KIND_CHANNEL_SECTIONS], @@ -54,21 +66,37 @@ export class ChannelSectionSyncManager { "#d": [D_TAG], limit: 1, }); - if (events.length === 0) return null; - if (events[0].pubkey !== this.pubkey) return null; - const result = await decryptAndParse(events[0]); - if (result) { - this.lastRemoteCreatedAt = Math.max( - this.lastRemoteCreatedAt, - result.createdAt, - ); + if (events.length === 0 || events[0].pubkey !== this.pubkey) { + return { status: "absent" }; + } + const event = events[0]; + // An event exists — record its created_at regardless of whether we can + // decrypt it, so seed-publish is blocked even when the payload is + // unreadable (e.g. wrong key). + this.recordRemoteHead(event.created_at); + const result = await decryptAndParse(event); + if (!result) { + return { status: "failed", createdAt: event.created_at }; } - return result; + return { + status: "found", + data: result, + createdAt: result.createdAt, + eventId: result.eventId, + }; } catch { - return null; + return { status: "failed" }; } } + /** Update in-memory + persisted watermark. */ + private recordRemoteHead(createdAt: number): void { + if (createdAt > this.lastRemoteCreatedAt) { + this.lastRemoteCreatedAt = createdAt; + } + advanceWatermark(this.pubkey, BLOB_TYPE, this.relayUrl, createdAt); + } + cancelPendingPublish(): void { if (this.debounceTimer !== null) { window.clearTimeout(this.debounceTimer); @@ -102,11 +130,17 @@ export class ChannelSectionSyncManager { limit: 1, }); if (events.length === 0 || events[0].pubkey !== this.pubkey) return store; - const remote = await decryptAndParse(events[0]); + const event = events[0]; + // Snapshot the watermark before advancing it: after recordRemoteHead + // runs, lastRemoteCreatedAt equals event.created_at, so the LWW + // comparison remote.createdAt > lastRemoteCreatedAt would always be + // false and silently suppress the merge. + const headBeforeFetch = this.lastRemoteCreatedAt; + this.recordRemoteHead(event.created_at); + const remote = await decryptAndParse(event); if (!remote) return store; // Sections use whole-blob LWW: take whichever is newer - if (remote.createdAt > this.lastRemoteCreatedAt) { - this.lastRemoteCreatedAt = remote.createdAt; + if (remote.createdAt > headBeforeFetch) { return remote.store; } return store; @@ -181,10 +215,7 @@ export class ChannelSectionSyncManager { "Timed out publishing channel sections.", "Failed to publish channel sections.", ); - this.lastRemoteCreatedAt = Math.max( - this.lastRemoteCreatedAt, - event.created_at, - ); + this.recordRemoteHead(event.created_at); this.lastPublishedStore = merged; this.pendingStore = null; } catch (error) { @@ -204,12 +235,11 @@ export class ChannelSectionSyncManager { }, (event: RelayEvent) => { if (event.pubkey !== this.pubkey) return; + // Record the raw head before decrypt so an undecryptable live event + // still advances the watermark and blocks future seed-publish. + this.recordRemoteHead(event.created_at); void decryptAndParse(event).then((result) => { if (result) { - this.lastRemoteCreatedAt = Math.max( - this.lastRemoteCreatedAt, - result.createdAt, - ); onUpdate(result); } }); @@ -217,14 +247,28 @@ export class ChannelSectionSyncManager { ); } + /** + * Fetches the remote blob on first mount, records the remote head, and + * delegates the seed/hold/apply-remote decision to `runBootstrap`. + */ + async bootstrap(localStore: ChannelSectionStore) { + const fetchResult = await this.fetchRemoteSections(); + return runBootstrap({ + fetchResult, + lastHead: this.lastRemoteCreatedAt, + localStore, + isLocalNonEmpty: (s) => s.sections.length > 0, + publishFn: (s) => this.publishSections(s), + }); + } + destroy(): void { // Cancel any pending publish and mark this manager as destroyed so any - // in-flight doPublish() calls abort before reaching relayClient. The - // scoped localStorage write is already durable; when the user returns to - // this relay the existing seed-publish guard will re-publish from local - // state. Flushing here would race against community switching and could - // publish relay A's sections to relay B via the shared relayClient - // singleton. + // in-flight doPublish() calls abort before reaching relayClient. + // Pending debounce-window changes are intentionally dropped: flushing + // could publish relay A's sections to relay B via the shared relayClient + // singleton. On return, bootstrap's found path whole-blob-replaces from + // remote, so any dropped pending edit is lost. this.destroyed = true; this.cancelPendingPublish(); this.pendingStore = null; diff --git a/desktop/src/features/sidebar/lib/channelSortPreference.ts b/desktop/src/features/sidebar/lib/channelSortPreference.ts index 6bd9b48d7b..aa67ca3fb1 100644 --- a/desktop/src/features/sidebar/lib/channelSortPreference.ts +++ b/desktop/src/features/sidebar/lib/channelSortPreference.ts @@ -1,4 +1,4 @@ -import { normalizeRelayUrl } from "@/features/profile/lib/selfProfileStorage"; +import { normalizeRelayUrl } from "@/shared/lib/normalizeRelayUrl"; import type { Channel } from "@/shared/api/types"; const STORAGE_KEY_PREFIX = "buzz-channel-sort.v1"; diff --git a/desktop/src/features/sidebar/lib/channelSortSync.test.mjs b/desktop/src/features/sidebar/lib/channelSortSync.test.mjs index 76bf57b6c5..28159eedd3 100644 --- a/desktop/src/features/sidebar/lib/channelSortSync.test.mjs +++ b/desktop/src/features/sidebar/lib/channelSortSync.test.mjs @@ -3,174 +3,260 @@ import test, { mock } from "node:test"; import { relayClient } from "@/shared/api/relayClient"; import { ChannelSortSyncManager } from "./channelSortSync.ts"; +import { + makeFakeWindow, + installFakeWindow, + installTauriMock, +} from "./sidebarSyncTestHelpers.mjs"; function makeStore(groups = {}) { return { version: 1, groups }; } -// ─── destroy() must cancel pending publish, not flush ───────────────────────── +const RELAY = "wss://r.test"; +const RELAY_KEY = encodeURIComponent(RELAY); -// Regression guard for the community-switch cross-relay publish vector: -// change a sort mode in relay A → destroy() is called (relayUrl dep change) → -// no publish should fire. The scoped localStorage write is durable; when the -// user returns to relay A the seed-publish path handles it. +// ─── destroy() must cancel pending publish, not flush ───────────────────────── test("destroy: cancels pending publish without flushing to the relay", () => { - const publishCalls = []; mock.method(relayClient, "fetchEvents", () => Promise.resolve([])); + const publishCalls = []; mock.method(relayClient, "publishEvent", (...args) => { publishCalls.push(args); return Promise.resolve(); }); - - let timerCallback = null; - const fakeTimers = []; - let nextId = 1; - if (typeof globalThis.window === "undefined") { - globalThis.window = {}; - } - const originalSetTimeout = globalThis.window.setTimeout; - const originalClearTimeout = globalThis.window.clearTimeout; - globalThis.window.setTimeout = (fn, _ms) => { - const id = nextId++; - fakeTimers.push({ id, fn }); - timerCallback = fn; - return id; - }; - globalThis.window.clearTimeout = (id) => { - const idx = fakeTimers.findIndex((t) => t.id === id); - if (idx !== -1) { - fakeTimers.splice(idx, 1); - timerCallback = null; - } - }; - + const fw = makeFakeWindow(); + const restore = installFakeWindow(fw); try { - const manager = new ChannelSortSyncManager("pk-test"); - const store = makeStore({ channels: "recent" }); - - manager.publishSortPrefs(store); - assert.ok(timerCallback !== null, "debounce timer should be set"); - + const manager = new ChannelSortSyncManager("pk-test", RELAY); + manager.publishSortPrefs(makeStore({ channels: "recent" })); + assert.ok(fw._hasTimer(), "debounce timer should be set"); manager.destroy(); - - assert.ok( - timerCallback === null, - "debounce timer should be cleared on destroy", - ); - assert.equal( - publishCalls.length, - 0, - "no publish event should have been sent after destroy", - ); + assert.ok(!fw._hasTimer(), "debounce timer should be cleared on destroy"); + assert.equal(publishCalls.length, 0); + assert.equal(manager.getPendingStore(), null); } finally { - if (originalSetTimeout !== undefined) { - globalThis.window.setTimeout = originalSetTimeout; - } - if (originalClearTimeout !== undefined) { - globalThis.window.clearTimeout = originalClearTimeout; - } + restore(); mock.reset(); } }); -// Regression guard for the timer-fired race: debounce fires → doPublish starts -// awaiting fetchOwnBlobBeforePublish → destroy() is called (relayUrl dep -// change) → publishEvent must never be called even though the timer already -// fired and cleared itself before destroy() ran. +// Regression guard for the timer-fired race: debounce fires → doPublish awaits +// fetchOwnBlobBeforePublish → destroy() called → publishEvent must not fire. test("destroy: aborts in-flight doPublish after fetchOwnBlobBeforePublish resolves", async () => { let releaseFetch = null; const publishCalls = []; - - mock.method(relayClient, "fetchEvents", () => { - return new Promise((resolve) => { - releaseFetch = () => resolve([]); - }); - }); + mock.method( + relayClient, + "fetchEvents", + () => + new Promise((res) => { + releaseFetch = () => res([]); + }), + ); mock.method(relayClient, "publishEvent", (...args) => { publishCalls.push(args); return Promise.resolve(); }); - - if (typeof globalThis.window === "undefined") { - globalThis.window = {}; - } - let capturedCallback = null; - let nextId = 1; - const origSetTimeout = globalThis.window.setTimeout; - const origClearTimeout = globalThis.window.clearTimeout; - globalThis.window.setTimeout = (fn, _ms) => { - capturedCallback = fn; - return nextId++; - }; - globalThis.window.clearTimeout = (_id) => { - capturedCallback = null; - }; - + const fw = makeFakeWindow(); + const restore = installFakeWindow(fw); try { - const manager = new ChannelSortSyncManager("pk-race"); - const store = makeStore({ dms: "recent" }); - - manager.publishSortPrefs(store); - assert.ok(capturedCallback !== null, "debounce timer should be set"); - - const timerFn = capturedCallback; - capturedCallback = null; // timer cleared itself inside the callback - timerFn(); - + const manager = new ChannelSortSyncManager("pk-race", RELAY); + manager.publishSortPrefs(makeStore({ dms: "recent" })); + fw._fireTimer(); // starts doPublish, which is now awaiting fetchOwnBlobBeforePublish manager.destroy(); - releaseFetch(); - - await new Promise((resolve) => setTimeout(resolve, 0)); - + await new Promise((r) => setTimeout(r, 0)); assert.equal( publishCalls.length, 0, - "publishEvent must not be called after destroy() even when timer already fired", + "publishEvent must not fire after destroy", ); } finally { - globalThis.window.setTimeout = origSetTimeout; - globalThis.window.clearTimeout = origClearTimeout; + restore(); mock.reset(); } }); test("destroy: is safe to call with no pending publish", () => { - const manager = new ChannelSortSyncManager("pk-no-pending"); - assert.doesNotThrow(() => manager.destroy()); + const fw = makeFakeWindow(); + const restore = installFakeWindow(fw); + try { + const manager = new ChannelSortSyncManager("pk-no-pending", RELAY); + assert.doesNotThrow(() => manager.destroy()); + } finally { + restore(); + } }); -test("destroy: cancelPendingPublish clears pendingStore", () => { - let timerCallback = null; - let nextId = 1; - if (typeof globalThis.window === "undefined") { - globalThis.window = {}; +// ─── Boot seed-publish guard (the revert-fix regression suite) ──────────────── +// Wiring tests 1-3 drive the production bootstrap() path; policy tested once +// in sidebarSyncWatermark.test.mjs. + +// 1. fetch failed (error/timeout) + local non-empty → hold, zero publish calls +// Mutation: removing the failed guard causes bootstrap to call publishSortPrefs → pendingStore set. +test("revert-fix: fetch failed (error) does not trigger seed-publish via bootstrap", async () => { + mock.method(relayClient, "fetchEvents", () => + Promise.reject(new Error("relay timeout")), + ); + mock.method(relayClient, "publishEvent", () => Promise.resolve()); + const fw = makeFakeWindow(); + const restore = installFakeWindow(fw); + try { + const manager = new ChannelSortSyncManager("pk-fail", RELAY); + const result = await manager.bootstrap(makeStore({ channels: "recent" })); + assert.equal(result.action, "hold"); + assert.equal(manager.getPendingStore(), null); + } finally { + restore(); + mock.reset(); } - const orig = globalThis.window.setTimeout; - const origClear = globalThis.window.clearTimeout; - globalThis.window.setTimeout = (fn, _ms) => { - timerCallback = fn; - return nextId++; - }; - globalThis.window.clearTimeout = (_id) => { - timerCallback = null; - }; +}); +// 2. absent + persisted head > 0 → hold, zero publish calls (the dev-build stale-copy case) +// Mutation: setting watermark to 0 in localStorage causes bootstrap to seed. +test("revert-fix: absent fetch with prior watermark blocks seed-publish via bootstrap", async () => { + mock.method(relayClient, "fetchEvents", () => Promise.resolve([])); + mock.method(relayClient, "publishEvent", () => Promise.resolve()); + const fw = makeFakeWindow(); + fw.localStorage.setItem( + `buzz-sync-watermark.v1:channel-sort:pk-stale:${RELAY_KEY}`, + "1700000000", + ); + const restore = installFakeWindow(fw); try { - const manager = new ChannelSortSyncManager("pk-pending-null"); - const store = makeStore({ starred: "recent" }); - manager.publishSortPrefs(store); - assert.deepEqual(manager.getPendingStore(), store); + const manager = new ChannelSortSyncManager("pk-stale", RELAY); + assert.ok( + Number( + fw.localStorage.getItem( + `buzz-sync-watermark.v1:channel-sort:pk-stale:${RELAY_KEY}`, + ) ?? "0", + ) > 0, + ); + const result = await manager.bootstrap(makeStore({ channels: "recent" })); + assert.equal(result.action, "hold"); + assert.equal(manager.getPendingStore(), null); + } finally { + restore(); + mock.reset(); + } +}); - manager.destroy(); +// 3. absent + head 0 + local non-empty → seed-publish queued (first-sync preserved) +// Mutation: removing the absent+head-0 seed call leaves pendingStore null. +test("revert-fix: absent fetch with zero watermark seeds via bootstrap (first-sync preserved)", async () => { + mock.method(relayClient, "fetchEvents", () => Promise.resolve([])); + mock.method(relayClient, "publishEvent", () => Promise.resolve()); + const fw = makeFakeWindow(); + const restore = installFakeWindow(fw); + try { + const manager = new ChannelSortSyncManager("pk-fresh", RELAY); assert.equal( - manager.getPendingStore(), + fw.localStorage.getItem( + `buzz-sync-watermark.v1:channel-sort:pk-fresh:${RELAY_KEY}`, + ), null, - "pendingStore must be null after destroy", ); - assert.ok(timerCallback === null, "timer must be cleared after destroy"); + const result = await manager.bootstrap(makeStore({ channels: "recent" })); + assert.equal(result.action, "hold"); + assert.ok(manager.getPendingStore() !== null); } finally { - globalThis.window.setTimeout = orig; - globalThis.window.clearTimeout = origClear; + restore(); + mock.reset(); + } +}); + +// 4. LWW baseline: newer decryptable pre-publish event still wins after an +// undecryptable head was recorded. +// Mutation test: headBeforeFetch → this.lastRemoteCreatedAt makes comparison +// 200>200=false → local wins instead of remote → wrong content encrypted. +test("revert-fix: sort LWW — newer decryptable pre-publish event selected after undecryptable head recorded", async () => { + const REMOTE_KEY = "remote-group-from-relay"; + let callCount = 0; + mock.method(relayClient, "fetchEvents", () => { + callCount++; + return Promise.resolve([ + { + pubkey: "pk-lww", + content: callCount === 1 ? "bad-cipher" : "good-cipher", + created_at: callCount === 1 ? 100 : 200, + id: `evt-${callCount}`, + }, + ]); + }); + mock.method(relayClient, "publishEvent", () => Promise.resolve()); + const fw = makeFakeWindow(); + const restore = installFakeWindow(fw); + const tauri = installTauriMock( + JSON.stringify({ version: 1, groups: { [REMOTE_KEY]: "recent" } }), + ); + try { + const manager = new ChannelSortSyncManager("pk-lww", RELAY); + await manager.fetchRemoteSortPrefs(); + assert.ok( + Number( + fw.localStorage.getItem( + `buzz-sync-watermark.v1:channel-sort:pk-lww:${RELAY_KEY}`, + ) ?? "0", + ) >= 100, + ); + manager.publishSortPrefs(makeStore({ "local-group": "recent" })); + fw._fireTimer(); + await new Promise((r) => setTimeout(r, 20)); + const pt = tauri.capturedPlaintext(); + assert.ok(pt !== null, "nip44EncryptToSelf must have been called"); + assert.ok( + JSON.parse(pt).groups && REMOTE_KEY in JSON.parse(pt).groups, + `remote groups must win LWW merge — got: ${pt}`, + ); + } finally { + tauri.restore(); + restore(); + mock.reset(); + } +}); + +// 5. live-sub: undecryptable event on live path records head before decrypt +// Mutation test: removing recordRemoteHead before decrypt in the live callback +// leaves watermark at 0 after a live event. +test("revert-fix: undecryptable live event advances watermark before decrypt attempt", async () => { + let liveCallback = null; + mock.method(relayClient, "subscribeLive", (_filter, onEvent) => { + liveCallback = onEvent; + return Promise.resolve(async () => {}); + }); + const fw = makeFakeWindow(); + const restore = installFakeWindow(fw); + try { + const manager = new ChannelSortSyncManager("pk-live", RELAY); + assert.equal( + fw.localStorage.getItem( + `buzz-sync-watermark.v1:channel-sort:pk-live:${RELAY_KEY}`, + ), + null, + "watermark starts absent", + ); + await manager.subscribeToSortPrefs(() => {}); + assert.ok( + liveCallback !== null, + "subscribeLive must have captured the callback", + ); + liveCallback({ + pubkey: "pk-live", + content: "!bad-cipher!", + created_at: 1700005555, + id: "live-evt-1", + }); + await new Promise((r) => setTimeout(r, 0)); + assert.ok( + Number( + fw.localStorage.getItem( + `buzz-sync-watermark.v1:channel-sort:pk-live:${RELAY_KEY}`, + ) ?? "0", + ) >= 1700005555, + "live undecryptable event must advance the watermark before decrypt is attempted", + ); + } finally { + restore(); + mock.reset(); } }); diff --git a/desktop/src/features/sidebar/lib/channelSortSync.ts b/desktop/src/features/sidebar/lib/channelSortSync.ts index e23387368d..fe71fe62df 100644 --- a/desktop/src/features/sidebar/lib/channelSortSync.ts +++ b/desktop/src/features/sidebar/lib/channelSortSync.ts @@ -10,8 +10,15 @@ import { parseChannelSortPayload, type ChannelSortStore, } from "./channelSortPreference"; +import { + advanceWatermark, + readWatermark, + runBootstrap, + type FetchResult, +} from "./sidebarSyncWatermark"; const D_TAG = "channel-sort"; +const BLOB_TYPE = D_TAG; const DEBOUNCE_MS = 2_000; export type RemoteSortPrefs = { @@ -44,17 +51,20 @@ async function decryptAndParse( */ export class ChannelSortSyncManager { private pubkey: string; + private relayUrl: string; private debounceTimer: number | null = null; - private lastRemoteCreatedAt = 0; + private lastRemoteCreatedAt: number; private pendingStore: ChannelSortStore | null = null; private lastPublishedStore: ChannelSortStore | null = null; private destroyed = false; - constructor(pubkey: string) { + constructor(pubkey: string, relayUrl: string) { this.pubkey = pubkey; + this.relayUrl = relayUrl; + this.lastRemoteCreatedAt = readWatermark(pubkey, BLOB_TYPE, relayUrl); } - async fetchRemoteSortPrefs(): Promise { + async fetchRemoteSortPrefs(): Promise> { try { const events = await relayClient.fetchEvents({ kinds: [KIND_CHANNEL_SORT], @@ -62,21 +72,33 @@ export class ChannelSortSyncManager { "#d": [D_TAG], limit: 1, }); - if (events.length === 0) return null; - if (events[0].pubkey !== this.pubkey) return null; - const result = await decryptAndParse(events[0]); - if (result) { - this.lastRemoteCreatedAt = Math.max( - this.lastRemoteCreatedAt, - result.createdAt, - ); + if (events.length === 0 || events[0].pubkey !== this.pubkey) { + return { status: "absent" }; + } + const event = events[0]; + this.recordRemoteHead(event.created_at); + const result = await decryptAndParse(event); + if (!result) { + return { status: "failed", createdAt: event.created_at }; } - return result; + return { + status: "found", + data: result, + createdAt: result.createdAt, + eventId: result.eventId, + }; } catch { - return null; + return { status: "failed" }; } } + private recordRemoteHead(createdAt: number): void { + if (createdAt > this.lastRemoteCreatedAt) { + this.lastRemoteCreatedAt = createdAt; + } + advanceWatermark(this.pubkey, BLOB_TYPE, this.relayUrl, createdAt); + } + cancelPendingPublish(): void { if (this.debounceTimer !== null) { window.clearTimeout(this.debounceTimer); @@ -110,11 +132,17 @@ export class ChannelSortSyncManager { limit: 1, }); if (events.length === 0 || events[0].pubkey !== this.pubkey) return store; - const remote = await decryptAndParse(events[0]); + const event = events[0]; + // Snapshot the watermark before advancing it: after recordRemoteHead + // runs, lastRemoteCreatedAt equals event.created_at, so the LWW + // comparison remote.createdAt > lastRemoteCreatedAt would always be + // false and silently suppress the merge. + const headBeforeFetch = this.lastRemoteCreatedAt; + this.recordRemoteHead(event.created_at); + const remote = await decryptAndParse(event); if (!remote) return store; // Sort prefs use whole-blob LWW: take whichever is newer - if (remote.createdAt > this.lastRemoteCreatedAt) { - this.lastRemoteCreatedAt = remote.createdAt; + if (remote.createdAt > headBeforeFetch) { return remote.store; } return store; @@ -174,10 +202,7 @@ export class ChannelSortSyncManager { "Timed out publishing channel sort preferences.", "Failed to publish channel sort preferences.", ); - this.lastRemoteCreatedAt = Math.max( - this.lastRemoteCreatedAt, - event.created_at, - ); + this.recordRemoteHead(event.created_at); this.lastPublishedStore = merged; this.pendingStore = null; } catch (error) { @@ -197,12 +222,11 @@ export class ChannelSortSyncManager { }, (event: RelayEvent) => { if (event.pubkey !== this.pubkey) return; + // Record the raw head before decrypt so an undecryptable live event + // still advances the watermark and blocks future seed-publish. + this.recordRemoteHead(event.created_at); void decryptAndParse(event).then((result) => { if (result) { - this.lastRemoteCreatedAt = Math.max( - this.lastRemoteCreatedAt, - result.createdAt, - ); onUpdate(result); } }); @@ -210,14 +234,28 @@ export class ChannelSortSyncManager { ); } + /** + * Fetches the remote blob on first mount, records the remote head, and + * delegates the seed/hold/apply-remote decision to `runBootstrap`. + */ + async bootstrap(localStore: ChannelSortStore) { + const fetchResult = await this.fetchRemoteSortPrefs(); + return runBootstrap({ + fetchResult, + lastHead: this.lastRemoteCreatedAt, + localStore, + isLocalNonEmpty: (s) => Object.keys(s.groups).length > 0, + publishFn: (s) => this.publishSortPrefs(s), + }); + } + destroy(): void { // Cancel any pending publish and mark this manager as destroyed so any - // in-flight doPublish() calls abort before reaching relayClient. The - // scoped localStorage write is already durable; when the user returns to - // this relay the existing seed-publish guard will re-publish from local - // state. Flushing here would race against community switching and could - // publish relay A's sort prefs to relay B via the shared relayClient - // singleton. + // in-flight doPublish() calls abort before reaching relayClient. + // Pending debounce-window changes are intentionally dropped: flushing + // could publish relay A's sort prefs to relay B via the shared relayClient + // singleton. On return, bootstrap's found path whole-blob-replaces from + // remote, so any dropped pending edit is lost. this.destroyed = true; this.cancelPendingPublish(); this.pendingStore = null; diff --git a/desktop/src/features/sidebar/lib/channelStarsSync.test.mjs b/desktop/src/features/sidebar/lib/channelStarsSync.test.mjs new file mode 100644 index 0000000000..b023574467 --- /dev/null +++ b/desktop/src/features/sidebar/lib/channelStarsSync.test.mjs @@ -0,0 +1,202 @@ +import assert from "node:assert/strict"; +import test, { mock } from "node:test"; + +import { relayClient } from "@/shared/api/relayClient"; +import { ChannelStarSyncManager } from "./channelStarsSync.ts"; +import { + makeFakeWindow, + installFakeWindow, +} from "./sidebarSyncTestHelpers.mjs"; + +const RELAY = "wss://r.test"; +const RELAY_KEY = encodeURIComponent(RELAY); + +function makeStore(channels = {}) { + return { version: 1, channels }; +} + +// ─── destroy() must cancel pending publish, not flush ───────────────────────── + +// Regression guard for the community-switch cross-relay publish vector: +// star a channel in relay A → destroy() called (relayUrl dep change) → +// no publish should fire. +test("destroy: cancels pending publish without flushing to the relay", () => { + const publishCalls = []; + mock.method(relayClient, "fetchEvents", () => Promise.resolve([])); + mock.method(relayClient, "publishEvent", (...args) => { + publishCalls.push(args); + return Promise.resolve(); + }); + const fw = makeFakeWindow(); + const restore = installFakeWindow(fw); + try { + const manager = new ChannelStarSyncManager("pk-test", RELAY); + manager.publishStars(makeStore({ ch1: { starred: true, updatedAt: 100 } })); + manager.destroy(); + assert.equal(publishCalls.length, 0, "no publish after destroy"); + assert.equal(manager.getPendingStarStore(), null); + } finally { + restore(); + mock.reset(); + } +}); + +test("destroy: aborts in-flight doPublish after fetchOwnBlobBeforePublish resolves", async () => { + let releaseFetch = null; + const publishCalls = []; + mock.method( + relayClient, + "fetchEvents", + () => + new Promise((res) => { + releaseFetch = () => res([]); + }), + ); + mock.method(relayClient, "publishEvent", (...args) => { + publishCalls.push(args); + return Promise.resolve(); + }); + const fw = makeFakeWindow(); + const restore = installFakeWindow(fw); + try { + const manager = new ChannelStarSyncManager("pk-race", RELAY); + manager.publishStars(makeStore({ ch1: { starred: true, updatedAt: 100 } })); + fw._fireTimer(); + manager.destroy(); + releaseFetch(); + await new Promise((r) => setTimeout(r, 0)); + assert.equal( + publishCalls.length, + 0, + "publishEvent must not be called after destroy", + ); + } finally { + restore(); + mock.reset(); + } +}); + +test("destroy: is safe to call with no pending publish", () => { + const fw = makeFakeWindow(); + const restore = installFakeWindow(fw); + try { + const manager = new ChannelStarSyncManager("pk-no-pending", RELAY); + assert.doesNotThrow(() => manager.destroy()); + } finally { + restore(); + } +}); + +// ─── Boot seed-publish guard (the revert-fix regression suite) ───────────────── + +// 1. fetch failed → hold, pendingStore null (mutation: remove failed guard → seed queued) +test("revert-fix: fetch failed (error) does not trigger seed-publish via bootstrap", async () => { + mock.method(relayClient, "fetchEvents", () => + Promise.reject(new Error("relay timeout")), + ); + mock.method(relayClient, "publishEvent", () => Promise.resolve()); + const fw = makeFakeWindow(); + const restore = installFakeWindow(fw); + try { + const manager = new ChannelStarSyncManager("pk-fail", RELAY); + const result = await manager.bootstrap( + makeStore({ ch1: { starred: true, updatedAt: 1 } }), + ); + assert.equal(result.action, "hold"); + assert.equal(manager.getPendingStarStore(), null); + } finally { + restore(); + mock.reset(); + } +}); + +// 2. absent + prior watermark → hold, pendingStore null (mutation: clear watermark → seed queued) +test("revert-fix: absent fetch with prior watermark blocks seed-publish via bootstrap", async () => { + mock.method(relayClient, "fetchEvents", () => Promise.resolve([])); + mock.method(relayClient, "publishEvent", () => Promise.resolve()); + const fw = makeFakeWindow(); + fw.localStorage.setItem( + `buzz-sync-watermark.v1:channel-stars:pk-stale:${RELAY_KEY}`, + "1700000000", + ); + const restore = installFakeWindow(fw); + try { + const manager = new ChannelStarSyncManager("pk-stale", RELAY); + assert.ok( + Number( + fw.localStorage.getItem( + `buzz-sync-watermark.v1:channel-stars:pk-stale:${RELAY_KEY}`, + ) ?? "0", + ) > 0, + ); + const result = await manager.bootstrap( + makeStore({ ch1: { starred: true, updatedAt: 1 } }), + ); + assert.equal(result.action, "hold"); + assert.equal(manager.getPendingStarStore(), null); + } finally { + restore(); + mock.reset(); + } +}); + +// 3. absent + zero watermark + non-empty → seed queued (mutation: remove seed call → pendingStore null) +test("revert-fix: absent fetch with zero watermark seeds via bootstrap (first-sync preserved)", async () => { + mock.method(relayClient, "fetchEvents", () => Promise.resolve([])); + mock.method(relayClient, "publishEvent", () => Promise.resolve()); + const fw = makeFakeWindow(); + const restore = installFakeWindow(fw); + try { + const manager = new ChannelStarSyncManager("pk-fresh", RELAY); + assert.equal( + fw.localStorage.getItem( + `buzz-sync-watermark.v1:channel-stars:pk-fresh:${RELAY_KEY}`, + ), + null, + ); + const result = await manager.bootstrap( + makeStore({ ch1: { starred: true, updatedAt: 1 } }), + ); + assert.equal(result.action, "hold"); + assert.ok(manager.getPendingStarStore() !== null); + } finally { + restore(); + mock.reset(); + } +}); + +// 4. relay-A / relay-B watermark isolation +// Mutation: using pubkey-only key (no relay) makes relay A's head suppress relay B's first-sync. +test("revert-fix: relay-A watermark does not suppress first-sync seed on relay-B", async () => { + const relayA = "wss://a.relay.test"; + const relayB = "wss://b.relay.test"; + mock.method(relayClient, "fetchEvents", () => Promise.resolve([])); + mock.method(relayClient, "publishEvent", () => Promise.resolve()); + const fw = makeFakeWindow(); + fw.localStorage.setItem( + `buzz-sync-watermark.v1:channel-stars:pk-iso:${encodeURIComponent(relayA)}`, + "1700000100", + ); + const restore = installFakeWindow(fw); + try { + const managerB = new ChannelStarSyncManager("pk-iso", relayB); + assert.equal( + fw.localStorage.getItem( + `buzz-sync-watermark.v1:channel-stars:pk-iso:${encodeURIComponent(relayB)}`, + ), + null, + "relay B watermark must be independent of relay A head", + ); + const result = await managerB.bootstrap( + makeStore({ ch1: { starred: true, updatedAt: 1 } }), + ); + assert.equal(result.action, "hold"); + assert.ok( + managerB.getPendingStarStore() !== null, + "first-sync seed on relay B must not be blocked by relay A watermark", + ); + } finally { + restore(); + mock.reset(); + } +}); diff --git a/desktop/src/features/sidebar/lib/channelStarsSync.ts b/desktop/src/features/sidebar/lib/channelStarsSync.ts index 6681030d47..a5abec03fb 100644 --- a/desktop/src/features/sidebar/lib/channelStarsSync.ts +++ b/desktop/src/features/sidebar/lib/channelStarsSync.ts @@ -11,8 +11,15 @@ import { parseStarPayload, type ChannelStarStore, } from "./channelStarsStorage"; +import { + advanceWatermark, + readWatermark, + runBootstrap, + type FetchResult, +} from "./sidebarSyncWatermark"; const D_TAG = "channel-stars"; +const BLOB_TYPE = D_TAG; const DEBOUNCE_MS = 2_000; export type RemoteStars = { @@ -34,16 +41,20 @@ async function decryptAndParse(event: RelayEvent): Promise { export class ChannelStarSyncManager { private pubkey: string; + private relayUrl: string; private debounceTimer: number | null = null; - private lastRemoteCreatedAt = 0; + private lastRemoteCreatedAt: number; private pendingStore: ChannelStarStore | null = null; private lastPublishedStore: ChannelStarStore | null = null; + private destroyed = false; - constructor(pubkey: string) { + constructor(pubkey: string, relayUrl: string) { this.pubkey = pubkey; + this.relayUrl = relayUrl; + this.lastRemoteCreatedAt = readWatermark(pubkey, BLOB_TYPE, relayUrl); } - async fetchRemoteStars(): Promise { + async fetchRemoteStars(): Promise> { try { const events = await relayClient.fetchEvents({ kinds: [KIND_CHANNEL_STARS], @@ -51,19 +62,31 @@ export class ChannelStarSyncManager { "#d": [D_TAG], limit: 1, }); - if (events.length === 0) return null; - if (events[0].pubkey !== this.pubkey) return null; - const result = await decryptAndParse(events[0]); - if (result) { - this.lastRemoteCreatedAt = Math.max( - this.lastRemoteCreatedAt, - result.createdAt, - ); + if (events.length === 0 || events[0].pubkey !== this.pubkey) { + return { status: "absent" }; + } + const event = events[0]; + this.recordRemoteHead(event.created_at); + const result = await decryptAndParse(event); + if (!result) { + return { status: "failed", createdAt: event.created_at }; } - return result; + return { + status: "found", + data: result, + createdAt: result.createdAt, + eventId: result.eventId, + }; } catch { - return null; + return { status: "failed" }; + } + } + + private recordRemoteHead(createdAt: number): void { + if (createdAt > this.lastRemoteCreatedAt) { + this.lastRemoteCreatedAt = createdAt; } + advanceWatermark(this.pubkey, BLOB_TYPE, this.relayUrl, createdAt); } cancelPendingStarPublish(): void { @@ -99,12 +122,11 @@ export class ChannelStarSyncManager { limit: 1, }); if (events.length === 0 || events[0].pubkey !== this.pubkey) return store; - const remote = await decryptAndParse(events[0]); + const event = events[0]; + // Record the raw head before decrypt on the pre-publish path too. + this.recordRemoteHead(event.created_at); + const remote = await decryptAndParse(event); if (!remote) return store; - this.lastRemoteCreatedAt = Math.max( - this.lastRemoteCreatedAt, - remote.createdAt, - ); return mergeStores(store, remote.store); } catch { return store; @@ -132,6 +154,10 @@ export class ChannelStarSyncManager { private async doPublish(store: ChannelStarStore): Promise { try { const merged = await this.fetchOwnBlobBeforePublish(store); + // Guard: manager may have been destroyed while fetchOwnBlobBeforePublish + // was awaited (community switch during in-flight fetch). If so, abort + // before touching the relay. + if (this.destroyed) return; if (this.isIdenticalToLastPublished(merged)) { this.pendingStore = null; return; @@ -154,15 +180,13 @@ export class ChannelStarSyncManager { ["t", D_TAG], // relay discoverability; not used in our filters ], }); + if (this.destroyed) return; await relayClient.publishEvent( event, "Timed out publishing channel stars.", "Failed to publish channel stars.", ); - this.lastRemoteCreatedAt = Math.max( - this.lastRemoteCreatedAt, - event.created_at, - ); + this.recordRemoteHead(event.created_at); this.lastPublishedStore = merged; this.pendingStore = null; } catch (error) { @@ -182,12 +206,11 @@ export class ChannelStarSyncManager { }, (event: RelayEvent) => { if (event.pubkey !== this.pubkey) return; + // Record the raw head before decrypt so an undecryptable live event + // still advances the watermark and blocks future seed-publish. + this.recordRemoteHead(event.created_at); void decryptAndParse(event).then((result) => { if (result) { - this.lastRemoteCreatedAt = Math.max( - this.lastRemoteCreatedAt, - result.createdAt, - ); onUpdate(result); } }); @@ -195,14 +218,30 @@ export class ChannelStarSyncManager { ); } + /** + * Fetches the remote blob on first mount, records the remote head, and + * delegates the seed/hold/apply-remote decision to `runBootstrap`. + */ + async bootstrap(localStore: ChannelStarStore) { + const fetchResult = await this.fetchRemoteStars(); + return runBootstrap({ + fetchResult, + lastHead: this.lastRemoteCreatedAt, + localStore, + isLocalNonEmpty: (s) => Object.keys(s.channels).length > 0, + publishFn: (s) => this.publishStars(s), + }); + } + destroy(): void { - if (this.debounceTimer !== null && this.pendingStore !== null) { - window.clearTimeout(this.debounceTimer); - this.debounceTimer = null; - void this.doPublish(this.pendingStore); - } else if (this.debounceTimer !== null) { - window.clearTimeout(this.debounceTimer); - this.debounceTimer = null; - } + // Cancel any pending publish and mark this manager as destroyed so any + // in-flight doPublish() calls abort before reaching relayClient. + // Pending debounce-window changes are intentionally dropped: flushing + // could publish relay A's state to relay B via the shared relayClient + // singleton. Local entries survive because the apply/publish paths merge + // per-entry via mergeStores, so no local work is permanently lost. + this.destroyed = true; + this.cancelPendingStarPublish(); + this.pendingStore = null; } } diff --git a/desktop/src/features/sidebar/lib/sidebarSyncTestHelpers.mjs b/desktop/src/features/sidebar/lib/sidebarSyncTestHelpers.mjs new file mode 100644 index 0000000000..c94d76db70 --- /dev/null +++ b/desktop/src/features/sidebar/lib/sidebarSyncTestHelpers.mjs @@ -0,0 +1,85 @@ +// Shared helpers for sidebar sync manager tests. + +export function makeFakeWindow() { + const storage = new Map(); + const ls = { + getItem: (k) => storage.get(k) ?? null, + setItem: (k, v) => storage.set(k, v), + removeItem: (k) => storage.delete(k), + clear: () => storage.clear(), + }; + let timerCallback = null; + let nextTimerId = 100; + return { + localStorage: ls, + setTimeout: (fn, _ms) => { + timerCallback = fn; + return nextTimerId++; + }, + clearTimeout: (_id) => { + timerCallback = null; + }, + _fireTimer: () => { + if (timerCallback) { + const fn = timerCallback; + timerCallback = null; + fn(); + } + }, + _hasTimer: () => timerCallback !== null, + }; +} + +export function installFakeWindow(fw) { + if (typeof globalThis.window === "undefined") globalThis.window = {}; + const origLs = globalThis.window.localStorage; + const origSt = globalThis.window.setTimeout; + const origCt = globalThis.window.clearTimeout; + globalThis.window.localStorage = fw.localStorage; + globalThis.window.setTimeout = fw.setTimeout; + globalThis.window.clearTimeout = fw.clearTimeout; + return () => { + if (origLs !== undefined) globalThis.window.localStorage = origLs; + if (origSt !== undefined) globalThis.window.setTimeout = origSt; + if (origCt !== undefined) globalThis.window.clearTimeout = origCt; + }; +} + +export function installTauriMock(goodCipherPayload) { + const orig = globalThis.window?.__TAURI_INTERNALS__; + if (typeof globalThis.window === "undefined") globalThis.window = {}; + let captured = null; + globalThis.window.__TAURI_INTERNALS__ = { + invoke: (cmd, args) => { + if (cmd === "nip44_decrypt_from_self") { + if (args?.ciphertext === "bad-cipher") + return Promise.reject(new Error("decrypt failed")); + return Promise.resolve(goodCipherPayload); + } + if (cmd === "nip44_encrypt_to_self") { + captured = args?.plaintext ?? null; + return Promise.resolve("ct"); + } + if (cmd === "sign_event") + return Promise.resolve( + JSON.stringify({ + id: "eid", + pubkey: "pk-lww", + content: "ct", + created_at: args?.createdAt ?? 0, + kind: args?.kind ?? 0, + tags: args?.tags ?? [], + sig: "s", + }), + ); + return Promise.reject(new Error(`unmocked: ${cmd}`)); + }, + }; + return { + restore: () => { + if (orig !== undefined) globalThis.window.__TAURI_INTERNALS__ = orig; + else delete globalThis.window.__TAURI_INTERNALS__; + }, + capturedPlaintext: () => captured, + }; +} diff --git a/desktop/src/features/sidebar/lib/sidebarSyncWatermark.test.mjs b/desktop/src/features/sidebar/lib/sidebarSyncWatermark.test.mjs new file mode 100644 index 0000000000..0e8cb373c1 --- /dev/null +++ b/desktop/src/features/sidebar/lib/sidebarSyncWatermark.test.mjs @@ -0,0 +1,253 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +// We need a minimal localStorage stub since we're running in Node. +function withFreshStorage(fn) { + const store = new Map(); + const ls = { + getItem: (k) => store.get(k) ?? null, + setItem: (k, v) => store.set(k, v), + removeItem: (k) => store.delete(k), + clear: () => store.clear(), + }; + const orig = globalThis.window?.localStorage; + if (typeof globalThis.window === "undefined") globalThis.window = {}; + globalThis.window.localStorage = ls; + try { + fn(ls); + } finally { + if (orig !== undefined) globalThis.window.localStorage = orig; + else delete globalThis.window.localStorage; + } +} + +const { readWatermark, advanceWatermark, runBootstrap } = await import( + "./sidebarSyncWatermark.ts" +); + +// Relay URLs are normalised (trimmed, lowercase, trailing slash stripped) +// so the same relay written two ways produces the same key. +const RELAY = "wss://relay.example.com"; +const RELAY_ENCODED = encodeURIComponent("wss://relay.example.com"); + +// ── readWatermark ──────────────────────────────────────────────────────────── + +test("readWatermark: returns 0 when no key exists", () => { + withFreshStorage(() => { + assert.equal(readWatermark("pk", "sections", RELAY), 0); + }); +}); + +test("readWatermark: returns 0 when stored value is 0", () => { + withFreshStorage((ls) => { + ls.setItem(`buzz-sync-watermark.v1:sections:pk:${RELAY_ENCODED}`, "0"); + assert.equal(readWatermark("pk", "sections", RELAY), 0); + }); +}); + +test("readWatermark: returns stored positive integer", () => { + withFreshStorage((ls) => { + ls.setItem( + `buzz-sync-watermark.v1:sections:pk:${RELAY_ENCODED}`, + "1700000000", + ); + assert.equal(readWatermark("pk", "sections", RELAY), 1700000000); + }); +}); + +test("readWatermark: scopes by blobType", () => { + withFreshStorage((ls) => { + ls.setItem(`buzz-sync-watermark.v1:sections:pk:${RELAY_ENCODED}`, "100"); + ls.setItem(`buzz-sync-watermark.v1:sort:pk:${RELAY_ENCODED}`, "200"); + assert.equal(readWatermark("pk", "sections", RELAY), 100); + assert.equal(readWatermark("pk", "sort", RELAY), 200); + }); +}); + +test("readWatermark: normalises relay URL (trailing slash, case)", () => { + withFreshStorage(() => { + // Write with one form, read with another — must produce the same value. + advanceWatermark("pk", "sections", "WSS://Relay.Example.Com/", 999); + assert.equal( + readWatermark("pk", "sections", "wss://relay.example.com"), + 999, + ); + assert.equal( + readWatermark("pk", "sections", "WSS://Relay.Example.Com/"), + 999, + ); + }); +}); + +// ── advanceWatermark ───────────────────────────────────────────────────────── + +test("advanceWatermark: writes when no prior value exists", () => { + withFreshStorage(() => { + advanceWatermark("pk", "sections", RELAY, 1700000000); + assert.equal(readWatermark("pk", "sections", RELAY), 1700000000); + }); +}); + +test("advanceWatermark: advances when next > current", () => { + withFreshStorage(() => { + advanceWatermark("pk", "sections", RELAY, 100); + advanceWatermark("pk", "sections", RELAY, 200); + assert.equal(readWatermark("pk", "sections", RELAY), 200); + }); +}); + +test("advanceWatermark: does not regress when next <= current (monotonic)", () => { + withFreshStorage(() => { + advanceWatermark("pk", "sections", RELAY, 500); + advanceWatermark("pk", "sections", RELAY, 400); // older — must not overwrite + advanceWatermark("pk", "sections", RELAY, 500); // equal — must not overwrite + assert.equal(readWatermark("pk", "sections", RELAY), 500); + }); +}); + +test("advanceWatermark: round-trips across separate reads (simulated restart)", () => { + withFreshStorage(() => { + // Session A writes watermark. + advanceWatermark("pk", "sections", RELAY, 1700000042); + // Session B reads it back. + assert.equal(readWatermark("pk", "sections", RELAY), 1700000042); + }); +}); + +// ── Relay-A / Relay-B isolation ────────────────────────────────────────────── + +test("relay-A watermark does not suppress first-sync on relay-B", () => { + withFreshStorage(() => { + const relayA = "wss://a.relay.test"; + const relayB = "wss://b.relay.test"; + advanceWatermark("pk", "sections", relayA, 1700000100); + assert.equal( + readWatermark("pk", "sections", relayB), + 0, + "relay B watermark must be independent of relay A", + ); + }); +}); + +test("relay-A watermark is preserved after relay-B session", () => { + withFreshStorage(() => { + const relayA = "wss://a.relay.test"; + const relayB = "wss://b.relay.test"; + advanceWatermark("pk", "sections", relayA, 1700000100); + advanceWatermark("pk", "sections", relayB, 1700000200); + assert.equal( + readWatermark("pk", "sections", relayA), + 1700000100, + "relay A head must not be clobbered by relay B activity", + ); + }); +}); + +// ── runBootstrap policy — tested once; mutations to any branch fail here ───── + +function makeBootstrapArgs({ fetchResult, lastHead, localNonEmpty }) { + let n = 0; + return { + args: { + fetchResult, + lastHead, + localStore: { items: localNonEmpty ? ["x"] : [] }, + isLocalNonEmpty: (s) => s.items.length > 0, + publishFn: () => { + n++; + }, + }, + publishCount: () => n, + }; +} + +// Guard: fetch failed → hold, zero publishes. +// Mutation: removing the failed branch causes a seed on first-sync case. +test("runBootstrap: fetch failed returns hold and never calls publishFn", () => { + const { args, publishCount } = makeBootstrapArgs({ + fetchResult: { status: "failed" }, + lastHead: 0, + localNonEmpty: true, + }); + const result = runBootstrap(args); + assert.equal(result.action, "hold"); + assert.equal( + publishCount(), + 0, + "publishFn must not be called on failed fetch", + ); +}); + +// Guard: fetch absent + prior head > 0 → hold, zero publishes (stale-dev-build case). +// Mutation: setting lastHead to 0 causes a seed. +test("runBootstrap: fetch absent with prior head returns hold and never calls publishFn", () => { + const { args, publishCount } = makeBootstrapArgs({ + fetchResult: { status: "absent" }, + lastHead: 1700000000, + localNonEmpty: true, + }); + const result = runBootstrap(args); + assert.equal(result.action, "hold"); + assert.equal( + publishCount(), + 0, + "publishFn must not be called when prior head exists", + ); +}); + +// Guard: fetch absent + head 0 + local non-empty → publishFn called exactly once, hold returned. +// Mutation: removing the absent+head-0 seed call leaves publishCount at 0. +test("runBootstrap: first-sync (absent + zero head + non-empty local) calls publishFn and returns hold", () => { + const { args, publishCount } = makeBootstrapArgs({ + fetchResult: { status: "absent" }, + lastHead: 0, + localNonEmpty: true, + }); + const result = runBootstrap(args); + assert.equal(result.action, "hold"); + assert.equal( + publishCount(), + 1, + "publishFn must be called exactly once on first-sync", + ); +}); + +// Guard: fetch absent + head 0 + empty local → no publish, hold returned. +test("runBootstrap: first-sync with empty local store does not call publishFn", () => { + const { args, publishCount } = makeBootstrapArgs({ + fetchResult: { status: "absent" }, + lastHead: 0, + localNonEmpty: false, + }); + const result = runBootstrap(args); + assert.equal(result.action, "hold"); + assert.equal(publishCount(), 0, "empty local store must not trigger seed"); +}); + +// Guard: fetch found → apply-remote returned, no publish. +// Mutation: removing the found branch drops the remote data. +test("runBootstrap: fetch found returns apply-remote with data and never calls publishFn", () => { + const remoteData = { + store: { version: 1, items: [] }, + createdAt: 100, + eventId: "e1", + }; + const { args, publishCount } = makeBootstrapArgs({ + fetchResult: { + status: "found", + data: remoteData, + createdAt: 100, + eventId: "e1", + }, + lastHead: 0, + localNonEmpty: true, + }); + const result = runBootstrap(args); + assert.equal(result.action, "apply-remote"); + assert.deepEqual(result.data, remoteData); + assert.equal( + publishCount(), + 0, + "publishFn must not be called when remote was found", + ); +}); diff --git a/desktop/src/features/sidebar/lib/sidebarSyncWatermark.ts b/desktop/src/features/sidebar/lib/sidebarSyncWatermark.ts new file mode 100644 index 0000000000..d81b188ad6 --- /dev/null +++ b/desktop/src/features/sidebar/lib/sidebarSyncWatermark.ts @@ -0,0 +1,135 @@ +/** + * Persisted remote-head watermark for sidebar-preference sync managers. + * + * Each manager (sections, sort, stars, mutes) persists the highest + * `created_at` it has ever observed from the relay under a key scoped to + * pubkey + relay + blob type. On the next boot the manager reads this value + * back: if it is > 0 a remote blob has existed before and seed-publishing + * must be skipped even when the fetch comes back empty (error, timeout, or + * auth-race). + * + * Keys live in localStorage alongside the payload blobs. They are tiny + * (one integer string per key) and scoped so they never bleed across + * identities, communities, or blob types. + * + * `relayUrl` is always required — a pubkey-only fallback is not safe because + * a head seen on relay A would suppress legitimate first-time seeding on + * relay B. The URL is normalised (trimmed, trailing slash stripped, + * lower-cased) before being embedded in the key so the same relay written + * two ways never produces two different keys. + */ + +import { normalizeRelayUrl } from "@/shared/lib/normalizeRelayUrl"; + +const PREFIX = "buzz-sync-watermark.v1"; + +/** + * Tri-state result returned by every `fetchRemote*()` method. + * + * - `found` — the relay returned an event that decrypted and parsed cleanly. + * - `absent` — the relay was successfully queried and returned zero events + * (genuine first-time use on this relay). + * - `failed` — the fetch threw (timeout, relay error, auth-race), or an event + * existed but could not be decrypted/parsed. In the `failed` + * case, `createdAt` may be set when the event itself was readable + * even though its payload was not — the manager records the head + * so seed-publish is still blocked. + */ +export type FetchResult = + | { status: "found"; data: T; createdAt: number; eventId: string } + | { status: "absent" } + | { status: "failed"; createdAt?: number }; + +function watermarkKey( + pubkey: string, + blobType: string, + relayUrl: string, +): string { + return `${PREFIX}:${blobType}:${pubkey}:${encodeURIComponent(normalizeRelayUrl(relayUrl))}`; +} + +/** Read the persisted watermark (0 when absent or on read error). */ +export function readWatermark( + pubkey: string, + blobType: string, + relayUrl: string, +): number { + try { + const raw = window.localStorage.getItem( + watermarkKey(pubkey, blobType, relayUrl), + ); + if (raw === null) return 0; + const n = Number(raw); + return Number.isFinite(n) && n > 0 ? n : 0; + } catch { + return 0; + } +} + +/** + * Persist a new watermark if it is strictly greater than the current value. + * Absence or error never lowers the watermark (monotonic). + */ +export function advanceWatermark( + pubkey: string, + blobType: string, + relayUrl: string, + next: number, +): void { + try { + const current = readWatermark(pubkey, blobType, relayUrl); + if (next <= current) return; + window.localStorage.setItem( + watermarkKey(pubkey, blobType, relayUrl), + String(next), + ); + } catch { + // Ignore write failures — the in-memory lastRemoteCreatedAt still guards + // seed-publish within this session; the watermark is belt-and-suspenders + // across sessions. + } +} + +/** Result returned by `bootstrap()` — the hook acts on this without publishing. */ +export type BootstrapResult = + | { action: "apply-remote"; data: T } + | { action: "hold" }; + +/** + * Shared boot policy for all four sidebar-preference sync managers. + * + * Each manager calls this from its `bootstrap()` method, supplying its + * surface-specific fetch, publish, and local-store accessors. The full + * decision lives here once so that a mutation to any one surface cannot + * escape via a per-manager copy. + * + * Policy: + * - `found` → return `apply-remote`; hook applies data. + * - `failed` → hold; seed-publish blocked (error or unreadable event). + * - `absent` + `lastHead > 0` → hold; relay blob seen before, absence may be transient. + * - `absent` + `lastHead === 0` + non-empty local → call `publishFn(local)`; return `hold`. + * - `absent` + `lastHead === 0` + empty local → hold; nothing to seed. + */ +export function runBootstrap({ + fetchResult, + lastHead, + localStore, + isLocalNonEmpty, + publishFn, +}: { + fetchResult: FetchResult; + lastHead: number; + localStore: TLocal; + isLocalNonEmpty: (store: TLocal) => boolean; + publishFn: (store: TLocal) => void; +}): BootstrapResult { + if (fetchResult.status === "found") { + return { action: "apply-remote", data: fetchResult.data }; + } + if (fetchResult.status === "absent" && lastHead === 0) { + if (isLocalNonEmpty(localStore)) { + publishFn(localStore); + } + } + return { action: "hold" }; +} diff --git a/desktop/src/features/sidebar/lib/useChannelMutes.ts b/desktop/src/features/sidebar/lib/useChannelMutes.ts index 1fe92b60a3..cab913834d 100644 --- a/desktop/src/features/sidebar/lib/useChannelMutes.ts +++ b/desktop/src/features/sidebar/lib/useChannelMutes.ts @@ -14,7 +14,10 @@ import { import { ChannelMuteSyncManager } from "./channelMutesSync"; import type { RemoteMutes } from "./channelMutesSync"; -export function useChannelMutes(pubkey: string | undefined): { +export function useChannelMutes( + pubkey: string | undefined, + relayUrl?: string, +): { mutedChannelIds: Set; muteChannel: (channelId: string) => void; unmuteChannel: (channelId: string) => void; @@ -31,7 +34,7 @@ export function useChannelMutes(pubkey: string | undefined): { const lastAppliedEventId = React.useRef(""); React.useEffect(() => { - if (!pubkey) { + if (!pubkey || !relayUrl) { setStore(DEFAULT_STORE); lastAppliedRemoteTs.current = 0; lastAppliedEventId.current = ""; @@ -40,12 +43,12 @@ export function useChannelMutes(pubkey: string | undefined): { setStore(readChannelMutesStore(pubkey)); lastAppliedRemoteTs.current = 0; lastAppliedEventId.current = ""; - managerRef.current = new ChannelMuteSyncManager(pubkey); + managerRef.current = new ChannelMuteSyncManager(pubkey, relayUrl); return () => { managerRef.current?.destroy(); managerRef.current = null; }; - }, [pubkey]); + }, [pubkey, relayUrl]); React.useEffect(() => { if (!pubkey) { @@ -86,24 +89,22 @@ export function useChannelMutes(pubkey: string | undefined): { ); React.useEffect(() => { - if (!pubkey) return; + if (!pubkey || !relayUrl) return; let cancelled = false; - void managerRef.current?.fetchRemoteMutes().then((remote) => { + const local = readChannelMutesStore(pubkey); + void managerRef.current?.bootstrap(local).then((result) => { if (cancelled) return; - if (remote) { - setStore(applyRemote(remote)); - } else { - const local = readChannelMutesStore(pubkey); - if (Object.keys(local.channels).length > 0) { - managerRef.current?.publishMutes(local); - } + if (result.action === "apply-remote") { + setStore(applyRemote(result.data)); } + // "hold": seed already performed by bootstrap (if first-sync), or blocked. }); return () => { cancelled = true; }; - }, [pubkey, applyRemote]); + }, [pubkey, relayUrl, applyRemote]); + // biome-ignore lint/correctness/useExhaustiveDependencies: relayUrl is intentional — rebinds subscription when the active relay changes even though it is not used inside the effect body directly (the manager via managerRef.current carries it) React.useEffect(() => { if (!pubkey) return; let unsub: (() => Promise) | null = null; @@ -124,16 +125,17 @@ export function useChannelMutes(pubkey: string | undefined): { cancelled = true; if (unsub) void unsub(); }; - }, [pubkey, applyRemote]); + }, [pubkey, relayUrl, applyRemote]); + // biome-ignore lint/correctness/useExhaustiveDependencies: relayUrl is intentional — rebinds reconnect listener when the active relay changes (community switch) even though it is not referenced directly inside the effect body React.useEffect(() => { if (!pubkey) return; let cancelled = false; const unsub = relayClient.subscribeToReconnects(() => { - void managerRef.current?.fetchRemoteMutes().then((remote) => { + void managerRef.current?.fetchRemoteMutes().then((result) => { if (cancelled) return; - if (remote) { - setStore(applyRemote(remote)); + if (result.status === "found") { + setStore(applyRemote(result.data)); } const pending = managerRef.current?.getPendingMuteStore(); if (pending) { @@ -145,7 +147,7 @@ export function useChannelMutes(pubkey: string | undefined): { cancelled = true; unsub(); }; - }, [pubkey, applyRemote]); + }, [pubkey, relayUrl, applyRemote]); // biome-ignore lint/correctness/useExhaustiveDependencies: store.channels is the relevant dep — the outer store identity can change without channels changing (e.g., on reconnect writes) const mutedChannelIds = React.useMemo( diff --git a/desktop/src/features/sidebar/lib/useChannelSections.ts b/desktop/src/features/sidebar/lib/useChannelSections.ts index 2ba659a484..3d8aa73608 100644 --- a/desktop/src/features/sidebar/lib/useChannelSections.ts +++ b/desktop/src/features/sidebar/lib/useChannelSections.ts @@ -45,7 +45,7 @@ export function useChannelSections( const lastAppliedEventId = React.useRef(""); React.useEffect(() => { - if (!pubkey) { + if (!pubkey || !relayUrl) { setStore(DEFAULT_STORE); lastAppliedRemoteTs.current = 0; lastAppliedEventId.current = ""; @@ -54,7 +54,7 @@ export function useChannelSections( setStore(readChannelSectionsStore(pubkey, relayUrl)); lastAppliedRemoteTs.current = 0; lastAppliedEventId.current = ""; - managerRef.current = new ChannelSectionSyncManager(pubkey); + managerRef.current = new ChannelSectionSyncManager(pubkey, relayUrl); return () => { managerRef.current?.destroy(); managerRef.current = null; @@ -102,18 +102,16 @@ export function useChannelSections( ); React.useEffect(() => { - if (!pubkey) return; + if (!pubkey || !relayUrl) return; let cancelled = false; - void managerRef.current?.fetchRemoteSections().then((remote) => { + const local = readChannelSectionsStore(pubkey, relayUrl); + void managerRef.current?.bootstrap(local).then((result) => { if (cancelled) return; - if (remote) { - setStore(applyRemote(remote)); - } else { - const local = readChannelSectionsStore(pubkey, relayUrl); - if (local.sections.length > 0) { - managerRef.current?.publishSections(local); - } + if (result.action === "apply-remote") { + setStore(applyRemote(result.data)); } + // "hold": seed already performed by bootstrap (if first-sync), or + // blocked (failed fetch / prior watermark). Hook does nothing. }); return () => { cancelled = true; @@ -146,10 +144,10 @@ export function useChannelSections( if (!pubkey) return; let cancelled = false; const unsub = relayClient.subscribeToReconnects(() => { - void managerRef.current?.fetchRemoteSections().then((remote) => { + void managerRef.current?.fetchRemoteSections().then((result) => { if (cancelled) return; - if (remote) { - setStore(applyRemote(remote)); + if (result.status === "found") { + setStore(applyRemote(result.data)); } const pending = managerRef.current?.getPendingStore(); if (pending) { diff --git a/desktop/src/features/sidebar/lib/useChannelSortPreference.ts b/desktop/src/features/sidebar/lib/useChannelSortPreference.ts index e347d41a9d..a7963a11e4 100644 --- a/desktop/src/features/sidebar/lib/useChannelSortPreference.ts +++ b/desktop/src/features/sidebar/lib/useChannelSortPreference.ts @@ -49,7 +49,7 @@ export function useChannelSortPreference( const lastAppliedEventId = React.useRef(""); React.useEffect(() => { - if (!pubkey) { + if (!pubkey || !relayUrl) { setStore(DEFAULT_STORE); lastAppliedRemoteTs.current = 0; lastAppliedEventId.current = ""; @@ -58,7 +58,7 @@ export function useChannelSortPreference( setStore(readChannelSortStore(pubkey, relayUrl)); lastAppliedRemoteTs.current = 0; lastAppliedEventId.current = ""; - managerRef.current = new ChannelSortSyncManager(pubkey); + managerRef.current = new ChannelSortSyncManager(pubkey, relayUrl); return () => { managerRef.current?.destroy(); managerRef.current = null; @@ -101,18 +101,15 @@ export function useChannelSortPreference( ); React.useEffect(() => { - if (!pubkey) return; + if (!pubkey || !relayUrl) return; let cancelled = false; - void managerRef.current?.fetchRemoteSortPrefs().then((remote) => { + const local = readChannelSortStore(pubkey, relayUrl); + void managerRef.current?.bootstrap(local).then((result) => { if (cancelled) return; - if (remote) { - setStore(applyRemote(remote)); - } else { - const local = readChannelSortStore(pubkey, relayUrl); - if (Object.keys(local.groups).length > 0) { - managerRef.current?.publishSortPrefs(local); - } + if (result.action === "apply-remote") { + setStore(applyRemote(result.data)); } + // "hold": seed already performed by bootstrap (if first-sync), or blocked. }); return () => { cancelled = true; @@ -145,10 +142,10 @@ export function useChannelSortPreference( if (!pubkey) return; let cancelled = false; const unsub = relayClient.subscribeToReconnects(() => { - void managerRef.current?.fetchRemoteSortPrefs().then((remote) => { + void managerRef.current?.fetchRemoteSortPrefs().then((result) => { if (cancelled) return; - if (remote) { - setStore(applyRemote(remote)); + if (result.status === "found") { + setStore(applyRemote(result.data)); } const pending = managerRef.current?.getPendingStore(); if (pending) { diff --git a/desktop/src/features/sidebar/lib/useChannelStars.ts b/desktop/src/features/sidebar/lib/useChannelStars.ts index 777bf52cfd..b19b18a864 100644 --- a/desktop/src/features/sidebar/lib/useChannelStars.ts +++ b/desktop/src/features/sidebar/lib/useChannelStars.ts @@ -14,7 +14,10 @@ import { import { ChannelStarSyncManager } from "./channelStarsSync"; import type { RemoteStars } from "./channelStarsSync"; -export function useChannelStars(pubkey: string | undefined): { +export function useChannelStars( + pubkey: string | undefined, + relayUrl?: string, +): { starredChannelIds: Set; starChannel: (channelId: string) => void; unstarChannel: (channelId: string) => void; @@ -31,7 +34,7 @@ export function useChannelStars(pubkey: string | undefined): { const lastAppliedEventId = React.useRef(""); React.useEffect(() => { - if (!pubkey) { + if (!pubkey || !relayUrl) { setStore(DEFAULT_STORE); lastAppliedRemoteTs.current = 0; lastAppliedEventId.current = ""; @@ -40,12 +43,12 @@ export function useChannelStars(pubkey: string | undefined): { setStore(readChannelStarsStore(pubkey)); lastAppliedRemoteTs.current = 0; lastAppliedEventId.current = ""; - managerRef.current = new ChannelStarSyncManager(pubkey); + managerRef.current = new ChannelStarSyncManager(pubkey, relayUrl); return () => { managerRef.current?.destroy(); managerRef.current = null; }; - }, [pubkey]); + }, [pubkey, relayUrl]); React.useEffect(() => { if (!pubkey) { @@ -86,24 +89,22 @@ export function useChannelStars(pubkey: string | undefined): { ); React.useEffect(() => { - if (!pubkey) return; + if (!pubkey || !relayUrl) return; let cancelled = false; - void managerRef.current?.fetchRemoteStars().then((remote) => { + const local = readChannelStarsStore(pubkey); + void managerRef.current?.bootstrap(local).then((result) => { if (cancelled) return; - if (remote) { - setStore(applyRemote(remote)); - } else { - const local = readChannelStarsStore(pubkey); - if (Object.keys(local.channels).length > 0) { - managerRef.current?.publishStars(local); - } + if (result.action === "apply-remote") { + setStore(applyRemote(result.data)); } + // "hold": seed already performed by bootstrap (if first-sync), or blocked. }); return () => { cancelled = true; }; - }, [pubkey, applyRemote]); + }, [pubkey, relayUrl, applyRemote]); + // biome-ignore lint/correctness/useExhaustiveDependencies: relayUrl is intentional — rebinds subscription when the active relay changes even though it is not used inside the effect body directly (the manager via managerRef.current carries it) React.useEffect(() => { if (!pubkey) return; let unsub: (() => Promise) | null = null; @@ -124,16 +125,17 @@ export function useChannelStars(pubkey: string | undefined): { cancelled = true; if (unsub) void unsub(); }; - }, [pubkey, applyRemote]); + }, [pubkey, relayUrl, applyRemote]); + // biome-ignore lint/correctness/useExhaustiveDependencies: relayUrl is intentional — rebinds reconnect listener when the active relay changes (community switch) even though it is not referenced directly inside the effect body React.useEffect(() => { if (!pubkey) return; let cancelled = false; const unsub = relayClient.subscribeToReconnects(() => { - void managerRef.current?.fetchRemoteStars().then((remote) => { + void managerRef.current?.fetchRemoteStars().then((result) => { if (cancelled) return; - if (remote) { - setStore(applyRemote(remote)); + if (result.status === "found") { + setStore(applyRemote(result.data)); } const pending = managerRef.current?.getPendingStarStore(); if (pending) { @@ -145,7 +147,7 @@ export function useChannelStars(pubkey: string | undefined): { cancelled = true; unsub(); }; - }, [pubkey, applyRemote]); + }, [pubkey, relayUrl, applyRemote]); // biome-ignore lint/correctness/useExhaustiveDependencies: store.channels is the relevant dep — the outer store identity can change without channels changing (e.g., on reconnect writes) const starredChannelIds = React.useMemo( diff --git a/desktop/src/shared/lib/normalizeRelayUrl.ts b/desktop/src/shared/lib/normalizeRelayUrl.ts new file mode 100644 index 0000000000..7222b1fe25 --- /dev/null +++ b/desktop/src/shared/lib/normalizeRelayUrl.ts @@ -0,0 +1,8 @@ +/** + * Normalizes a relay URL for use in storage keys. + * Trim, strip trailing slashes, lowercase — ensures equivalent URLs map to + * the same key regardless of formatting differences. + */ +export function normalizeRelayUrl(relayUrl: string): string { + return relayUrl.trim().replace(/\/+$/, "").toLowerCase(); +} From 1399ec1d13c4560f50fd947e504deeea70929751 Mon Sep 17 00:00:00 2001 From: Tyler <109685178+tlongwell-block@users.noreply.github.com> Date: Thu, 6 Aug 2026 17:28:25 -0400 Subject: [PATCH 07/61] Alert community owners and admins when a new key joins (#4900) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Owners and admins of a Buzz community get a desktop notification the first time a new key joins their community. Requested by Tyler in buzz-development ("we already have this [roster] — can we alert owners and admins when a new key joins for the first time?"); design and verification thread: channel `community-members-visibility`. ## Why the shape is what it is - **kind:13534 membership snapshot is the alerting signal, not the kind:8000 delta.** 8000 is leaky on two independent axes: its fan-out is pod-local (no Redis hop — being fixed separately in #4887), and `buzz-admin add-member` publishes no 8000 at all by documented design. The 13534 snapshot is the only signal covering every production join path with cross-pod delivery (completeness audit: every membership-insertion path enumerated at base `8342dfcc5`, all emit 13534). - **This adds Desktop's first live 13534 subscription** — deliberate line item. The existing read (`relayMembers.ts`) is a one-shot fetch; without a live subscription no snapshot ever arrives passively and nothing could fire. - **8000 is subscribed only as a latency accelerator** and it *refetches the authoritative snapshot* rather than alerting from its own payload, so one ledger governs both signals and they cannot double-alert. - **Persisted per-community/per-viewer ledger, written before the notification fires.** Snapshot publication is eventual (60s reconciler repairs failed best-effort publishes) and a reconciler-republished snapshot is indistinguishable from a fresh one — only a durable record answers "is this new". Also what makes reconnect replay (`since - 5s` skew; `since === undefined` full-backlog edge) safe. - **First snapshot per community seeds silently** (no notification storm for existing members), and `seeded` is an explicit persisted bit — not inferred from ledger non-emptiness, which would swallow the first genuine join in a community whose only member is the viewer. - Mounted in `useAppShellDesktopNotifications` (owns the notifications-enabled precondition; `AppShell.tsx` is at the file-size ratchet ceiling — net growth zero). 5 files, +3103 (production +752, tests +2,351), desktop-only. No relay changes. ## Verification **Current reviewed tip: `1854c4a5` — review-blessed code at `d992ed295ead8c8423f81a752f4ad614718d85c6`** (clean tree, HEAD checked in the same shell as each gate; history is `0e791f2d3` → merge of main `2034e693a` → `fdeda44f0` → `5d0d2b4c3` → `a20a7d8cb` → merge of main `0cfe4832` → `d992ed29` → `1854c4a5`, all fast-forward, no rebase or force). Independently gated by Eva, Wren, and Sami: typecheck rc=0, `pnpm check` rc=0 (pre-existing 1 warning / 2 infos), full Desktop unit package **4431/4431**; push hooks pass. Wren's adversarial verdict at `d992ed29`: APPROVE — minimalness 9, elegance 9, correctness 9, all four cancellation seams plus 1b re-derived independently. `1854c4a5` is assertions and comments only — no production behaviour change, so the test count is unchanged. **Remediation commits (review thread `community-members-visibility`):** - `fdeda44f0` — authorization read from the signed snapshot being reconciled (a demoting/removing snapshot fails closed before it can disclose the joins it carries); >3 joins collapse to one summary; 8000-triggered refetches coalesce on a 500ms trailing window. - `5d0d2b4c3` — join alerts coalesce **across** snapshots, not just within one: a live burst arrives as several growing rosters, so delivery defers onto a 1.5s trailing quiet window while ledger persistence and dedupe stay synchronous per snapshot. Max measured 10 banners from 50 real joins before this; the same shape now produces one. - `a20a7d8cb` — cancellation covers flushes already in flight, not just queued timers: a generation token (bumped only by `clearPending`) is rechecked after the profile lookup and before every send, so demotion/removal/unmount/community-switch landing mid-flush suppresses delivery; the notification title is captured with the batch rather than read at send time. Concurrent-flush semantics pinned: a newer authorized batch neither cancels nor is cancelled by an in-flight flush. - `d992ed29` — the stale-authorized-frame disclosure, independently reproduced at `0cfe4832` (held-open refetch released after a newer demoting frame: `notifications=1`, body naming the joiner, where 0 is required). Three fixes in one shape: every callback acts on a per-effect-run session object (community id, viewer, ledger, ordering state) instead of ambient current values, closing the community-switch window; a `created_at` fence plus a fail-closed revocation latch, as one mechanism, because the relay can publish two snapshots in the same second so neither `<` nor `<=` alone is safe — the invariant is “revocation wins”, not “newest wins”; and a 5s clamp on the 1.5s trailing window so a sustained drip cannot defer delivery without bound. Red-first: the four new arms fail at `0cfe4832` (25/29) and pass after (29/29). - `1854c4a5` — the privacy arm now asserts the persisted ledger is unchanged across the delayed frame's release, not only the notification count. Mutation-checked: moving the revoked check after the ledger advance keeps notifications at 0 and passes the old assertion, and is killed by the new one. Assertions and comments only. **Mutation testing:** 9/9 mounted-hook mutants killed at `a20a7d8cb`, each with a control row before and after — role/enabled gates, reconnect, 8000 authority, failed-write handling and ref ordering, community re-key/read, and query invalidation. The reducer/storage fix separately killed 6/6 mutants with 15/0 controls; the foundational ledger suite killed 9/9. At `d992ed29`: spelling the fence `<=` kills 5 arms; moving the empty-roster guard after the fence advance kills exactly the fence-advance arm and nothing else (28/29). One qualification stated rather than buried — moving the fence advance itself up to the comparison SURVIVES the whole suite. That is an equivalent mutant, not a coverage gap: the empty-roster guard returns before the comparison, and authorization rejection latches `revoked` so a later frame having moved the fence is unobservable. The scope is written into the test's docstring. At `1854c4a5`: the revoked-check-after-ledger-advance mutant is killed by the new ledger assertion (and by the 1b arm). **Scale/storage correction in `f6e5a3c57`:** the original 5,000-key cap could evict members still present in a 5,001+ roster, causing them to re-alert on every snapshot; read-time truncation reopened the same loop after reload; and a raw quota exception could reject before notification dispatch. The fix retains every on-roster key, caps only departed keys, removes read-time truncation, and uses the app's quota-aware writer. **Final ordering correction in `0e791f2d3`:** a failed post-recovery write now skips notification and leaves the in-memory ledger unchanged, so the next snapshot retries and delivers only after persistence succeeds. **Live-local matrix vs a real relay, executed at exact unchanged `d75cc6cd9` and transferred to the current tip:** a 4,800-sequence differential found zero old/new reducer divergences below the cap while exercising the positive alert path; its negative control diverged as required at 5,100 members (old re-alerts 100; new re-alerts 0). The final hook change affects only the newly tested failed-write branch; successful writes follow the same alert path exercised live. The live communities were sub-cap and persisted successfully, so the matrix remains applicable without a redundant rerun. - Invite claim: owner and admin each exactly one notification; plain member zero; 1.5s quiet window held (8000+13534 deduped); both open clients live-refreshed the roster. Screenshot receipts SHA-256-pinned and independently replicated. - **CLI `buzz-admin add-member` (13534-only path):** DB counts moved 8000 `9→9`, 13534 `15→16` — zero accelerator events, exactly one alert per manager. Proves snapshot-diff alone alerts. - Plain member: zero notifications **and** zero `buzz-community-join-seen.v1:*` localStorage keys before/after the join (gate sits before the ledger). - Staggered reload + replay dedupe: no alerts from startup refetch/replay; republished already-seen snapshot produced zero through a 2s quiet window. - Community switch: independent per-community seed state; effect re-keys; one alert per community, quiet window held at exactly two. **Live re-verification at `d992ed29` is in progress** (Max; the after-fix matrix leads with the delayed-refetch demotion arm, A→B switch ledger isolation, the 5s sustained-drip timing, and packaged-app click routing behind the positive/NIP-43 controls); earlier receipts at `a20a7d8cb` cover the instrumented storm and cap-boundary re-drive; earlier live receipts at `fdeda44f0` — privacy matrix (demote/remove/promote), summary click-through — transfer where the diff left those paths untouched. ## Known and accepted - **8000 cross-pod fan-out is broken relay-side** — fixed in #4887 (separate lane, not a blocker here): on a multi-pod relay the accelerator only fires on the claim-handling pod; 13534 still covers everyone, just not instantly. - **Late-not-lost semantics.** A live frame missed during a reload/socket gap is recovered by the next snapshot, reconnect refetch, or remount backfill (`limit: 1`) diffed against the persisted ledger. One live-run observation of an admin missing an immediate post-reload fresh join is attributed to harness rate limiting; the recovery paths above bound the damage to lateness, never duplicates. - **Remote promotion activates on reload, not on the next snapshot** (measured by Sami at `fdeda44f0`): the subscriptions are mounted from the cached membership lookup, so a viewer promoted to admin by someone else starts receiving join alerts only after a reload, community switch, or local membership mutation refreshes that cache. Fails safe (under-notify). Ruled accepted for v1 by Eva; the fix direction (subscribing before authorization) is a deliberate design change deferred to a follow-up if product wants instant activation. - **Cross-user live-delivery staleness reproduced at the PR's own base** (`2034e693a`, clean relay): a persisted send can fail to appear in an already-open recipient timeline. Detached from this PR by a pinned-base discriminator (identical failure with zero PR code) and tracked separately in issue `6e2bda3092fa`; current main passes 4/4. - **A stale demoting frame latches a genuine admin until reload or community switch** (reverse ordering of the stale-frame privacy race, `d992ed29`): if a snapshot that does not list the viewer as a manager arrives out of order, the fail-closed revocation latch trips even though the viewer is still an admin. The invalidation the latch fires refetches the membership lookup, which correctly returns admin, so `active` stays true, the effect deps do not change, and the session stays latched. Fails safe (under-notify, never over-disclose) and consistent with the promotion-on-reload semantics above. Ruled accepted for v1 by Eva; self-clearing the latch would cost a third piece of timing state. Pinned as documented behaviour in `useCommunityJoinAlerts.test.mjs` — and the suppressed join is re-announced rather than lost, because a latched session never records it in the ledger. - **Lifetime-first-only semantics:** ever-seen ledger means remove→re-add does not re-alert. Flagged for product ruling; one-line change if re-adds should ping. --------- Signed-off-by: Sami Signed-off-by: Eva <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz> Co-authored-by: Sami Co-authored-by: Eva <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz> --- .../app/useAppShellDesktopNotifications.ts | 8 + .../community-members/lib/joinAlerts.test.mjs | 265 ++ .../community-members/lib/joinAlerts.ts | 227 ++ .../useCommunityJoinAlerts.test.mjs | 2236 +++++++++++++++++ .../useCommunityJoinAlerts.ts | 538 ++++ 5 files changed, 3274 insertions(+) create mode 100644 desktop/src/features/community-members/lib/joinAlerts.test.mjs create mode 100644 desktop/src/features/community-members/lib/joinAlerts.ts create mode 100644 desktop/src/features/community-members/useCommunityJoinAlerts.test.mjs create mode 100644 desktop/src/features/community-members/useCommunityJoinAlerts.ts diff --git a/desktop/src/app/useAppShellDesktopNotifications.ts b/desktop/src/app/useAppShellDesktopNotifications.ts index f739e900df..6792faf21a 100644 --- a/desktop/src/app/useAppShellDesktopNotifications.ts +++ b/desktop/src/app/useAppShellDesktopNotifications.ts @@ -5,6 +5,7 @@ import { toSearchHit, } from "@/app/AppShell.helpers"; import { getThreadReference } from "@/features/messages/lib/threading"; +import { useCommunityJoinAlerts } from "@/features/community-members/useCommunityJoinAlerts"; import { hasMentionForEvent } from "@/features/notifications/lib/shouldNotify"; import type { NotificationSettings } from "@/features/notifications/hooks"; import { @@ -45,6 +46,13 @@ export function useAppShellDesktopNotifications({ pubkey?: string; silentChannelIds?: ReadonlySet; }) { + // Roster alerts are owner/admin-only and self-gating; mounted here because + // it shares this hook's "desktop notifications are on" precondition and + // AppShell sits at the file-size ratchet ceiling. + useCommunityJoinAlerts({ + enabled: enabled && notificationSettings.desktopEnabled, + }); + const handleChannelNotification = React.useEffectEvent( (_channelId: string, event: RelayEvent) => { if (!enabled) return; diff --git a/desktop/src/features/community-members/lib/joinAlerts.test.mjs b/desktop/src/features/community-members/lib/joinAlerts.test.mjs new file mode 100644 index 0000000000..f7346bd5e9 --- /dev/null +++ b/desktop/src/features/community-members/lib/joinAlerts.test.mjs @@ -0,0 +1,265 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + EMPTY_JOIN_ALERT_LEDGER, + JOIN_ALERT_DEPARTED_MAX_ITEMS, + joinAlertBody, + joinAlertTitle, + readJoinAlertLedger, + reconcileJoinAlertLedger, + writeJoinAlertLedger, +} from "./joinAlerts.ts"; + +const COMMUNITY = "community-1"; +const OWNER = "a".repeat(64); +const ALICE = "b".repeat(64); +const BOB = "c".repeat(64); + +function installLocalStorage({ throwOnSet = false } = {}) { + const values = new Map(); + globalThis.window = { + localStorage: { + get length() { + return values.size; + }, + key: (index) => [...values.keys()][index] ?? null, + getItem: (key) => values.get(key) ?? null, + setItem: (key, value) => { + if (throwOnSet) { + const error = new Error("quota exceeded"); + error.name = "QuotaExceededError"; + throw error; + } + values.set(key, value); + }, + removeItem: (key) => values.delete(key), + }, + }; + return values; +} + +/** Fold a roster in and persist, the way the hook does. */ +function applySnapshot(ledger, rosterPubkeys) { + const result = reconcileJoinAlertLedger({ + ledger, + rosterPubkeys, + viewerPubkey: OWNER, + }); + if (result.changed) { + writeJoinAlertLedger(COMMUNITY, OWNER, result.ledger); + } + return result; +} + +test("first snapshot seeds an existing roster without alerting", () => { + installLocalStorage(); + + const result = applySnapshot(EMPTY_JOIN_ALERT_LEDGER, [OWNER, ALICE, BOB]); + + assert.deepEqual(result.alerts, []); + assert.equal(result.ledger.seeded, true); + assert.deepEqual(result.ledger.pubkeys, [ALICE, BOB]); +}); + +test("a key joining after the seed alerts exactly once", () => { + installLocalStorage(); + + const seeded = applySnapshot(EMPTY_JOIN_ALERT_LEDGER, [OWNER, ALICE]).ledger; + const joined = applySnapshot(seeded, [OWNER, ALICE, BOB]); + + assert.deepEqual(joined.alerts, [BOB]); + + // A redelivered identical snapshot must not re-alert or rewrite. + const redelivered = applySnapshot(joined.ledger, [OWNER, ALICE, BOB]); + assert.deepEqual(redelivered.alerts, []); + assert.equal(redelivered.changed, false); +}); + +test("a community seeded with only the viewer still alerts on the first join", () => { + // Regression: inferring "seeded" from a non-empty ledger classified this + // first genuine join as the seeding run and dropped the alert silently. + installLocalStorage(); + + const seeded = applySnapshot(EMPTY_JOIN_ALERT_LEDGER, [OWNER]); + assert.deepEqual(seeded.alerts, []); + assert.deepEqual(seeded.ledger.pubkeys, []); + assert.equal(seeded.ledger.seeded, true); + + const joined = applySnapshot(seeded.ledger, [OWNER, ALICE]); + assert.deepEqual(joined.alerts, [ALICE]); +}); + +test("the seeded flag survives a reload through storage", () => { + installLocalStorage(); + + applySnapshot(EMPTY_JOIN_ALERT_LEDGER, [OWNER]); + const reloaded = readJoinAlertLedger(COMMUNITY, OWNER); + + assert.equal(reloaded.seeded, true); + assert.deepEqual(reloaded.pubkeys, []); + assert.deepEqual(applySnapshot(reloaded, [OWNER, ALICE]).alerts, [ALICE]); +}); + +test("remove then re-add does not alert a second time", () => { + installLocalStorage(); + + const seeded = applySnapshot(EMPTY_JOIN_ALERT_LEDGER, [OWNER]).ledger; + assert.deepEqual(applySnapshot(seeded, [OWNER, ALICE]).alerts, [ALICE]); + + const afterRemoval = applySnapshot(readJoinAlertLedger(COMMUNITY, OWNER), [ + OWNER, + ]); + assert.deepEqual(afterRemoval.alerts, []); + + const afterReAdd = applySnapshot(readJoinAlertLedger(COMMUNITY, OWNER), [ + OWNER, + ALICE, + ]); + assert.deepEqual(afterReAdd.alerts, []); +}); + +test("the kind:8000 accelerator and the live snapshot yield one alert", () => { + installLocalStorage(); + + const seeded = applySnapshot(EMPTY_JOIN_ALERT_LEDGER, [OWNER]).ledger; + + // Delta arrives first and triggers a snapshot refetch... + const viaDelta = applySnapshot(seeded, [OWNER, ALICE]); + assert.deepEqual(viaDelta.alerts, [ALICE]); + + // ...then the live 13534 for the same join lands. + const viaLive = applySnapshot(readJoinAlertLedger(COMMUNITY, OWNER), [ + OWNER, + ALICE, + ]); + assert.deepEqual(viaLive.alerts, []); +}); + +test("the viewer is never alerted on or recorded", () => { + installLocalStorage(); + + const seeded = applySnapshot(EMPTY_JOIN_ALERT_LEDGER, [ALICE]).ledger; + const result = applySnapshot(seeded, [ALICE, OWNER]); + + assert.deepEqual(result.alerts, []); + assert.equal(result.changed, false); + assert.equal(result.ledger.pubkeys.includes(OWNER), false); +}); + +test("roster pubkeys are matched case-insensitively", () => { + installLocalStorage(); + + const seeded = applySnapshot(EMPTY_JOIN_ALERT_LEDGER, [OWNER]).ledger; + const joined = applySnapshot(seeded, [OWNER, ALICE.toUpperCase()]); + + assert.deepEqual(joined.alerts, [ALICE]); + assert.deepEqual(applySnapshot(joined.ledger, [OWNER, ALICE]).alerts, []); +}); + +test("a duplicated pubkey in one snapshot alerts once", () => { + installLocalStorage(); + + const seeded = applySnapshot(EMPTY_JOIN_ALERT_LEDGER, [OWNER]).ledger; + const joined = applySnapshot(seeded, [OWNER, ALICE, ALICE]); + + assert.deepEqual(joined.alerts, [ALICE]); + assert.deepEqual(joined.ledger.pubkeys, [ALICE]); +}); + +test("a ledger stored before the seeded flag existed is treated as seeded", () => { + const values = installLocalStorage(); + const [key] = [...values.keys()]; + writeJoinAlertLedger(COMMUNITY, OWNER, { seeded: true, pubkeys: [ALICE] }); + const storageKey = key ?? [...values.keys()][0]; + values.set(storageKey, JSON.stringify({ pubkeys: [ALICE] })); + + const ledger = readJoinAlertLedger(COMMUNITY, OWNER); + assert.equal(ledger.seeded, true); + assert.deepEqual(applySnapshot(ledger, [OWNER, ALICE, BOB]).alerts, [BOB]); +}); + +test("unreadable storage reads as an unseeded ledger", () => { + const values = installLocalStorage(); + writeJoinAlertLedger(COMMUNITY, OWNER, { seeded: true, pubkeys: [ALICE] }); + values.set([...values.keys()][0], "{not json"); + + assert.deepEqual(readJoinAlertLedger(COMMUNITY, OWNER), { + seeded: false, + pubkeys: [], + }); +}); + +test("a roster larger than the departed cap never re-alerts its own members", () => { + // Regression: capping *all* retained keys shed pubkeys that were still on the + // roster, so the next snapshot saw them as unknown and alerted again — every + // snapshot, forever, for any community past the cap. + installLocalStorage(); + + const roster = Array.from( + { length: JOIN_ALERT_DEPARTED_MAX_ITEMS + 100 }, + (_unused, index) => index.toString(16).padStart(64, "0"), + ); + + const seeded = applySnapshot(EMPTY_JOIN_ALERT_LEDGER, roster); + assert.deepEqual(seeded.alerts, []); + assert.equal(seeded.ledger.pubkeys.length, roster.length); + + for (let pass = 0; pass < 3; pass++) { + const repeat = applySnapshot(readJoinAlertLedger(COMMUNITY, OWNER), roster); + assert.deepEqual(repeat.alerts, []); + assert.equal(repeat.changed, false); + } + + // The read path must not truncate either: a stored ledger above the cap has + // to come back whole or the same re-alert loop reopens on reload. + assert.equal( + readJoinAlertLedger(COMMUNITY, OWNER).pubkeys.length, + roster.length, + ); +}); + +test("the cap sheds only departed pubkeys, oldest first", () => { + installLocalStorage(); + + const roster = Array.from( + { length: JOIN_ALERT_DEPARTED_MAX_ITEMS + 10 }, + (_unused, index) => index.toString(16).padStart(64, "0"), + ); + const seeded = applySnapshot(EMPTY_JOIN_ALERT_LEDGER, roster).ledger; + + // Everyone leaves except the newest member; one new key joins. + const survivor = roster.at(-1); + const shrunk = applySnapshot(seeded, [OWNER, survivor, BOB]); + + assert.deepEqual(shrunk.alerts, [BOB]); + // 5010 retained - 9 departed over the cap, plus BOB. + assert.equal(shrunk.ledger.pubkeys.length, roster.length - 9 + 1); + assert.equal(shrunk.ledger.pubkeys.includes(roster[0]), false); + assert.equal(shrunk.ledger.pubkeys.includes(roster[8]), false); + assert.equal(shrunk.ledger.pubkeys.includes(roster[9]), true); + // The on-roster key is retained no matter where it sits in insertion order. + assert.equal(shrunk.ledger.pubkeys.includes(survivor), true); +}); + +test("a write that cannot land is reported, not thrown", () => { + // The writer runs inside an async snapshot handler: a raw QuotaExceededError + // would reject before the notification is sent, on every snapshot. + installLocalStorage({ throwOnSet: true }); + + assert.equal( + writeJoinAlertLedger(COMMUNITY, OWNER, { seeded: true, pubkeys: [ALICE] }), + false, + ); + assert.deepEqual(readJoinAlertLedger(COMMUNITY, OWNER), { + seeded: false, + pubkeys: [], + }); +}); + +test("notification copy names the community when known", () => { + assert.equal(joinAlertTitle("Buzz HQ"), "New member in Buzz HQ"); + assert.equal(joinAlertTitle(" "), "New community member"); + assert.equal(joinAlertTitle(null), "New community member"); + assert.equal(joinAlertBody("Alice"), "Alice joined"); +}); diff --git a/desktop/src/features/community-members/lib/joinAlerts.ts b/desktop/src/features/community-members/lib/joinAlerts.ts new file mode 100644 index 0000000000..2a3ed3fa95 --- /dev/null +++ b/desktop/src/features/community-members/lib/joinAlerts.ts @@ -0,0 +1,227 @@ +/** + * First-join alert bookkeeping for community owners/admins. + * + * # Why the roster snapshot is the source of truth, not the kind:8000 delta + * + * The relay emits a kind:8000 "member-added" delta on the invite-claim and + * relay-admin paths, but `buzz-admin add-member` deliberately emits none + * (`crates/buzz-admin/src/main.rs:6-13`), and kind:8000 fan-out is pod-local + * (`fan_out_event_to_local_subscribers` never calls `publish_event`, unlike + * `dispatch_persistent_event_inner`). The kind:13534 membership snapshot is the + * only signal that covers every join path *and* propagates across pods, so it + * is the correctness signal here; kind:8000 is a latency accelerator only. + * + * # Why a persisted ledger rather than snapshot-to-snapshot diffing + * + * Snapshot publication is eventual, not transactional: a failed post-commit + * publish is repaired by the relay's periodic reconciler, so the same member + * can first appear in a snapshot arriving up to a reconcile interval late, and + * a reconciler-published snapshot is indistinguishable from a fresh one. Only a + * ledger of pubkeys we have already alerted on can answer "is this new to the + * user", which is the question the notification actually asks. The ledger also + * absorbs kind:8000 redelivery on reconnect, where the replay filter re-sends + * events at or after `lastSeenCreatedAt - skew` and can repeat a seen delta. + */ + +import { setLocalStorageItemWithRecovery } from "@/shared/lib/localStorageQuota"; + +const JOIN_ALERT_STORAGE_PREFIX = "buzz-community-join-seen.v1"; + +/** + * Cap on *departed* pubkeys retained per community. + * + * A pubkey still on the roster can never be shed: the next snapshot presents it + * again, the ledger no longer recognizes it, and it is alerted as a fresh join + * — on every snapshot, forever. So the cap bounds only the tail of keys that + * have left, and the ledger's real ceiling is the roster the relay can deliver + * (a kind:13534 snapshot larger than `BUZZ_MAX_FRAME_BYTES` never arrives). + */ +export const JOIN_ALERT_DEPARTED_MAX_ITEMS = 5_000; + +export type JoinAlertLedger = { + /** + * Whether a roster snapshot has already been folded in for this community. + * + * Tracked explicitly rather than inferred from `pubkeys.length > 0`, because + * the two are not the same proposition: a community whose only member is the + * viewer seeds to an *empty* pubkey list (the viewer is never recorded), and + * inferring from emptiness would then classify the first genuine join as the + * seeding run and silently swallow the very alert this feature exists for. + */ + seeded: boolean; + /** Pubkeys already alerted on, oldest first. */ + pubkeys: string[]; +}; + +export const EMPTY_JOIN_ALERT_LEDGER: JoinAlertLedger = { + seeded: false, + pubkeys: [], +}; + +export function joinAlertStorageKey(communityId: string, viewerPubkey: string) { + return `${JOIN_ALERT_STORAGE_PREFIX}:${communityId}:${viewerPubkey}`; +} + +export function normalizeJoinPubkey(pubkey: string): string { + return pubkey.trim().toLowerCase(); +} + +export function readJoinAlertLedger( + communityId: string, + viewerPubkey: string, +): JoinAlertLedger { + if ( + typeof window === "undefined" || + communityId.length === 0 || + viewerPubkey.length === 0 + ) { + return EMPTY_JOIN_ALERT_LEDGER; + } + + const rawValue = window.localStorage.getItem( + joinAlertStorageKey(communityId, viewerPubkey), + ); + if (!rawValue) { + return EMPTY_JOIN_ALERT_LEDGER; + } + + try { + const parsed: unknown = JSON.parse(rawValue); + if (parsed === null || typeof parsed !== "object") { + return EMPTY_JOIN_ALERT_LEDGER; + } + + const { pubkeys, seeded } = parsed as Partial; + if (!Array.isArray(pubkeys)) { + return EMPTY_JOIN_ALERT_LEDGER; + } + + return { + // A stored ledger is by definition the residue of a snapshot we already + // folded in, so unreadable/absent `seeded` reads as true. Defaulting the + // other way would re-seed and drop a real join. + seeded: seeded !== false, + pubkeys: pubkeys.filter( + (value): value is string => typeof value === "string", + ), + }; + } catch { + return EMPTY_JOIN_ALERT_LEDGER; + } +} + +/** + * Persist the ledger. Returns false when the write did not land. + * + * Routed through the quota-aware writer rather than `localStorage.setItem`: + * this runs inside an async snapshot handler, where a raw QuotaExceededError + * would reject before the notification is ever sent, and it would do so on + * every subsequent snapshot too. + */ +export function writeJoinAlertLedger( + communityId: string, + viewerPubkey: string, + ledger: JoinAlertLedger, +): boolean { + if ( + typeof window === "undefined" || + communityId.length === 0 || + viewerPubkey.length === 0 + ) { + return false; + } + + return setLocalStorageItemWithRecovery( + joinAlertStorageKey(communityId, viewerPubkey), + JSON.stringify(ledger satisfies JoinAlertLedger), + ); +} + +/** + * Fold a roster snapshot into the ledger, returning the pubkeys to alert on. + * + * The viewer's own pubkey is never alerted on or recorded: an owner does not + * need to be told they joined their own community. + * + * `alerts` is empty on the seeding run — the first snapshot for a community + * records every existing member silently, so installing the app against an + * established roster does not produce a notification per member. + */ +export function reconcileJoinAlertLedger({ + ledger, + rosterPubkeys, + viewerPubkey, +}: { + ledger: JoinAlertLedger; + rosterPubkeys: readonly string[]; + viewerPubkey: string; +}): { alerts: string[]; changed: boolean; ledger: JoinAlertLedger } { + const normalizedViewer = normalizeJoinPubkey(viewerPubkey); + const seen = new Set(ledger.pubkeys); + const roster = new Set(); + const fresh: string[] = []; + + for (const rawPubkey of rosterPubkeys) { + const pubkey = normalizeJoinPubkey(rawPubkey); + if (pubkey.length === 0) continue; + if (pubkey === normalizedViewer) continue; + roster.add(pubkey); + if (seen.has(pubkey)) continue; + seen.add(pubkey); + fresh.push(pubkey); + } + + if (fresh.length === 0 && ledger.seeded) { + return { alerts: [], changed: false, ledger }; + } + + // Shed only pubkeys absent from the roster we were just handed. Capping the + // whole ledger instead would evict keys that are still members, and every + // later snapshot would then re-alert them — permanently, once the roster + // passes the cap. + const departed = ledger.pubkeys.filter((pubkey) => !roster.has(pubkey)); + const shedCount = departed.length - JOIN_ALERT_DEPARTED_MAX_ITEMS; + const shed = shedCount > 0 ? new Set(departed.slice(0, shedCount)) : null; + const retained = + shed === null + ? ledger.pubkeys + : ledger.pubkeys.filter((pubkey) => !shed.has(pubkey)); + + return { + alerts: ledger.seeded ? fresh : [], + changed: true, + ledger: { + seeded: true, + pubkeys: [...retained, ...fresh], + }, + }; +} + +/** Notification copy for a single first join. */ +export function joinAlertTitle(communityName: string | null | undefined) { + const trimmed = communityName?.trim(); + return trimmed && trimmed.length > 0 + ? `New member in ${trimmed}` + : "New community member"; +} + +export function joinAlertBody(displayName: string) { + return `${displayName} joined`; +} + +/** + * Most per-key notifications emitted for a single snapshot. + * + * Above this, one summary replaces the batch. A snapshot is a whole roster, not + * an event per join, so a bulk import or an invite link shared into a group + * chat lands every new key at once: without a cap that is one OS notification + * per member (measured: a 250-key snapshot emitted 248 banners in a serial + * loop). The cap is deliberately small — past a handful the individual + * identities are unreadable as notifications anyway, and the useful signal is + * that a batch arrived. + */ +export const JOIN_ALERT_MAX_INDIVIDUAL = 3; + +export function joinAlertSummaryBody(count: number) { + return `${count} new members joined`; +} diff --git a/desktop/src/features/community-members/useCommunityJoinAlerts.test.mjs b/desktop/src/features/community-members/useCommunityJoinAlerts.test.mjs new file mode 100644 index 0000000000..7b34b90161 --- /dev/null +++ b/desktop/src/features/community-members/useCommunityJoinAlerts.test.mjs @@ -0,0 +1,2236 @@ +/** + * Mounted-hook tests for useCommunityJoinAlerts. + * + * The ledger reducer is covered by lib/joinAlerts.test.mjs. Nothing there + * exercises the parts of this feature that only exist once the hook is + * mounted, and those are exactly the parts a unit test cannot reach: + * + * - the owner/admin gate sitting BEFORE any storage access, so a plain + * member creates no ledger key at all; + * - the reconnect arm, which refetches the snapshot across a socket gap and + * must not re-alert keys the ledger already carries; + * - the effect re-key on community switch, so each community gets its own + * subscription and its own seed state; + * - the kind:8000 arm refetching the authoritative snapshot rather than + * alerting from the delta's own payload. + * + * Max's live-local matrix could not land the reconnect arm (simultaneous + * browser reloads tripped relay rate limiting) and did not exercise community + * switch, so these are the only evidence for those two paths. + * + * ── Harness shape ──────────────────────────────────────────────────────────── + * Same pattern as useLoadArchivedObserverEvents.test.mjs: minimal DOM shim → + * __TAURI_INTERNALS__.invoke interception → production imports → createRoot/act + * inside a QueryClientProvider. relayClient's three entry points are replaced + * with mock.method so no socket is opened; window.Notification is stubbed so + * sendDesktopNotification takes its real permission-granted path and we can + * count what it emitted. + */ + +import assert from "node:assert/strict"; +import { describe, it, beforeEach, afterEach, mock } from "node:test"; + +// ── Minimal DOM shim ───────────────────────────────────────────────────────── + +function installDOMShim() { + class MinimalEventTarget { + constructor() { + this._listeners = {}; + } + addEventListener(type, fn) { + if (!this._listeners[type]) this._listeners[type] = []; + this._listeners[type].push(fn); + } + removeEventListener(type, fn) { + if (this._listeners[type]) { + this._listeners[type] = this._listeners[type].filter((f) => f !== fn); + } + } + dispatchEvent(e) { + for (const fn of this._listeners[e.type] ?? []) fn(e); + return true; + } + } + + class MinimalNode extends MinimalEventTarget { + constructor(tagName) { + super(); + this.tagName = tagName; + this.children = []; + this.childNodes = []; + this.style = {}; + this.nodeType = 1; + this.parentNode = null; + } + get ownerDocument() { + return globalThis.document; + } + get firstChild() { + return this.children[0] ?? null; + } + get lastChild() { + return this.children[this.children.length - 1] ?? null; + } + get nextSibling() { + return null; + } + get nodeValue() { + return null; + } + appendChild(child) { + this.children.push(child); + this.childNodes.push(child); + child.parentNode = this; + return child; + } + removeChild(child) { + this.children = this.children.filter((c) => c !== child); + this.childNodes = this.childNodes.filter((c) => c !== child); + return child; + } + insertBefore(newNode, refNode) { + if (!refNode) return this.appendChild(newNode); + const i = this.children.indexOf(refNode); + if (i < 0) return this.appendChild(newNode); + this.children.splice(i, 0, newNode); + this.childNodes.splice(i, 0, newNode); + newNode.parentNode = this; + return newNode; + } + contains(node) { + if (!node) return false; + return this === node || this.children.some((c) => c?.contains?.(node)); + } + } + + class MinimalDocument extends MinimalEventTarget { + constructor() { + super(); + this.nodeType = 9; + } + createElement(tagName) { + return new MinimalNode(tagName); + } + createTextNode(value) { + const n = new MinimalNode("#text"); + n.nodeValue = value; + n.nodeType = 3; + return n; + } + createComment(value) { + const n = new MinimalNode("#comment"); + n.nodeValue = value; + n.nodeType = 8; + return n; + } + get body() { + if (!this._body) this._body = this.createElement("body"); + return this._body; + } + get activeElement() { + return null; + } + contains(node) { + return node != null; + } + } + + globalThis.document = new MinimalDocument(); + globalThis.HTMLElement = MinimalNode; + // react-dom's commit phase does `element instanceof window.HTMLIFrameElement` + // (getActiveElementDeep, react-dom-client.development.js:3667). Leaving it + // undefined throws "Right-hand side of 'instanceof' is not an object" out of + // commitRoot, before any assertion runs. + globalThis.HTMLIFrameElement = MinimalNode; + globalThis.IS_REACT_ACT_ENVIRONMENT = true; + process.env.IS_REACT_ACT_ENVIRONMENT = "true"; + + if (typeof globalThis.window === "undefined") { + Object.defineProperty(globalThis, "window", { + value: globalThis, + configurable: true, + }); + } + if (!Object.getOwnPropertyDescriptor(globalThis, "navigator")?.value) { + Object.defineProperty(globalThis, "navigator", { + value: { userAgent: "node" }, + configurable: true, + }); + } + globalThis.MutationObserver = class { + observe() {} + disconnect() {} + takeRecords() { + return []; + } + }; + globalThis.requestAnimationFrame = (fn) => setTimeout(fn, 0); +} + +installDOMShim(); + +// ── localStorage shim ──────────────────────────────────────────────────────── +// +// Backs the real production ledger read/write. Kept as a plain Map so a test +// can inspect exactly which keys the feature created — the plain-member arm +// asserts on key ABSENCE, so a shim that silently swallows writes would make +// that assertion vacuous. + +const storage = new Map(); +/** When true the shim rejects writes the way a full origin quota does. */ +let storageFull = false; + +globalThis.localStorage = { + get length() { + return storage.size; + }, + key: (index) => [...storage.keys()][index] ?? null, + getItem: (key) => storage.get(key) ?? null, + setItem: (key, value) => { + if (storageFull) { + const error = new Error("QuotaExceededError"); + error.name = "QuotaExceededError"; + throw error; + } + storage.set(key, value); + }, + removeItem: (key) => storage.delete(key), + clear: () => storage.clear(), +}; +globalThis.window.localStorage = globalThis.localStorage; + +// ── Notification shim ──────────────────────────────────────────────────────── +// +// sendDesktopNotification returns false unless permission is "granted", so +// without this every alert assertion would pass for the wrong reason (silent +// success). Recording the constructor calls is how we count alerts. + +const notifications = []; +/** + * Optional hook fired synchronously from inside the Notification constructor. + * + * The named-alert loop awaits each send, so "a demotion lands between send 1 + * and send 2" is only expressible from inside a send. Nothing else in the + * harness can reach that point in the loop. + */ +let onNotification = null; + +class StubNotification { + static permission = "granted"; + constructor(title, options) { + notifications.push({ title, body: options?.body, options }); + if (onNotification) onNotification(notifications.length); + } + close() {} +} + +globalThis.Notification = StubNotification; +globalThis.window.Notification = StubNotification; + +// ── Tauri IPC interceptor ──────────────────────────────────────────────────── + +/** @type {Map Promise>} */ +const ipcHandlers = new Map(); + +globalThis.__TAURI_INTERNALS__ = { + invoke: (cmd, args) => { + const handler = ipcHandlers.get(cmd); + if (handler) return handler(args); + return Promise.reject(new Error(`unmocked Tauri command: ${cmd}`)); + }, + transformCallback: () => Math.random(), +}; + +// ── Production imports (after shims) ───────────────────────────────────────── + +import React from "react"; +import { createRoot } from "react-dom/client"; +import { act } from "react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +import { useCommunityJoinAlerts } from "@/features/community-members/useCommunityJoinAlerts.ts"; +import { joinAlertStorageKey } from "@/features/community-members/lib/joinAlerts.ts"; +import { relayClient } from "@/shared/api/relayClient.ts"; +import { CommunitiesProvider } from "@/features/communities/useCommunities.tsx"; +import { useCommunities } from "@/features/communities/useCommunities.tsx"; +import { + myRelayMembershipLookupQueryKey, + relayMembersQueryKey, + useRelayMembersQuery, +} from "@/features/community-members/hooks.ts"; + +// ── Constants ──────────────────────────────────────────────────────────────── + +const VIEWER = "a".repeat(64); +const ALICE = "b".repeat(64); +const BOB = "c".repeat(64); +const CAROL = "d".repeat(64); +const COMMUNITY_A = "community-a"; +const COMMUNITY_B = "community-b"; + +const KIND_SNAPSHOT = 13534; +const KIND_MEMBER_ADDED = 8000; + +/** + * A kind:13534 membership snapshot carrying the given roster. + * + * The viewer is stamped `owner` unless `viewerRole` says otherwise, mirroring + * the relay: `publish_nip43_membership_locked` emits `["member", pubkey, role]` + * for every row, so the viewer's own authorization always rides in the + * snapshot. A fixture that stamped everyone `member` could not express the + * demotion this hook now gates on. + */ +function snapshot( + rosterPubkeys, + { id = "snap-1", createdAt = 1000, viewerRole = "owner" } = {}, +) { + return { + id, + pubkey: "f".repeat(64), + created_at: createdAt, + kind: KIND_SNAPSHOT, + tags: rosterPubkeys.map((pubkey) => [ + "member", + pubkey, + pubkey === VIEWER ? viewerRole : "member", + ]), + content: "", + sig: "s".repeat(128), + }; +} + +/** Seed the communities the provider will load from localStorage. */ +function seedCommunities(activeId) { + storage.set( + "buzz-communities", + JSON.stringify([ + { + id: COMMUNITY_A, + name: "Community A", + relayUrl: "wss://a.test", + addedAt: "2026-01-01T00:00:00Z", + }, + { + id: COMMUNITY_B, + name: "Community B", + relayUrl: "wss://b.test", + addedAt: "2026-01-01T00:00:00Z", + }, + ]), + ); + storage.set("buzz-active-community-id", activeId); +} + +/** + * Replace relayClient's three entry points and hand the test direct control of + * every callback the hook registers. + */ +function installRelayStub() { + /** @type {Map void>>} */ + const liveByKind = new Map(); + const reconnectListeners = []; + let fetchFirstEventCalls = 0; + let nextSnapshot = null; + let subscribeCount = 0; + let unsubscribeCount = 0; + /** When set, `fetchFirstEvent` parks here before resolving. */ + let fetchGate = null; + + mock.method(relayClient, "subscribeLive", async (filter, onEvent) => { + subscribeCount++; + const kind = filter.kinds[0]; + if (!liveByKind.has(kind)) liveByKind.set(kind, []); + liveByKind.get(kind).push(onEvent); + return async () => { + unsubscribeCount++; + const list = liveByKind.get(kind) ?? []; + liveByKind.set( + kind, + list.filter((fn) => fn !== onEvent), + ); + }; + }); + + mock.method(relayClient, "fetchFirstEvent", async () => { + fetchFirstEventCalls++; + if (fetchGate) await fetchGate; + return nextSnapshot; + }); + + mock.method(relayClient, "subscribeToReconnects", (listener) => { + reconnectListeners.push(listener); + return () => { + const i = reconnectListeners.indexOf(listener); + if (i >= 0) reconnectListeners.splice(i, 1); + }; + }); + + return { + /** Deliver a snapshot down every live kind:13534 callback. */ + emitSnapshot: (event) => { + for (const fn of liveByKind.get(KIND_SNAPSHOT) ?? []) fn(event); + }, + /** Deliver a kind:8000 delta down every live accelerator callback. */ + emitDelta: (event) => { + for (const fn of liveByKind.get(KIND_MEMBER_ADDED) ?? []) fn(event); + }, + /** Fire the relay client's reconnect notification. */ + emitReconnect: () => { + for (const fn of [...reconnectListeners]) fn(); + }, + /** What a subsequent fetchFirstEvent (refetch) resolves to. */ + setRefetchSnapshot: (event) => { + nextSnapshot = event; + }, + /** + * Hold the snapshot refetch open, the way a slow relay does. + * + * The stale-frame privacy race is only expressible if a refetch can resolve + * AFTER a newer live frame has been processed. Without a gate here, the + * refetch resolves inside the same drain that started it and the two frames + * can never be interleaved. + */ + deferRefetch: () => { + let release = null; + fetchGate = new Promise((resolve) => { + release = resolve; + }); + return async () => { + fetchGate = null; + release(); + await settle(); + }; + }, + counts: () => ({ + fetchFirstEventCalls, + subscribeCount, + unsubscribeCount, + liveSnapshotSubs: (liveByKind.get(KIND_SNAPSHOT) ?? []).length, + liveDeltaSubs: (liveByKind.get(KIND_MEMBER_ADDED) ?? []).length, + reconnectListeners: reconnectListeners.length, + }), + }; +} + +/** Mount the real hook under a real CommunitiesProvider + QueryClientProvider. */ +function mountHook({ role = "owner", enabled = true } = {}) { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + queryClient.setQueryData(["identity"], { pubkey: VIEWER }); + queryClient.setQueryData(myRelayMembershipLookupQueryKey, { + snapshotFound: true, + membershipRequired: true, + membership: + role === null + ? null + : { pubkey: VIEWER, role, addedBy: null, createdAt: null }, + }); + + const invalidations = []; + const realInvalidate = queryClient.invalidateQueries.bind(queryClient); + queryClient.invalidateQueries = (args) => { + invalidations.push(args?.queryKey); + return realInvalidate(args); + }; + + // Captured from inside the tree so a test can switch community the way the + // rail does — in the SAME mounted tree. Unmount/remount would tear the + // subscriptions down no matter what the effect keys on, which makes the + // re-key assertion pass on a hook with an empty dependency array. + const control = { switchCommunity: null }; + + function Harness() { + control.switchCommunity = useCommunities().switchCommunity; + useCommunityJoinAlerts({ enabled }); + return null; + } + + const container = document.createElement("div"); + const root = createRoot(container); + + const render = async () => { + await act(async () => { + root.render( + React.createElement( + QueryClientProvider, + { client: queryClient }, + React.createElement( + CommunitiesProvider, + null, + React.createElement(Harness, null), + ), + ), + ); + }); + }; + + return { + render, + invalidations, + queryClient, + switchCommunity: async (id) => { + await act(async () => { + control.switchCommunity(id); + }); + }, + unmount: async () => { + await act(async () => { + root.unmount(); + }); + }, + }; +} + +async function settle(iterations = 4) { + for (let i = 0; i < iterations; i++) { + await act(async () => { + await new Promise((r) => setTimeout(r, 5)); + }); + } +} + +/** + * Advance past the kind:8000 refetch debounce, then settle. + * + * The accelerator coalesces refetches on a 500ms trailing window so a bulk add + * costs one REQ instead of one per member; anything asserting on a refetch has + * to outwait that window or it is asserting on a timer that has not fired. + */ +async function settleAfterRefreshDebounce() { + await act(async () => { + await new Promise((r) => setTimeout(r, 600)); + }); + await settle(); +} + +/** + * Advance past the cross-snapshot notify window, then settle. + * + * Alerts are queued per snapshot and delivered on a trailing quiet window, so + * a burst spanning several intermediate 13534s produces one notification + * instead of one per snapshot. Anything asserting that a notification WAS + * delivered has to outwait that window; anything asserting an absence should + * outwait it too, or it proves only that delivery is deferred. + */ +async function settleAfterNotifyWindow() { + await act(async () => { + await new Promise((r) => setTimeout(r, 1_700)); + }); + await settle(); +} + +/** + * Hold the profile lookup open so the timer-fired/lookup-in-flight window is + * addressable from a test. + * + * `flushPending` consumes the pending refs at entry and then awaits + * `getUsersBatch` before it sends anything. Every arm that wants to assert on + * a revocation arriving DURING a flush has to be able to park the flush there; + * without this, the whole flush runs inside one microtask drain and the + * ordering Max and Wren found is not expressible at all. + * + * Routed through the Tauri IPC shim rather than a module mock so the real + * `getUsersBatch` runs — a stubbed production function would be a fixture + * re-declaring the code under test. + */ +function deferProfileLookup() { + let release = null; + const gate = new Promise((resolve) => { + release = resolve; + }); + let calls = 0; + ipcHandlers.set("get_users_batch", async () => { + calls += 1; + await gate; + return { profiles: {}, missing: [] }; + }); + return { + calls: () => calls, + /** Let the in-flight lookup resolve, then drain. */ + release: async () => { + release(); + await settle(); + }, + }; +} + +function ledgerKeys() { + return [...storage.keys()].filter((key) => + key.startsWith("buzz-community-join-seen.v1"), + ); +} + +// ── Tests ──────────────────────────────────────────────────────────────────── + +describe("useCommunityJoinAlerts — mounted subscription behaviour", () => { + beforeEach(() => { + storage.clear(); + storageFull = false; + notifications.length = 0; + onNotification = null; + ipcHandlers.clear(); + seedCommunities(COMMUNITY_A); + }); + + afterEach(() => { + mock.restoreAll(); + }); + + /** + * Positive control for the whole harness. Every other arm asserts an absence + * (no alert, no key, no extra subscription); if the harness could never + * produce an alert in the first place, all of them would pass vacuously. + */ + it("seeds silently on the first snapshot, then alerts on a genuine join", async () => { + const relay = installRelayStub(); + const { render, unmount } = mountHook(); + + await render(); + await settle(); + + relay.emitSnapshot(snapshot([VIEWER, ALICE])); + await settle(); + + assert.equal( + notifications.length, + 0, + "the first snapshot per community must seed silently", + ); + assert.equal(ledgerKeys().length, 1, "the seed must be persisted"); + + relay.emitSnapshot(snapshot([VIEWER, ALICE, BOB], { id: "snap-2" })); + await settleAfterNotifyWindow(); + + assert.equal(notifications.length, 1, "a genuine join must alert once"); + assert.match(notifications[0].title, /Community A/); + assert.match(notifications[0].body, /joined/); + + await unmount(); + }); + + /** + * A plain member mounts the hook (it is mounted unconditionally alongside the + * other desktop notification wiring) and must be inert. Eva asked for the + * stronger assertion: not merely "no notification" but "no ledger key", which + * proves the role gate sits before storage access rather than after it. + * + * A key materializing here would not be a gate-ordering nit — it would mean + * canManageCommunityMembers returned true for a non-manager, i.e. a + * role-resolution bug upstream in relayMembers.ts. + */ + it("is completely inert for a plain member: no subscription, no ledger key", async () => { + const relay = installRelayStub(); + const { render, unmount } = mountHook({ role: "member" }); + + await render(); + await settle(); + + const counts = relay.counts(); + assert.equal( + counts.subscribeCount, + 0, + "a plain member must open no subscription", + ); + assert.equal( + counts.reconnectListeners, + 0, + "a plain member must register no reconnect listener", + ); + + // Even if a snapshot somehow arrived, nothing is wired to receive it. + relay.emitSnapshot(snapshot([VIEWER, ALICE, BOB])); + await settle(); + + assert.equal(notifications.length, 0); + assert.deepEqual( + ledgerKeys(), + [], + "no buzz-community-join-seen.v1 key may be created for a plain member", + ); + + await unmount(); + }); + + /** + * `enabled: false` is the desktopEnabled precondition from + * useAppShellDesktopNotifications. An owner with notifications switched off + * must be as inert as a plain member — including writing no ledger, so + * turning notifications back on later seeds rather than back-alerting. + */ + it("is inert for an owner when notifications are disabled", async () => { + const relay = installRelayStub(); + const { render, unmount } = mountHook({ enabled: false }); + + await render(); + await settle(); + + assert.equal(relay.counts().subscribeCount, 0); + assert.deepEqual(ledgerKeys(), []); + + await unmount(); + }); + + /** + * Reconnect arm. Max could not land this live (simultaneous browser reloads + * tripped relay rate limiting), so this is the only evidence for it. + * + * Two halves, and the second is the one that matters: the reconnect must + * refetch (a socket gap can span joins that `limit: 1` backfill will not + * redeliver), AND the refetched snapshot must not re-alert keys the ledger + * already carries. Asserting only the refetch would pass on a hook that + * alerts twice for every reconnect. + */ + it("refetches on reconnect without re-alerting already-seen keys", async () => { + const relay = installRelayStub(); + const { render, unmount } = mountHook(); + + await render(); + await settle(); + + relay.emitSnapshot(snapshot([VIEWER, ALICE])); + await settle(); + relay.emitSnapshot(snapshot([VIEWER, ALICE, BOB], { id: "snap-2" })); + await settleAfterNotifyWindow(); + assert.equal(notifications.length, 1, "precondition: one join alerted"); + + const before = relay.counts().fetchFirstEventCalls; + + // The socket drops and recovers; the relay client replays the same roster. + relay.setRefetchSnapshot( + snapshot([VIEWER, ALICE, BOB], { id: "snap-replay", createdAt: 2000 }), + ); + relay.emitReconnect(); + await settleAfterRefreshDebounce(); + await settleAfterNotifyWindow(); + + assert.ok( + relay.counts().fetchFirstEventCalls > before, + "reconnect must refetch the authoritative snapshot", + ); + assert.equal( + notifications.length, + 1, + "a reconnect replay of a known roster must not re-alert", + ); + + // A key that joined during the gap still alerts on the refetched snapshot. + relay.setRefetchSnapshot( + snapshot([VIEWER, ALICE, BOB, "d".repeat(64)], { + id: "snap-gap", + createdAt: 3000, + }), + ); + relay.emitReconnect(); + await settleAfterRefreshDebounce(); + await settleAfterNotifyWindow(); + + assert.equal( + notifications.length, + 2, + "a join that landed during the socket gap must alert on refetch", + ); + + await unmount(); + }); + + /** + * The kind:8000 accelerator must refetch the authoritative snapshot rather + * than alert from the delta's own payload — that is what lets one ledger + * govern both signals so the pair cannot double-alert. + * + * The delta here names a pubkey that is NOT in the refetched roster. A hook + * alerting off the delta payload would fire; the correct hook fires nothing, + * because the snapshot is the authority. + */ + it("treats kind:8000 as a refetch trigger, not an alert payload", async () => { + const relay = installRelayStub(); + const { render, unmount } = mountHook(); + + await render(); + await settle(); + + relay.emitSnapshot(snapshot([VIEWER, ALICE])); + await settle(); + assert.equal(notifications.length, 0); + + const before = relay.counts().fetchFirstEventCalls; + + // Delta names a pubkey the authoritative roster does not (yet) carry. + relay.setRefetchSnapshot( + snapshot([VIEWER, ALICE], { id: "snap-unchanged", createdAt: 2000 }), + ); + relay.emitDelta({ + id: "delta-1", + pubkey: "f".repeat(64), + created_at: 1500, + kind: KIND_MEMBER_ADDED, + tags: [["p", BOB]], + content: "", + sig: "s".repeat(128), + }); + await settleAfterRefreshDebounce(); + + assert.ok( + relay.counts().fetchFirstEventCalls > before, + "a kind:8000 delta must trigger a snapshot refetch", + ); + assert.equal( + notifications.length, + 0, + "the delta's own payload must never produce an alert — only the snapshot decides", + ); + + // Now the snapshot agrees, and exactly one alert follows. + relay.setRefetchSnapshot( + snapshot([VIEWER, ALICE, BOB], { id: "snap-agrees", createdAt: 3000 }), + ); + relay.emitDelta({ + id: "delta-2", + pubkey: "f".repeat(64), + created_at: 2500, + kind: KIND_MEMBER_ADDED, + tags: [["p", BOB]], + content: "", + sig: "s".repeat(128), + }); + await settleAfterRefreshDebounce(); + await settleAfterNotifyWindow(); + + assert.equal(notifications.length, 1); + + // And the live snapshot carrying the same join must not alert a second time. + relay.emitSnapshot( + snapshot([VIEWER, ALICE, BOB], { id: "snap-live", createdAt: 3500 }), + ); + await settle(); + assert.equal( + notifications.length, + 1, + "the accelerator and the live snapshot share one ledger and must not double-alert", + ); + + await unmount(); + }); + + /** + * Community switch. Max's live-local run did not exercise this. + * + * The switch happens in the SAME mounted tree (via the provider's real + * switchCommunity), not by remounting: a remount tears every subscription + * down regardless of what the effect keys on, so a remount-based version of + * this test would pass on a hook with an empty dependency array. Switching + * in-tree makes the assertion actually about [active, communityId, viewer]. + */ + it("re-keys on community switch: fresh subscription and independent seed", async () => { + const relay = installRelayStub(); + const harness = mountHook(); + + await harness.render(); + await settle(); + + relay.emitSnapshot(snapshot([VIEWER, ALICE])); + await settle(); + relay.emitSnapshot(snapshot([VIEWER, ALICE, BOB], { id: "snap-2" })); + await settleAfterNotifyWindow(); + assert.equal(notifications.length, 1, "precondition: A alerted once"); + assert.deepEqual(ledgerKeys(), [joinAlertStorageKey(COMMUNITY_A, VIEWER)]); + + const beforeSwitch = relay.counts(); + assert.equal( + beforeSwitch.liveSnapshotSubs, + 1, + "precondition: A holds one live snapshot subscription", + ); + + await harness.switchCommunity(COMMUNITY_B); + await settle(); + + const afterSwitch = relay.counts(); + assert.equal( + afterSwitch.unsubscribeCount, + beforeSwitch.subscribeCount, + `switching must close every subscription community A opened — opened ${beforeSwitch.subscribeCount}, closed ${afterSwitch.unsubscribeCount}`, + ); + assert.equal( + afterSwitch.subscribeCount, + beforeSwitch.subscribeCount * 2, + "switching must open a fresh pair of subscriptions for community B", + ); + assert.equal( + afterSwitch.liveSnapshotSubs, + 1, + "exactly one live snapshot subscription may be open after the switch", + ); + assert.equal( + afterSwitch.liveDeltaSubs, + 1, + "exactly one live delta subscription may be open after the switch", + ); + + // B's existing roster must seed silently even though A is already seeded. + relay.emitSnapshot( + snapshot([VIEWER, ALICE, BOB], { id: "snap-b", createdAt: 4000 }), + ); + await settle(); + + assert.equal( + notifications.length, + 1, + "community B must seed silently — its roster is not a set of joins", + ); + + const keys = ledgerKeys().sort(); + assert.deepEqual( + keys, + [ + joinAlertStorageKey(COMMUNITY_A, VIEWER), + joinAlertStorageKey(COMMUNITY_B, VIEWER), + ].sort(), + "each community must keep its own ledger", + ); + + // And B alerts on its own first genuine join. + relay.emitSnapshot( + snapshot([VIEWER, ALICE, BOB, "d".repeat(64)], { + id: "snap-b2", + createdAt: 5000, + }), + ); + await settleAfterNotifyWindow(); + assert.equal(notifications.length, 2); + + // Switching back must not re-alert A's roster: its ledger persisted. + await harness.switchCommunity(COMMUNITY_A); + await settle(); + relay.emitSnapshot( + snapshot([VIEWER, ALICE, BOB], { id: "snap-a-return", createdAt: 6000 }), + ); + await settleAfterNotifyWindow(); + assert.equal( + notifications.length, + 2, + "returning to A must not re-alert keys A's ledger already carries", + ); + + await harness.unmount(); + }); + + /** + * Eva's red-team finding (thread 866f149d): writeJoinAlertLedger returns + * whether the write landed, and the caller dropped it. On a quota failure + * that survives cache eviction the alert fired against an unpersisted + * ledger — so the next reload re-alerted the same keys, which is exactly the + * "repeat" the ordering comment one line above promises never to do. + * + * Two halves, and both are needed. Asserting only "no notification" would + * pass on a hook that also poisons the in-memory ref, silently swallowing + * the alert forever. The second half proves the alert is deferred, not lost: + * once storage recovers, the next snapshot delivers it. + */ + it("does not notify when the ledger write cannot land, and delivers once it can", async () => { + const relay = installRelayStub(); + const { render, unmount } = mountHook(); + + await render(); + await settle(); + + relay.emitSnapshot(snapshot([VIEWER, ALICE])); + await settle(); + assert.equal(ledgerKeys().length, 1, "precondition: the seed persisted"); + + // Origin quota is exhausted and cache eviction cannot free enough. + storageFull = true; + relay.emitSnapshot(snapshot([VIEWER, ALICE, BOB], { id: "snap-full" })); + await settleAfterNotifyWindow(); + + assert.equal( + notifications.length, + 0, + "an alert must not fire against a ledger that was never persisted", + ); + + // Storage recovers. The same join must still be pending, not consumed by + // the failed attempt: the ref was deliberately left un-advanced. + storageFull = false; + relay.emitSnapshot( + snapshot([VIEWER, ALICE, BOB], { id: "snap-recovered", createdAt: 2000 }), + ); + await settleAfterNotifyWindow(); + + assert.equal( + notifications.length, + 1, + "the deferred alert must be delivered by the first snapshot whose write lands", + ); + + // And it is not delivered twice now that the ledger is on disk. + relay.emitSnapshot( + snapshot([VIEWER, ALICE, BOB], { id: "snap-after", createdAt: 3000 }), + ); + await settleAfterNotifyWindow(); + assert.equal(notifications.length, 1); + + await unmount(); + }); + + /** + * A snapshot refreshes the members panel regardless of alert eligibility: a + * removal or a role change alters the roster without producing anything new + * to alert on, and the open panel must still repaint. + * + * The refresh is a direct cache WRITE, not an invalidation — an invalidation + * refetched every active observer, costing one REQ frame per snapshot (see + * the two-arm REQ test at the end of this file). So this asserts the roster + * that lands in the cache, which is the property the panel actually renders + * from, and is a strictly stronger claim than "an invalidation was issued": + * it fails both if the refresh disappears AND if it writes the wrong roster. + */ + it("writes the roster into the members query on every snapshot, including a seeding one", async () => { + const relay = installRelayStub(); + const { render, queryClient, unmount } = mountHook(); + + await render(); + await settle(); + + assert.equal( + queryClient.getQueryData(relayMembersQueryKey), + undefined, + "precondition: nothing has populated the members query yet", + ); + + relay.emitSnapshot(snapshot([VIEWER, ALICE])); + await settle(); + + const cached = queryClient.getQueryData(relayMembersQueryKey); + assert.deepEqual( + cached?.map((member) => member.pubkey).sort(), + [VIEWER, ALICE].sort(), + "the seeding snapshot must still refresh the roster panel", + ); + // The viewer's own role rides in the snapshot, so the written rows carry it + // — a fixture writing bare pubkeys would render an owner as a plain member. + assert.equal( + cached?.find((member) => member.pubkey === VIEWER)?.role, + "owner", + "the written rows must carry roles, not just pubkeys", + ); + + await unmount(); + }); + /** + * Authorization must come from the snapshot in hand, not the cached role that + * mounted the effect. + * + * `useMyRelayMembershipLookupQuery` is invalidated only by this client's own + * membership mutations, and `staleTime` marks data stale without scheduling a + * refetch — so a viewer demoted by ANOTHER admin keeps a cached owner/admin + * role for as long as the app stays open. Found by Wren, reproduced live by + * Max against a real relay: the demoted viewer kept learning every later + * joiner's identity. + * + * The demotion and the join ride in the SAME snapshot, which is the racy + * shape: an async invalidation cannot beat the handler it is racing. + */ + it("stops alerting when the snapshot itself demotes the viewer", async () => { + const relay = installRelayStub(); + const { render, unmount } = mountHook({ role: "admin" }); + + await render(); + await settle(); + + relay.emitSnapshot(snapshot([VIEWER, ALICE], { viewerRole: "admin" })); + await settle(); + + // Positive control: still admin, so a genuine join must alert. Without + // this, a gate that refused everything would pass the assertions below. + relay.emitSnapshot( + snapshot([VIEWER, ALICE, BOB], { id: "snap-join", viewerRole: "admin" }), + ); + await settleAfterNotifyWindow(); + assert.equal(notifications.length, 1, "precondition: admin still alerts"); + + const ledgerBefore = storage.get(joinAlertStorageKey(COMMUNITY_A, VIEWER)); + + // Remote demotion + a new member, in one authoritative snapshot. + relay.emitSnapshot( + snapshot([VIEWER, ALICE, BOB, CAROL], { + id: "snap-demote", + createdAt: 4000, + viewerRole: "member", + }), + ); + await settleAfterNotifyWindow(); + + assert.equal( + notifications.length, + 1, + "a demoted viewer must not be told who joined", + ); + assert.equal( + storage.get(joinAlertStorageKey(COMMUNITY_A, VIEWER)), + ledgerBefore, + "the ledger must not advance on a snapshot the viewer is not authorized for", + ); + + await unmount(); + }); + + /** + * Removal is the same disclosure as demotion, and `find` returning undefined + * is a different code path from a role that is present but wrong. + */ + it("stops alerting when the viewer is dropped from the roster entirely", async () => { + const relay = installRelayStub(); + const { render, unmount } = mountHook({ role: "owner" }); + + await render(); + await settle(); + + relay.emitSnapshot(snapshot([VIEWER, ALICE])); + await settle(); + + // Viewer absent from the snapshot; a new key arrives alongside. + relay.emitSnapshot({ + id: "snap-removed", + pubkey: "f".repeat(64), + created_at: 5000, + kind: KIND_SNAPSHOT, + tags: [ + ["member", ALICE, "member"], + ["member", BOB, "member"], + ], + content: "", + sig: "s".repeat(128), + }); + await settleAfterNotifyWindow(); + + assert.equal( + notifications.length, + 0, + "a removed viewer must learn nothing about later joins", + ); + + await unmount(); + }); + + /** + * A snapshot is a whole roster, so a bulk add lands every new key at once. + * Uncapped that is one OS notification per member — measured at 248 banners + * for a 250-key snapshot, delivered through a serial await loop. + */ + it("collapses a bulk join into one summary instead of a banner per member", async () => { + const relay = installRelayStub(); + const { render, unmount } = mountHook(); + + await render(); + await settle(); + + relay.emitSnapshot(snapshot([VIEWER, ALICE])); + await settle(); + + const bulk = []; + for (let i = 0; i < 40; i++) { + bulk.push(`${i.toString(16).padStart(2, "0").repeat(31)}ff`); + } + relay.emitSnapshot( + snapshot([VIEWER, ALICE, ...bulk], { id: "snap-bulk", createdAt: 6000 }), + ); + await settleAfterNotifyWindow(); + + assert.equal(notifications.length, 1, "one summary, not one per member"); + assert.equal(notifications[0].body, "40 new members joined"); + + await unmount(); + }); + + /** + * Below the cap the alert still names people — the summary must not swallow + * the ordinary one-or-two-join case the feature exists for. + */ + it("still names individuals for a small batch", async () => { + const relay = installRelayStub(); + const { render, unmount } = mountHook(); + + await render(); + await settle(); + + relay.emitSnapshot(snapshot([VIEWER, ALICE])); + await settle(); + + relay.emitSnapshot( + snapshot([VIEWER, ALICE, BOB, CAROL], { + id: "snap-two", + createdAt: 7000, + }), + ); + await settleAfterNotifyWindow(); + + assert.equal(notifications.length, 2, "two joins, two named alerts"); + assert.ok( + notifications.every((entry) => entry.body.endsWith(" joined")), + "each alert names the joiner rather than summarizing", + ); + + await unmount(); + }); + + /** + * Each refetch is a REQ frame billed against the same per-principal WsEvents + * budget as the user's own sends (default 10/s over a 5s window), and a bulk + * add emits one kind:8000 per member. Uncoalesced that was 250 REQs for 250 + * deltas — spending the budget the owner needs to send messages and open + * channels. + */ + it("coalesces a burst of kind:8000 deltas into a single refetch", async () => { + const relay = installRelayStub(); + const { render, unmount } = mountHook(); + + await render(); + await settle(); + + relay.emitSnapshot(snapshot([VIEWER, ALICE])); + await settle(); + + const before = relay.counts().fetchFirstEventCalls; + relay.setRefetchSnapshot( + snapshot([VIEWER, ALICE], { id: "snap-burst", createdAt: 8000 }), + ); + + for (let i = 0; i < 50; i++) { + relay.emitDelta({ + id: `burst-${i}`, + pubkey: "f".repeat(64), + created_at: 8000 + i, + kind: KIND_MEMBER_ADDED, + tags: [["p", BOB]], + content: "", + sig: "s".repeat(128), + }); + } + await settleAfterRefreshDebounce(); + + assert.equal( + relay.counts().fetchFirstEventCalls - before, + 1, + "50 deltas must cost exactly one REQ, not 50", + ); + + await unmount(); + }); + + /** + * Max's live 50-join storm at `fdeda44f0`: 10 banners, not 1. + * + * The per-snapshot cap answers "one snapshot, many keys". The relay answers + * back "one burst, many snapshots" — it republishes the whole 13534 as each + * concurrent add commits, so a storm arrives as several growing rosters and + * each one independently emitted its own capped batch. The batch sizes below + * are Max's observed live values (6, 17, 4, 4, 4, 4, 11 = 50). + */ + it("collapses a burst spanning several snapshots into one alert", async () => { + const relay = installRelayStub(); + const { render, unmount } = mountHook(); + + await render(); + await settle(); + + relay.emitSnapshot(snapshot([VIEWER])); + await settleAfterNotifyWindow(); + assert.equal(notifications.length, 0, "precondition: seeded silently"); + + const roster = [VIEWER]; + let minted = 0; + let snapIndex = 0; + for (const size of [6, 17, 4, 4, 4, 4, 11]) { + for (let i = 0; i < size; i++) { + minted += 1; + roster.push(minted.toString(16).padStart(2, "0").repeat(32)); + } + snapIndex += 1; + relay.emitSnapshot( + snapshot([...roster], { + id: `storm-${snapIndex}`, + createdAt: 9000 + snapIndex, + }), + ); + await settle(); + } + await settleAfterNotifyWindow(); + + assert.equal(minted, 50, "fixture must mint Max's 50 joins"); + assert.equal( + notifications.length, + 1, + "a burst spanning 7 snapshots must produce one alert, not one per snapshot", + ); + assert.equal(notifications[0].body, "50 new members joined"); + + await unmount(); + }); + + /** + * Wren's arm 3, and the reason batching is not free: deferring delivery + * reopens his disclosure as a DELAYED one unless revocation also drops what + * is already queued. Measured failing before the clearPending() call existed + * — the queued batch flushed "5 new members joined" after the demotion. + */ + it("drops queued alerts when a later snapshot demotes the viewer", async () => { + const relay = installRelayStub(); + const { render, unmount } = mountHook({ role: "admin" }); + + await render(); + await settle(); + + relay.emitSnapshot(snapshot([VIEWER], { viewerRole: "admin" })); + await settle(); + + // Joins land and are queued, but the flush window has not elapsed. + const roster = [VIEWER, ALICE, BOB, CAROL]; + relay.emitSnapshot( + snapshot([...roster], { + id: "queued-joins", + createdAt: 10_000, + viewerRole: "admin", + }), + ); + await settle(); + assert.equal( + notifications.length, + 0, + "precondition: delivery is still pending on the window", + ); + + // Demotion arrives before the timer fires. + relay.emitSnapshot( + snapshot([...roster], { + id: "queued-demote", + createdAt: 10_001, + viewerRole: "member", + }), + ); + await settleAfterNotifyWindow(); + + assert.equal( + notifications.length, + 0, + "a demotion before the flush must cancel the queued batch, not delay it", + ); + + await unmount(); + }); + + /** + * Wren's arm 5. The window must batch a burst without swallowing legitimate + * later joins — otherwise the fix trades 10 spurious alerts for a silently + * dropped one. + */ + it("still alerts separately for joins beyond the batching window", async () => { + const relay = installRelayStub(); + const { render, unmount } = mountHook(); + + await render(); + await settle(); + + relay.emitSnapshot(snapshot([VIEWER])); + await settleAfterNotifyWindow(); + + relay.emitSnapshot( + snapshot([VIEWER, ALICE], { id: "join-1", createdAt: 11_000 }), + ); + await settleAfterNotifyWindow(); + assert.equal(notifications.length, 1, "first join alerts on its own"); + + relay.emitSnapshot( + snapshot([VIEWER, ALICE, BOB], { id: "join-2", createdAt: 12_000 }), + ); + await settleAfterNotifyWindow(); + + assert.equal( + notifications.length, + 2, + "a join after the window closed must get its own alert, not be suppressed", + ); + + await unmount(); + }); + + /** + * Teardown must drop the queued batch, not just its timer. On a community + * switch the effect re-keys, and keys accumulated for the old community must + * never flush against the new one. + */ + it("does not deliver a queued batch after unmount", async () => { + const relay = installRelayStub(); + const { render, unmount } = mountHook(); + + await render(); + await settle(); + + relay.emitSnapshot(snapshot([VIEWER])); + await settle(); + relay.emitSnapshot( + snapshot([VIEWER, ALICE, BOB, CAROL], { + id: "queued-at-teardown", + createdAt: 13_000, + }), + ); + await settle(); + assert.equal(notifications.length, 0, "precondition: still queued"); + + await unmount(); + await settleAfterNotifyWindow(); + + assert.equal( + notifications.length, + 0, + "a torn-down mount must not fire its pending batch", + ); + }); + + // ── Mid-flight cancellation (Max's race, Wren's arm list) ────────────────── + // + // Clearing the pending refs cannot stop a flush that already consumed them. + // Every send sits behind an await — the profile lookup, then each + // notification — so a revocation landing after the timer fired but before + // the sends resolve delivered anyway at 5d0d2b4c. These arms pin the + // generation token that closes it. All five park the flush on a deferred + // `get_users_batch`; without that the ordering is not expressible. + + it("suppresses an in-flight flush when a demotion lands during the lookup", async () => { + const relay = installRelayStub(); + const profiles = deferProfileLookup(); + const { render, unmount } = mountHook({ role: "admin" }); + + await render(); + await settle(); + relay.emitSnapshot(snapshot([VIEWER], { viewerRole: "admin" })); + await settle(); + + const roster = [VIEWER, ALICE, BOB]; + relay.emitSnapshot( + snapshot([...roster], { + id: "inflight-joins", + createdAt: 14_000, + viewerRole: "admin", + }), + ); + await settleAfterNotifyWindow(); + + assert.equal( + profiles.calls(), + 1, + "precondition: flush is parked on the lookup", + ); + assert.equal(notifications.length, 0, "precondition: nothing sent yet"); + + // Authorization is revoked while the flush holds the batch in locals. + relay.emitSnapshot( + snapshot([...roster], { + id: "inflight-demote", + createdAt: 14_001, + viewerRole: "member", + }), + ); + await settle(); + + await profiles.release(); + await settleAfterNotifyWindow(); + + assert.equal( + notifications.length, + 0, + "a demotion during the profile lookup must abort the resumed flush", + ); + + await unmount(); + }); + + it("suppresses an in-flight flush when the viewer is removed during the lookup", async () => { + const relay = installRelayStub(); + const profiles = deferProfileLookup(); + const { render, unmount } = mountHook({ role: "admin" }); + + await render(); + await settle(); + relay.emitSnapshot(snapshot([VIEWER], { viewerRole: "admin" })); + await settle(); + + relay.emitSnapshot( + snapshot([VIEWER, ALICE, BOB], { + id: "removal-joins", + createdAt: 15_000, + viewerRole: "admin", + }), + ); + await settleAfterNotifyWindow(); + assert.equal( + profiles.calls(), + 1, + "precondition: flush is parked on the lookup", + ); + + // Dropped from the roster entirely — fail closed, same as a demotion. + relay.emitSnapshot( + snapshot([ALICE, BOB], { id: "removal", createdAt: 15_001 }), + ); + await settle(); + + await profiles.release(); + await settleAfterNotifyWindow(); + + assert.equal( + notifications.length, + 0, + "removal during the profile lookup must abort the resumed flush", + ); + + await unmount(); + }); + + it("suppresses an in-flight flush across a community switch, under either name", async () => { + const relay = installRelayStub(); + const profiles = deferProfileLookup(); + const { render, switchCommunity, unmount } = mountHook(); + + await render(); + await settle(); + relay.emitSnapshot(snapshot([VIEWER])); + await settle(); + + relay.emitSnapshot( + snapshot([VIEWER, ALICE, BOB], { + id: "switch-joins", + createdAt: 16_000, + }), + ); + await settleAfterNotifyWindow(); + assert.equal( + profiles.calls(), + 1, + "precondition: flush is parked on the lookup", + ); + + await switchCommunity(COMMUNITY_B); + await profiles.release(); + await settleAfterNotifyWindow(); + + assert.equal( + notifications.length, + 0, + "community A's keys must not deliver after the switch to B", + ); + // The title is read at send time from a ref, so a surviving flush would + // also mislabel A's joiners as B's. Assert the mislabel is impossible + // rather than inferring it from the count above. + assert.ok( + notifications.every((entry) => !entry.title.includes("Community B")), + "no alert may carry the new community's title", + ); + + await unmount(); + }); + + it("suppresses the remainder of a batch when a demotion lands between sends", async () => { + const relay = installRelayStub(); + const { render, unmount } = mountHook({ role: "admin" }); + + await render(); + await settle(); + relay.emitSnapshot(snapshot([VIEWER], { viewerRole: "admin" })); + await settle(); + + const roster = [VIEWER, ALICE, BOB, CAROL]; + relay.emitSnapshot( + snapshot([...roster], { + id: "midloop-joins", + createdAt: 17_000, + viewerRole: "admin", + }), + ); + await settle(); + + // Fire the demotion from inside the first send — the only point in the + // program where "between named send 1 and send 2" exists. + onNotification = (count) => { + if (count !== 1) return; + onNotification = null; + relay.emitSnapshot( + snapshot([...roster], { + id: "midloop-demote", + createdAt: 17_001, + viewerRole: "member", + }), + ); + }; + await settleAfterNotifyWindow(); + + assert.equal( + notifications.length, + 1, + "the send already in flight completes, but the rest of the batch is suppressed", + ); + + await unmount(); + }); + + /** + * Positive control for the cancellation token, and the semantics Eva asked + * to be pinned: the generation bumps on CANCELLATION only, never on an + * ordinary enqueue. A newer authorized batch queued while an earlier flush's + * lookup is in flight must neither cancel it nor be cancelled by it — both + * deliver. + * + * Without this arm a token that bumped on every enqueue would pass all four + * arms above by suppressing everything, which is the failure mode a + * suppression test cannot see. + */ + it("delivers both batches when a new authorized batch queues during a flush", async () => { + const relay = installRelayStub(); + const profiles = deferProfileLookup(); + const { render, unmount } = mountHook(); + + await render(); + await settle(); + relay.emitSnapshot(snapshot([VIEWER])); + await settle(); + + relay.emitSnapshot( + snapshot([VIEWER, ALICE], { id: "batch-one", createdAt: 18_000 }), + ); + await settleAfterNotifyWindow(); + assert.equal( + profiles.calls(), + 1, + "precondition: first flush parked on the lookup", + ); + assert.equal(notifications.length, 0, "precondition: nothing sent yet"); + + // A second, fully authorized join arrives while the first flush waits. + relay.emitSnapshot( + snapshot([VIEWER, ALICE, BOB], { id: "batch-two", createdAt: 18_001 }), + ); + await settle(); + + await profiles.release(); + await settleAfterNotifyWindow(); + + assert.equal( + notifications.length, + 2, + "a legitimate concurrent batch must not erase, or be erased by, the in-flight one", + ); + assert.ok( + notifications.every((entry) => entry.body.endsWith(" joined")), + "both alerts name their joiner", + ); + + await unmount(); + }); + + // ── Stale-frame ordering: the fence and the revocation latch ─────────────── + // + // Everything below concerns frames arriving out of order. The demotion arms + // above all deliver the revoking snapshot LAST, which is the only ordering a + // trailing-window suite naturally produces — and the ordering under which a + // hook with no fence and no latch passes every one of them. + + /** + * The privacy regression. Red at 0cfe4832, green with the latch. + * + * A refetch (kind:8000 accelerator or reconnect) is held open while a newer + * live frame demotes the viewer. The stale frame then resolves still listing + * the viewer as owner AND carrying a new member. At 0cfe4832 the hook + * authorized that frame against its own roster, found "owner", and disclosed + * the joiner's identity to an admin who had already been demoted — measured as + * `notifications=1 bodies=["cccc… joined"]`. + * + * The latch is what closes it, not the fence: `created_at` ordering alone + * cannot, because the relay can emit two snapshots in the same second. + */ + it("never discloses a joiner from a stale frame that outlives a demotion", async () => { + const relay = installRelayStub(); + const { render, unmount } = mountHook({ role: "owner" }); + + await render(); + await settle(); + + relay.emitSnapshot(snapshot([VIEWER, ALICE])); + await settle(); + assert.equal(notifications.length, 0, "precondition: seeded silently"); + + // A stale authorized frame — still owner, and it carries BOB — is put in + // flight and held there. + relay.setRefetchSnapshot( + snapshot([VIEWER, ALICE, BOB], { + id: "snap-stale-authorized", + createdAt: 20_000, + }), + ); + const releaseRefetch = relay.deferRefetch(); + relay.emitDelta({ + id: "delta-1", + pubkey: "f".repeat(64), + created_at: 20_000, + kind: KIND_MEMBER_ADDED, + tags: [["p", BOB]], + content: "", + sig: "s".repeat(128), + }); + await settleAfterRefreshDebounce(); + + // Meanwhile the live subscription delivers the demotion. Same second as the + // stale frame on purpose: a strictly-older fence does not reject it, so this + // arm cannot pass on the fence alone. + relay.emitSnapshot( + snapshot([VIEWER, ALICE], { + id: "snap-demote", + createdAt: 20_000, + viewerRole: "member", + }), + ); + await settle(); + assert.equal( + notifications.length, + 0, + "precondition: the demotion itself discloses nothing", + ); + + // Persisted ledger immediately before the delayed frame is released. The + // notification count alone cannot distinguish "refused before touching the + // ledger" from "recorded BOB as seen but suppressed the banner" — and the + // second shape would silently swallow the alert forever once the session + // recovers, since a key already marked seen is never announced again. + const ledgerBeforeRelease = storage.get( + joinAlertStorageKey(COMMUNITY_A, VIEWER), + ); + + // Now the stale authorized frame lands. + await releaseRefetch(); + await settleAfterNotifyWindow(); + + assert.equal( + notifications.length, + 0, + "a frame that predates the demotion must not re-open disclosure", + ); + assert.ok( + !notifications.some((entry) => entry.body?.includes(BOB.slice(0, 8))), + "the demoted viewer must never learn the new member's identity", + ); + assert.equal( + storage.get(joinAlertStorageKey(COMMUNITY_A, VIEWER)), + ledgerBeforeRelease, + "the latched session must refuse the frame before reconciliation, leaving the ledger untouched", + ); + assert.ok( + !(ledgerBeforeRelease ?? "").includes(BOB), + "control: BOB must not already be in the ledger, or the assertion above is vacuous", + ); + + await unmount(); + }); + + /** + * The fence's own arm: a strictly older frame is not treated as current. + * + * Distinct from the latch above — here the viewer is never demoted, so the + * latch never trips and only the `created_at` comparison can reject the frame. + * A stale roster that has LOST a member must not cause that member to be + * re-alerted when they reappear in the (already-seen) newer roster. + */ + it("ignores a strictly older snapshot rather than treating it as current", async () => { + const relay = installRelayStub(); + const { render, unmount } = mountHook({ role: "owner" }); + + await render(); + await settle(); + + relay.emitSnapshot(snapshot([VIEWER, ALICE], { createdAt: 30_000 })); + await settle(); + + relay.emitSnapshot( + snapshot([VIEWER, ALICE, BOB], { id: "snap-new", createdAt: 31_000 }), + ); + await settleAfterNotifyWindow(); + assert.equal(notifications.length, 1, "precondition: BOB alerted once"); + + const ledgerAfterBob = storage.get( + joinAlertStorageKey(COMMUNITY_A, VIEWER), + ); + + // An older frame arrives late, carrying a roster that predates BOB and adds + // CAROL. Processing it as current would fold a superseded roster in. + relay.emitSnapshot( + snapshot([VIEWER, ALICE, CAROL], { + id: "snap-older", + createdAt: 30_500, + }), + ); + await settleAfterNotifyWindow(); + + assert.equal( + notifications.length, + 1, + "an older frame must not alert from a superseded roster", + ); + assert.equal( + storage.get(joinAlertStorageKey(COMMUNITY_A, VIEWER)), + ledgerAfterBob, + "an older frame must not advance the ledger", + ); + + await unmount(); + }); + + /** + * Eva's constraint: the fence advances only on frames actually accepted. + * + * If a rejected frame moved newest-seen, a stale frame could push the fence + * past a legitimate frame still in flight and that real snapshot would be + * dropped as though it were stale. The rejection used here is the empty + * roster, and the legitimate frame that follows carries a LOWER `created_at` + * than the rejected one. + * + * Scope, measured rather than assumed. Moving the empty-roster guard to AFTER + * the fence advance fails this arm and only this arm (28/29 still pass), so it + * is a real and uniquely-targeted guard. But moving the fence advance itself + * back up to the comparison — the literal edit Eva's constraint forbids — + * SURVIVES the whole suite, and that is not a gap in this test: it is an + * equivalent mutant. Only two guards sit between the comparison and the + * advance, and each is already immune: + * + * - the empty-roster guard returns BEFORE the comparison, so a frame it + * rejects never reaches either position; + * - the authorization guard latches `revoked` on the way out, and a revoked + * session refuses every later frame outright, so whether that frame moved + * the fence first is unobservable. + * + * The placement is therefore defence in depth against a FUTURE reject-and- + * continue path, not a currently-reachable defect. Pinning it here is what + * makes the next such guard visible — a new early return added between these + * two points would be caught by this arm rather than by a user. + */ + it("does not advance the stale-frame fence on a frame it rejects", async () => { + const relay = installRelayStub(); + const { render, unmount } = mountHook({ role: "owner" }); + + await render(); + await settle(); + + relay.emitSnapshot(snapshot([VIEWER, ALICE], { createdAt: 40_000 })); + await settle(); + + // Rejected frame, far in the future. An empty roster is dropped before the + // fence would have anything to say about it. + relay.emitSnapshot(snapshot([], { id: "snap-empty", createdAt: 90_000 })); + await settle(); + + // A legitimate frame, newer than the accepted one but OLDER than the + // rejected one. If the rejected frame had advanced the fence, this real + // join would be silently discarded. + relay.emitSnapshot( + snapshot([VIEWER, ALICE, BOB], { id: "snap-real", createdAt: 41_000 }), + ); + await settleAfterNotifyWindow(); + + assert.equal( + notifications.length, + 1, + "a rejected frame must not fence out a later legitimate one", + ); + + await unmount(); + }); + + /** + * Eva's constraint: the latch trip drops the queued batch before anything + * else, exactly as the pre-latch demotion path did. + * + * The latch is an addition to that path, not a replacement for it, and a latch + * that returned early WITHOUT clearing would leave an armed timer holding + * authorized-at-queue-time keys that fires after revocation. Asserted by + * queueing a batch, tripping the latch mid-window, and then outwaiting the + * window: silence can only come from the batch having been dropped. + */ + it("drops the queued batch when the latch trips, not merely afterwards", async () => { + const relay = installRelayStub(); + const { render, unmount } = mountHook({ role: "owner" }); + + await render(); + await settle(); + + relay.emitSnapshot(snapshot([VIEWER, ALICE], { createdAt: 50_000 })); + await settle(); + + // Queue a batch and leave it pending inside the trailing window. + relay.emitSnapshot( + snapshot([VIEWER, ALICE, BOB], { id: "snap-queue", createdAt: 51_000 }), + ); + await settle(); + assert.equal( + notifications.length, + 0, + "precondition: the batch is queued, not yet delivered", + ); + + // Trip the latch while that timer is still armed. + relay.emitSnapshot( + snapshot([VIEWER, ALICE, BOB], { + id: "snap-latch", + createdAt: 52_000, + viewerRole: "member", + }), + ); + await settleAfterNotifyWindow(); + + assert.equal( + notifications.length, + 0, + "a batch queued before revocation must be dropped by the latch trip", + ); + + await unmount(); + }); + + /** + * Known and accepted for v1 (Eva's ruling): a stale DEMOTING frame latches a + * viewer who is still a genuine admin, and the latch does not self-clear. + * + * This is the reverse ordering of the privacy race. The invalidation the latch + * fires refetches the membership lookup, which correctly returns admin, so + * `active` stays true, the effect deps do not change, and no re-key occurs — + * the session stays latched until reload or community switch. + * + * It is fail-safe (under-notify, never over-disclose) and consistent with the + * promotion-on-reload semantics this feature already ships, so it is pinned + * here as documented behaviour rather than left to be rediscovered as a bug. + * Clearing it would cost a third piece of timing state, which is not worth it + * at v1. + */ + it("stays latched after a stale demoting frame, until reload or switch (accepted)", async () => { + const relay = installRelayStub(); + const { render, switchCommunity, unmount } = mountHook({ role: "admin" }); + + await render(); + await settle(); + + relay.emitSnapshot( + snapshot([VIEWER, ALICE], { createdAt: 60_000, viewerRole: "admin" }), + ); + await settle(); + + // A stale frame that does not list the viewer as a manager arrives first. + relay.emitSnapshot( + snapshot([VIEWER, ALICE], { + id: "snap-stale-demote", + createdAt: 60_000, + viewerRole: "member", + }), + ); + await settle(); + + // The viewer is in fact still an admin, and later frames say so. + relay.emitSnapshot( + snapshot([VIEWER, ALICE, BOB], { + id: "snap-still-admin", + createdAt: 61_000, + viewerRole: "admin", + }), + ); + await settleAfterNotifyWindow(); + + assert.equal( + notifications.length, + 0, + "documented: the latch does not self-clear, so alerts stay off for this session", + ); + + // A community switch re-keys the effect and builds a fresh session, which is + // the documented recovery path (alongside reload). Switching away and back + // is what a user does; assert the feature is alive again afterwards. + await switchCommunity(COMMUNITY_B); + await settle(); + await switchCommunity(COMMUNITY_A); + await settle(); + + relay.emitSnapshot( + snapshot([VIEWER, ALICE, BOB], { + id: "snap-after-switch", + createdAt: 62_000, + viewerRole: "admin", + }), + ); + await settle(); + relay.emitSnapshot( + snapshot([VIEWER, ALICE, BOB, CAROL], { + id: "snap-after-switch-join", + createdAt: 63_000, + viewerRole: "admin", + }), + ); + await settleAfterNotifyWindow(); + + // Two, not one: the latched session suppressed BOB's alert but also never + // recorded him in the ledger, so the fresh session sees him as unseen and + // announces him alongside CAROL. The accepted cost of the latch is therefore + // DELAYED notification, not lost notification — which is what makes + // "fail-safe" true rather than merely reassuring. + assert.equal( + notifications.length, + 2, + "a community switch clears the latch: the feature recovers without a reload", + ); + assert.ok( + notifications.some((entry) => entry.body?.includes(BOB.slice(0, 8))), + "the join suppressed by the latch is re-announced, not lost", + ); + assert.ok( + notifications.some((entry) => entry.body?.includes(CAROL.slice(0, 8))), + "and the new join lands too", + ); + + await unmount(); + }); + + /** + * F1: a snapshot in flight across a community switch is folded into the + * session that requested it, or into nothing — never into the new + * community's ledger. + * + * `handleSnapshot` is a `useEffectEvent`, so before the session binding it + * read whatever community was CURRENTLY rendered. A frame from community A + * resolving after a switch to B would be reconciled against B's ledger and + * persisted under B's storage key, alerting for A's members under B's name. + */ + it("never folds a snapshot from the previous community into the new one", async () => { + const relay = installRelayStub(); + const { render, switchCommunity, unmount } = mountHook({ role: "owner" }); + + await render(); + await settle(); + + relay.emitSnapshot(snapshot([VIEWER, ALICE], { createdAt: 70_000 })); + await settle(); + + const keyA = joinAlertStorageKey(COMMUNITY_A, VIEWER); + const keyB = joinAlertStorageKey(COMMUNITY_B, VIEWER); + const ledgerABefore = storage.get(keyA); + assert.ok(ledgerABefore, "precondition: community A seeded"); + assert.equal(storage.get(keyB), undefined, "precondition: B unseeded"); + + // Community A's refetch is held open across the switch. + relay.setRefetchSnapshot( + snapshot([VIEWER, ALICE, BOB], { + id: "snap-a-inflight", + createdAt: 71_000, + }), + ); + const releaseRefetch = relay.deferRefetch(); + relay.emitReconnect(); + await settleAfterRefreshDebounce(); + + await switchCommunity(COMMUNITY_B); + await settle(); + + // A's frame now resolves, with B active. + await releaseRefetch(); + await settleAfterNotifyWindow(); + + assert.equal( + storage.get(keyA), + ledgerABefore, + "the retired session must not write community A's ledger either", + ); + const ledgerB = storage.get(keyB); + if (ledgerB !== undefined) { + assert.ok( + !ledgerB.includes(BOB), + "community A's roster must never reach community B's ledger", + ); + } + assert.ok( + !notifications.some((entry) => entry.title?.includes("Community B")), + "community A's joiners must never be announced under community B", + ); + + await unmount(); + }); + + /** + * F2: the trailing window is a pure debounce, so a join cadence faster than + * the window re-arms it indefinitely. + * + * Measured before the clamp: 13 joins at ~700ms intervals produced ZERO + * notifications across 9.1 continuous seconds, with the ledger persisted the + * whole time — so a quit mid-drip loses a batch already recorded as alerted. + * The clamp bounds that. This arm drips faster than the window for longer + * than the ceiling and asserts delivery happens DURING the drip. + * + * The existing burst arm cannot catch this: it emits its snapshots in a tight + * loop inside one drain, so the window never re-arms against wall-clock time + * and the starvation is structurally unreachable there. + */ + it("delivers during a sustained drip instead of deferring without bound", async () => { + const relay = installRelayStub(); + const { render, unmount } = mountHook({ role: "owner" }); + + await render(); + await settle(); + + relay.emitSnapshot(snapshot([VIEWER], { createdAt: 80_000 })); + await settle(); + + const roster = [VIEWER]; + // 1s apart — inside the 1.5s window, so every join re-arms it — for 8s, + // which is past the 5s ceiling. + for (let i = 0; i < 8; i++) { + roster.push(`${i.toString(16).repeat(63)}e`); + relay.emitSnapshot( + snapshot([...roster], { + id: `snap-drip-${i}`, + createdAt: 80_001 + i, + }), + ); + await act(async () => { + await new Promise((r) => setTimeout(r, 1_000)); + }); + } + + assert.ok( + notifications.length > 0, + `a sustained drip must not starve delivery; got ${notifications.length} alerts across 8s`, + ); + + await settleAfterNotifyWindow(); + await unmount(); + }); + /** + * The members panel must not turn each roster snapshot into a REQ frame. + * + * `useRelayMembersQuery` is the settings card's own query, and its queryFn + * `listRelayMembers` is a REQ (`fetchFirstEvent({ kinds: [13534], limit: 1 })`). + * `invalidateQueries` refetches every ACTIVE observer, so while the panel was + * open the snapshot handler emitted one REQ per accepted snapshot — measured + * 1:1 at 20 snapshots, both here and live against a real relay — against a + * per-principal budget of 50 REQ per 5s. Unlike the kind:8000 accelerator this + * path is not behind `MEMBER_REFRESH_DEBOUNCE_MS`, so nothing coalesced it. + * + * Both arms are asserted, and the second is what makes this test honest: + * DELETING the cache write also produces zero REQ, so a REQ-only assertion is + * satisfied by a fix that silently freezes the panel. The observed roster is + * the discriminator (measured: 21 keys with the write, 0 without it). + * + * The closed arm is the negative control — without it, a hook that stopped + * subscribing entirely would pass the open arm. + */ + it("keeps the members panel fresh across a burst without emitting a REQ per snapshot", async () => { + const SNAPSHOT_COUNT = 20; + const relay = installRelayStub(); + + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + queryClient.setQueryData(["identity"], { pubkey: VIEWER }); + queryClient.setQueryData(myRelayMembershipLookupQueryKey, { + snapshotFound: true, + membershipRequired: true, + membership: { + pubkey: VIEWER, + role: "owner", + addedBy: null, + createdAt: null, + }, + }); + + // Mirrors CommunityMembersSettingsCard:251 — the real query hook, so this + // arm cannot pass by re-declaring the observer the regression runs through. + const observed = { roster: undefined }; + function MembersPanelObserver() { + observed.roster = useRelayMembersQuery(true).data; + return null; + } + + // The panel opens INSIDE the mounted tree, the way navigating to Settings + // does. Mounting a second tree instead would give the observer its own + // QueryClient and the invalidation could never reach it. + const openPanel = { current: null }; + function Harness() { + useCommunityJoinAlerts({ enabled: true }); + const [open, setOpen] = React.useState(false); + openPanel.current = setOpen; + return open ? React.createElement(MembersPanelObserver, null) : null; + } + + const container = document.createElement("div"); + const root = createRoot(container); + const render = async () => { + await act(async () => { + root.render( + React.createElement( + QueryClientProvider, + { client: queryClient }, + React.createElement( + CommunitiesProvider, + null, + React.createElement(Harness, null), + ), + ), + ); + }); + }; + + /** Emit `SNAPSHOT_COUNT` growing rosters, returning REQ frames spent. */ + const burst = async (startAt) => { + const before = relay.counts().fetchFirstEventCalls; + const roster = [VIEWER]; + for (let i = 0; i < SNAPSHOT_COUNT; i++) { + roster.push(String(i).padStart(64, "e")); + const event = snapshot([...roster], { + id: `snap-req-${startAt}-${i}`, + createdAt: startAt + i, + }); + relay.setRefetchSnapshot(event); + relay.emitSnapshot(event); + await settle(2); + } + await settleAfterNotifyWindow(); + return relay.counts().fetchFirstEventCalls - before; + }; + + await render(); + await settle(); + + // Arm 1 — panel closed (negative control). + const closedArmReqs = await burst(1_000); + assert.equal( + closedArmReqs, + 0, + `panel closed must cost no REQ; spent ${closedArmReqs}`, + ); + + // Arm 2 — panel open: the regression arm. + await act(async () => { + openPanel.current(true); + }); + await settle(); + const openArmReqs = await burst(2_000); + + assert.equal( + openArmReqs, + 0, + `an open members panel must not cost a REQ per snapshot; spent ${openArmReqs} across ${SNAPSHOT_COUNT} snapshots`, + ); + + // The half a REQ count cannot see: deleting the write scores 0 REQ too. + assert.equal( + observed.roster?.length, + SNAPSHOT_COUNT + 1, + `the panel must observe the full roster (viewer + ${SNAPSHOT_COUNT}); got ${observed.roster?.length}`, + ); + + await act(async () => { + root.unmount(); + }); + }); +}); diff --git a/desktop/src/features/community-members/useCommunityJoinAlerts.ts b/desktop/src/features/community-members/useCommunityJoinAlerts.ts new file mode 100644 index 0000000000..72c8a2e912 --- /dev/null +++ b/desktop/src/features/community-members/useCommunityJoinAlerts.ts @@ -0,0 +1,538 @@ +import * as React from "react"; +import { useQueryClient } from "@tanstack/react-query"; + +import { + myRelayMembershipLookupQueryKey, + relayMembersQueryKey, +} from "@/features/community-members/hooks"; +import { useMyRelayMembershipLookupQuery } from "@/features/community-members/hooks"; +import { useCommunities } from "@/features/communities/useCommunities"; +import { + joinAlertBody, + joinAlertSummaryBody, + joinAlertTitle, + normalizeJoinPubkey, + readJoinAlertLedger, + reconcileJoinAlertLedger, + writeJoinAlertLedger, + type JoinAlertLedger, + JOIN_ALERT_MAX_INDIVIDUAL, +} from "@/features/community-members/lib/joinAlerts"; +import { sendDesktopNotification } from "@/features/notifications/lib/desktop"; +import { resolveUserLabel } from "@/features/profile/lib/identity"; +import type { UserProfileLookup } from "@/features/profile/lib/identity"; +import { relayClient } from "@/shared/api/relayClient"; +import { useIdentityQuery } from "@/shared/api/hooks"; +import { + canManageCommunityMembers, + relayMembersFromEvent, +} from "@/shared/api/relayMembers"; +import { getUsersBatch } from "@/shared/api/tauriProfiles"; +import type { RelayEvent, RelayMember } from "@/shared/api/types"; + +const KIND_NIP43_MEMBERSHIP_LIST = 13534; +const KIND_NIP43_MEMBER_ADDED = 8000; + +/** + * Trailing window for coalescing kind:8000-triggered snapshot refetches. + * + * Long enough that a bulk add collapses to a single REQ, short enough that a + * lone join still feels immediate — the accelerator exists only to beat the + * live snapshot's own arrival, so sub-second is the whole budget. + */ +const MEMBER_REFRESH_DEBOUNCE_MS = 500; + +/** + * Everything one mounted effect run is allowed to act on. + * + * The subscription callbacks that deliver snapshots belong to the effect run + * that registered them, but `handleSnapshot` is a `useEffectEvent` and so reads + * whatever is *currently* rendered. Between the re-render that switches + * community and that effect's cleanup, those two disagree — and a snapshot from + * the old community would be folded into the new community's ledger under the + * new community's storage key. + * + * Binding the identity, the ledger, and the ordering state into one object + * created by the effect run turns those scattered ambient reads into a single + * value with an identity that can be compared. `handleSnapshot` still reads + * `sessionRef.current`, so it is the surrounding ordering that makes the bug + * unreachable: cleanup retires the session before the next run installs its + * own, each retired callback is stopped by its run's `disposed` flag, and every + * send boundary re-checks that the session it captured is still the live one. + */ +type JoinAlertSession = { + communityId: string; + viewerPubkey: string; + ledger: JoinAlertLedger; + /** + * `created_at` of the newest snapshot already folded in. + * + * A snapshot older than this is a stale view of the roster — an in-flight + * refetch that resolves after a newer live frame — and must not be treated as + * current. Without this, an older frame can re-alert a departed key or, worse, + * re-assert an authorization a newer frame just revoked. + */ + newestSnapshotAt: number; + /** + * Latched once a snapshot shows the viewer is no longer owner/admin. + * + * Fail-closed, and deliberately stronger than the `newestSnapshotAt` fence: + * the relay can publish two snapshots within the same second, so an + * equal-`created_at` stale frame passes a strictly-older fence. Dropping + * equal timestamps instead would discard legitimate same-second joins. The + * latch removes the timestamp from the safety argument entirely — once + * revocation is observed, this session is done disclosing, whatever order the + * remaining frames arrive in. + * + * Re-promotion is unaffected: nothing invalidates the membership lookup on + * promotion, so regaining the panel already requires a reload today. + */ + revoked: boolean; +}; + +/** + * Trailing quiet window for coalescing join alerts ACROSS snapshots. + * + * The per-snapshot cap bounds "one snapshot, many keys". It does nothing for + * "one burst, many snapshots": the relay republishes the whole 13534 as each + * concurrent add commits, so a 50-join storm arrives as a handful of growing + * rosters and each one independently emitted its own capped batch. Max measured + * 10 banners from 50 real joins at `fdeda44f0` for exactly this reason. + * + * Sized above the observed intermediate-snapshot cadence so a burst lands in + * one batch, and above MEMBER_REFRESH_DEBOUNCE_MS so an 8000-triggered refetch + * folds into the same window rather than flushing behind it. + */ +const JOIN_ALERT_NOTIFY_WINDOW_MS = 1_500; + +/** + * Ceiling on how long a batch may be deferred by the trailing window. + * + * `JOIN_ALERT_NOTIFY_WINDOW_MS` is a pure trailing debounce: every snapshot + * re-arms it, so a join cadence faster than the window defers delivery for as + * long as the joins keep coming. Measured before this clamp existed: 13 joins at + * ~700ms intervals produced zero notifications across 9.1 continuous seconds. + * + * That is the wrong shape for an alerting feature, and it is worse than mere + * lateness — the ledger is persisted per snapshot while delivery waits, so a + * quit or community switch mid-drip drops a batch the ledger already recorded as + * alerted, and it is never re-announced. Clamping bounds both the silence and + * that loss window. + * + * Sized against both ends rather than picked round: it must exceed the span a + * bulk add's intermediate snapshots occupy, or the clamp would split the burst + * this window exists to collapse, and it must sit BELOW the measured drip above, + * or it would leave the case that motivated it unchanged. A burst's snapshots + * arrive within a second or two of each other; the drip ran 9.1s. Five seconds + * clears the first by a wide margin and cuts the second roughly in half. + */ +const JOIN_ALERT_MAX_DEFERRAL_MS = 5_000; + +/** + * Notify community owners/admins the first time a key appears in their roster. + * + * Delivery rests on a live kind:13534 subscription because that snapshot is the + * only membership signal covering every join path with cross-pod propagation; + * see `lib/joinAlerts.ts` for the full rationale. Desktop's other 13534 read + * (`relayMembers.ts`) is a one-shot fetch, so without this subscription no + * snapshot ever arrives passively and nothing could fire. + * + * The kind:8000 delta is subscribed purely to shorten latency on the paths that + * emit one. It refreshes the authoritative snapshot rather than alerting from + * the delta's own payload, so one ledger governs both signals and the pair + * cannot double-alert. + * + * Viewer, community, and role are read from context rather than passed in: + * `AppShell` is at the file-size ratchet ceiling, so the mount has to stay a + * single call. + */ +export function useCommunityJoinAlerts({ enabled }: { enabled: boolean }) { + const queryClient = useQueryClient(); + const { activeCommunity } = useCommunities(); + const identityQuery = useIdentityQuery(); + const membershipQuery = useMyRelayMembershipLookupQuery(); + + const communityId = activeCommunity?.id ?? null; + const communityName = activeCommunity?.name ?? null; + const normalizedViewer = normalizeJoinPubkey( + identityQuery.data?.pubkey ?? "", + ); + const active = + enabled && + canManageCommunityMembers(membershipQuery.data) && + communityId !== null && + normalizedViewer.length > 0; + + // Session for the current effect run. Callbacks read it through this ref so + // they stay stable — re-subscribing on every roster change would drop deltas + // in the gap between REQ and CLOSE — but every read is validated against the + // session's own bound community, never against ambient render state. + const sessionRef = React.useRef(null); + + // Community name is read fresh rather than captured, because a rename does not + // re-key the effect and a captured name would go stale. Guarded by id at use + // time so it can only ever label its own community. + const communityNameRef = React.useRef<{ id: string; name: string } | null>( + null, + ); + communityNameRef.current = + communityId === null + ? null + : { id: communityId, name: communityName ?? "" }; + + const resolveTitle = React.useCallback((session: JoinAlertSession) => { + const named = communityNameRef.current; + // Fall back to the generic title rather than a name belonging to a + // different community. + return joinAlertTitle( + named?.id === session.communityId ? named.name : null, + ); + }, []); + + // Pending cross-snapshot batch. A burst arrives as several growing rosters, + // so alerts accumulate here and flush once the roster stops moving. + // + // `pendingEventRef` holds the LATEST snapshot only, as the notification's + // click target. Every key in the batch is present in that roster (the ledger + // is monotonic within a burst), so the newest snapshot is the accurate + // referent for the whole batch. + const pendingRef = React.useRef([]); + const pendingEventRef = React.useRef(null); + const notifyTimerRef = React.useRef(null); + // When the batch currently pending first enqueued, for the deferral clamp. + const pendingSinceRef = React.useRef(null); + + // Cancellation token for flushes already past the refs. + // + // Clearing the refs cannot stop a flush that has already consumed them and + // is parked on an await, and every send in `flushPending` sits behind one: + // the profile lookup, and each notification itself. A demotion, removal, + // unmount, or community switch landing in that window would otherwise still + // deliver — Max and Wren both found this at 5d0d2b4c. + // + // Bumped ONLY by `clearPending`, never by an ordinary enqueue, so an + // authorized batch queued while an earlier flush's lookup is in flight + // neither cancels it nor is cancelled by it: both deliver. Cancellation is + // the only thing that invalidates a claim. + const flushGenerationRef = React.useRef(0); + + /** Drop anything queued but not yet delivered, in flight or not. */ + const clearPending = React.useCallback(() => { + pendingRef.current = []; + pendingEventRef.current = null; + pendingSinceRef.current = null; + flushGenerationRef.current += 1; + if (notifyTimerRef.current !== null) { + window.clearTimeout(notifyTimerRef.current); + notifyTimerRef.current = null; + } + }, []); + + const flushPending = React.useEffectEvent(async () => { + const session = sessionRef.current; + const alerts = pendingRef.current; + const event = pendingEventRef.current; + pendingRef.current = []; + pendingEventRef.current = null; + pendingSinceRef.current = null; + if (alerts.length === 0 || !event || !session) return; + // A session that observed revocation never delivers, even if a batch was + // queued before the latch closed. + if (session.revoked) return; + + // Claim this batch. Checked again at every side-effect boundary below — + // not merely after the awaits that exist today, so that adding an await + // later cannot silently reopen the disclosure. + const generation = flushGenerationRef.current; + const cancelled = () => + flushGenerationRef.current !== generation || + sessionRef.current !== session || + session.revoked; + + // Bind the title to the community these keys were queued under, not to + // whatever is active when the send resolves. + const title = resolveTitle(session); + + // Resolve display names so the alert reads "Alice joined" rather than a + // truncated key; a lookup failure degrades to the key, it does not skip. + // + // Above the cap the batch collapses into one summary, so skip the profile + // fetch entirely — it would be a 250-key request whose result is unused. + if (alerts.length > JOIN_ALERT_MAX_INDIVIDUAL) { + if (cancelled()) return; + await sendDesktopNotification({ + body: joinAlertSummaryBody(alerts.length), + target: { + channelId: null, + eventId: event.id, + kind: event.kind, + pubkey: undefined, + }, + title, + }); + return; + } + + let profiles: UserProfileLookup | undefined; + try { + profiles = (await getUsersBatch(alerts)).profiles; + } catch { + profiles = undefined; + } + + for (const pubkey of alerts) { + // Per-send, not once after the lookup: a demotion landing between two + // named sends must suppress the rest of the batch, not just the batch + // that had not started. + if (cancelled()) return; + await sendDesktopNotification({ + body: joinAlertBody( + resolveUserLabel({ preferResolvedSelfLabel: true, profiles, pubkey }), + ), + target: { + channelId: null, + eventId: event.id, + kind: event.kind, + pubkey, + }, + title, + }); + } + }); + + const handleSnapshot = React.useEffectEvent(async (event: RelayEvent) => { + const session = sessionRef.current; + if (!session) return; + // Already revoked: this session neither alerts nor learns anything further. + if (session.revoked) return; + + const roster = relayMembersFromEvent(event); + const rosterPubkeys = roster.map((member) => member.pubkey); + if (rosterPubkeys.length === 0) return; + + // Drop a stale view of the roster before it can be treated as current. + // + // An in-flight refetch (kind:8000 accelerator or reconnect) can resolve + // AFTER a newer live frame. Processing it would fold a superseded roster in + // as authoritative — re-alerting a departed key, and re-asserting an + // authorization the newer frame revoked. Strictly older only: two snapshots + // can share a second, and dropping equal timestamps would discard real + // joins. The revocation latch, not this fence, is what makes the privacy + // arm safe at equal timestamps. + const snapshotAt = event.created_at; + if (snapshotAt < session.newestSnapshotAt) return; + + // The roster can change shape without anything being new to us (a removal + // or a role change), so refresh the panel regardless of alert eligibility. + // + // Written directly rather than invalidated. `invalidateQueries` refetches + // every ACTIVE observer, and `listRelayMembers` is a REQ frame + // (`fetchFirstEvent({ kinds: [13534], limit: 1 })`), so with the members + // panel open this path emitted one REQ per accepted snapshot — measured + // 1:1 across 20 snapshots, live and in unit, against a documented budget + // of limit x window = 50 REQ per 5s (`default_human_ws()` = 10/s, + // `WS_BURST_WINDOW_SECS` = 5; REQ is billed as `WsEvents`). A join burst + // large enough to matter would rate-limit the owner out of their own app, + // and unlike the kind:8000 accelerator this path is not behind + // `MEMBER_REFRESH_DEBOUNCE_MS`. + // + // The refetch was never load-bearing: `roster` above is the output of the + // same `relayMembersFromEvent` parser `listRelayMembers` feeds the query + // with (`relayMembers.ts:125-127`), from a snapshot this session has + // already accepted as current — so the write is the identical shape and + // strictly fresher than a refetch, which would race the stream that + // triggered it. The stale fence above guarantees no superseded roster + // reaches here, and the query client is per-community + // (`CommunityQueryProvider key={communityKey}`, `App.tsx:556`), so this + // non-community-scoped key cannot be written across a switch. + queryClient.setQueryData(relayMembersQueryKey, roster); + + // Authorize against the snapshot in hand, not the cached role that mounted + // this effect. `useMyRelayMembershipLookupQuery` is only invalidated by this + // client's own membership mutations, and `staleTime` marks data stale + // without scheduling a refetch, so a viewer demoted by another admin keeps + // a cached owner/admin role for as long as the app stays open — and would + // otherwise keep learning every later joiner's identity from a role they no + // longer hold. The snapshot carries the viewer's own role + // (`["member", pubkey, role]`, relay-signed in `publish_nip43_membership_locked`), + // so the event that revokes authorization is the same event that would + // disclose the join. Checking it here closes that race in one read rather + // than racing an async invalidation. + // + // Fail closed: a snapshot that does not list the viewer at all means they + // were removed outright. + const viewerEntry = roster.find( + (member) => member.pubkey === session.viewerPubkey, + ); + if (viewerEntry?.role !== "owner" && viewerEntry?.role !== "admin") { + // Latch, so no later frame — including an older authorized snapshot still + // in flight — can re-open disclosure for this session. + session.revoked = true; + // Revocation must also drop anything queued but not yet delivered. + // Batching across snapshots would otherwise reopen the disclosure Wren + // found as a *delayed* one: joins accumulated while authorized would + // still fire from a timer after the snapshot that revoked the role. + clearPending(); + // Refresh the mount gate so the subscriptions themselves tear down. + void queryClient.invalidateQueries({ + queryKey: myRelayMembershipLookupQueryKey, + }); + return; + } + + // Fence advances only here: past the roster and authorization checks, on a + // frame this session actually accepts as its current view. Advancing it at + // the comparison instead would let a frame rejected for some *other* reason + // push the fence past a legitimate frame still in flight, dropping a real + // snapshot as though it were stale. + session.newestSnapshotAt = snapshotAt; + + const { alerts, changed, ledger } = reconcileJoinAlertLedger({ + ledger: session.ledger, + rosterPubkeys, + viewerPubkey: session.viewerPubkey, + }); + if (!changed) return; + + // Persisted before notifying, never after: a crash between the two must + // lose the notification rather than repeat it on every later snapshot. + // + // A write that cannot land (quota still exceeded after cache eviction) + // leaves the session's ledger alone deliberately. Advancing it would mark + // these keys seen in memory while nothing reached storage, so the alert + // would be lost until a reload; leaving it means the next snapshot retries + // the write and the alert survives to whichever attempt lands. The notify is + // skipped either way — a false return means nothing was persisted, so + // notifying here is exactly the "repeat on every later snapshot" this + // ordering exists to prevent. + if ( + !writeJoinAlertLedger(session.communityId, session.viewerPubkey, ledger) + ) { + return; + } + session.ledger = ledger; + if (alerts.length === 0) return; + + // Queue rather than notify. Persistence and the ledger advance stay + // synchronous per snapshot (above), so cross-snapshot dedupe still holds + // and a crash before the flush loses the alert rather than repeating it — + // the ordering invariant this feature already committed to. Only the + // delivery is deferred, onto a trailing quiet window, so one burst + // produces one alert instead of one per intermediate snapshot. + pendingRef.current.push(...alerts); + pendingEventRef.current = event; + if (notifyTimerRef.current !== null) { + window.clearTimeout(notifyTimerRef.current); + } + const now = Date.now(); + if (pendingSinceRef.current === null) pendingSinceRef.current = now; + // Clamp the trailing window so a sustained drip cannot defer delivery (and + // the ledger-already-written loss window) without bound. + const deadline = pendingSinceRef.current + JOIN_ALERT_MAX_DEFERRAL_MS; + const delay = Math.max( + 0, + Math.min(JOIN_ALERT_NOTIFY_WINDOW_MS, deadline - now), + ); + notifyTimerRef.current = window.setTimeout(() => { + notifyTimerRef.current = null; + void flushPending(); + }, delay); + }); + + React.useEffect(() => { + if (!active || communityId === null) return; + + // One session per effect run. Every callback below reaches this community's + // ledger and this viewer's role through it and cannot reach any other, so a + // switch mid-flight is a cancelled session rather than a mislabeled alert. + const session: JoinAlertSession = { + communityId, + ledger: readJoinAlertLedger(communityId, normalizedViewer), + newestSnapshotAt: 0, + revoked: false, + viewerPubkey: normalizedViewer, + }; + sessionRef.current = session; + + let disposed = false; + const disposers: Array<() => Promise> = []; + let refreshTimeout: number | null = null; + + const track = (unsubscribe: () => Promise) => { + if (disposed) { + void unsubscribe(); + return; + } + disposers.push(unsubscribe); + }; + + const fetchSnapshot = () => { + void relayClient + .fetchFirstEvent({ kinds: [KIND_NIP43_MEMBERSHIP_LIST], limit: 1 }) + .then((snapshot) => { + if (!disposed && snapshot) void handleSnapshot(snapshot); + }) + .catch(() => { + // Best effort: the live 13534 subscription still delivers. + }); + }; + + /** + * Coalesce refetches on a trailing window. + * + * Each refetch is a REQ frame, and REQ is billed against the same per- + * principal `WsEvents` budget as the user's own sends (default 10/s over a + * 5s window). A bulk add emits one kind:8000 per member, so an uncoalesced + * 1:1 refetch would spend the budget the owner needs for messages and + * channel opens — rate-limiting them out of their own app. One snapshot is + * authoritative for the whole burst, so the trailing edge loses nothing. + */ + const refreshSnapshot = () => { + if (disposed || refreshTimeout !== null) return; + refreshTimeout = window.setTimeout(() => { + refreshTimeout = null; + if (!disposed) fetchSnapshot(); + }, MEMBER_REFRESH_DEBOUNCE_MS); + }; + + void relayClient + .subscribeLive({ kinds: [KIND_NIP43_MEMBERSHIP_LIST], limit: 1 }, (e) => { + if (!disposed) void handleSnapshot(e); + }) + .then(track) + .catch((error) => { + console.error("Couldn’t subscribe to community membership", error); + }); + + // Accelerator only: refetch the authoritative snapshot instead of trusting + // the delta, so the ledger only ever sees one consistent roster view. + void relayClient + .subscribeLive({ kinds: [KIND_NIP43_MEMBER_ADDED], limit: 0 }, () => { + if (!disposed) refreshSnapshot(); + }) + .then(track) + .catch((error) => { + console.error("Couldn’t subscribe to community joins", error); + }); + + // A reconnect can span joins that landed while the socket was down, and + // `limit: 1` backfill is not guaranteed to redeliver them. + const unsubscribeReconnect = + relayClient.subscribeToReconnects(refreshSnapshot); + + return () => { + disposed = true; + if (refreshTimeout !== null) window.clearTimeout(refreshTimeout); + // Retire the session before dropping the batch, so any flush already past + // the refs sees `sessionRef.current !== session` and stops. Guarded in + // case a later run has already installed its own. + if (sessionRef.current === session) sessionRef.current = null; + // Drop the queued batch too, not just its timer: on a community switch + // this effect re-keys, and keys accumulated for the old community must + // not flush against the new one. + clearPending(); + unsubscribeReconnect(); + for (const dispose of disposers) void dispose(); + }; + }, [active, communityId, normalizedViewer, clearPending]); +} From 67b77344d61fa663411f99a9518296f31969078e Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Thu, 6 Aug 2026 17:13:55 -0700 Subject: [PATCH 08/61] fix(desktop): next/back navigation during key creation onboarding (#4978) **Category:** fix **User Impact:** Users can navigate back while an identity key is being created, while Next remains visible and unavailable until creation finishes. **Problem:** The key-creation hold hid both navigation actions, leaving users without an escape route or a clear indication of what would happen next. **Solution:** Keep the onboarding footer mounted throughout creation, leave Back enabled, and gate Next on the completed identity state.
File changes **desktop/src/features/onboarding/ui/BackupStep.tsx** Keeps the onboarding navigation footer visible during key creation, with Back available and Next disabled until the identity is ready. **desktop/tests/e2e/onboarding-backup.spec.ts** Covers the loading and completed navigation states so the intended behavior cannot quietly crawl back out of the pit.
## Reproduction steps 1. Start desktop onboarding and choose to create a new identity. 2. Submit the profile step and observe the key-creation screen. 3. Confirm Back is enabled while Next is visible but disabled. 4. Wait for key creation to finish and confirm Next becomes enabled. ## Screenshots | Before | After | | --- | --- | | Navigation actions are hidden during key creation. | Back remains enabled while Next stays visible and disabled. | | ![Before: key creation screen without navigation actions](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/4978/key-creation-before-bird.png) | ![After: key creation screen with disabled Next and enabled Back](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/4978/key-creation-after-bird.png) | Signed-off-by: Taylor Ho --- .../src/features/onboarding/ui/BackupStep.tsx | 42 +++++++++---------- desktop/tests/e2e/onboarding-backup.spec.ts | 4 ++ 2 files changed, 24 insertions(+), 22 deletions(-) diff --git a/desktop/src/features/onboarding/ui/BackupStep.tsx b/desktop/src/features/onboarding/ui/BackupStep.tsx index 99d9c6324d..2367b9faf9 100644 --- a/desktop/src/features/onboarding/ui/BackupStep.tsx +++ b/desktop/src/features/onboarding/ui/BackupStep.tsx @@ -410,29 +410,27 @@ export function BackupStep({ )} - {created ? ( - - + + - - - ) : null} + + ); } diff --git a/desktop/tests/e2e/onboarding-backup.spec.ts b/desktop/tests/e2e/onboarding-backup.spec.ts index df3528c8bd..4b9040bd2b 100644 --- a/desktop/tests/e2e/onboarding-backup.spec.ts +++ b/desktop/tests/e2e/onboarding-backup.spec.ts @@ -59,6 +59,9 @@ test("backup step appears on fresh-key path after profile submit", async ({ page.getByRole("heading", { name: "Creating your identity key" }), ).toBeVisible(); await expect(page.getByTestId("backup-intro-logo")).toBeVisible(); + await expect(page.getByTestId("onboarding-next")).toBeVisible(); + await expect(page.getByTestId("onboarding-next")).toBeDisabled(); + await expect(page.getByTestId("onboarding-back")).toBeEnabled(); await expect( page.getByRole("heading", { @@ -66,6 +69,7 @@ test("backup step appears on fresh-key path after profile submit", async ({ }), ).toBeVisible(); await expect(page.getByTestId("backup-intro-logo")).toHaveCount(0); + await expect(page.getByTestId("onboarding-next")).toBeEnabled(); }); // --------------------------------------------------------------------------- From f03de210cd0e384870aaa00cb1fa6985a75640ff Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Thu, 6 Aug 2026 17:14:12 -0700 Subject: [PATCH 09/61] fix(desktop): preserve authoritative agent avatars (#4984) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **Category:** fix **User Impact:** Agent cards and catalog listings now show the avatar belonging to the identity they represent. **Problem:** Running agent cards could show a stale definition avatar instead of the concrete agent profile, while adding another publisher's catalog entry could let local edits repaint that publisher's listing. This made agent identity look inconsistent across My Agents and the Agent Catalog. **Solution:** Treat the concrete agent pubkey profile as authoritative for running-card avatars, with the linked definition as fallback. Keep relay publications authoritative for foreign catalog presentation while using local copies only for linkage and selection state. | before | after | |--|--| | Screenshot 2026-08-06 at 3 48
43 PM | Screenshot 2026-08-06 at 3 48
40 PM | | agent-set avatar not showing | agent-set avatar is showing | ## Changes
File changes **desktop/src/features/agents/lib/agentCardAvatar.ts** Adds the explicit avatar precedence rule for running agent cards and blocks avatar-dependent actions until the authoritative profile query settles. **desktop/src/features/agents/lib/agentCardAvatar.test.mjs** Covers profile precedence, definition fallback, blank avatar handling, and the profile-loading transition for linked-agent actions. **desktop/src/features/agents/lib/personaCatalogRelay.ts** Keeps publisher-provided catalog identity and behavior fields authoritative after a local copy is added. **desktop/src/features/agents/lib/personaCatalogRelay.test.mjs** Verifies local copies contribute linkage and selection without overriding publisher presentation. **desktop/src/features/agents/ui/UnifiedAgentsSection.tsx** Uses the concrete agent profile avatar before the linked definition avatar on running-agent cards.
## Reproduction Steps ### Running agent card uses the agent profile avatar Use two visibly different, publicly reachable image URLs: **A** for the saved definition and **B** for the running agent profile. 1. In **Settings → Experiments**, enable **Agent-managed profiles**. This prevents Desktop from restoring the definition avatar over an agent's own relay-profile changes. 2. In **Agents**, create an agent with image **A** as its avatar and start it. 3. In a channel containing that agent, ask it to update its own Buzz profile avatar to image **B**. The exact CLI operation under the agent identity is `buzz users set-profile --avatar `. 4. After the agent confirms the update, reopen **Agents → My Agents** (or reload the page so its kind:0 profile is fetched again). 5. Verify the running agent card shows image **B**, not definition image **A**. Open **⋯ → Share** and verify the share flow also uses image **B**. Before this fix, the My Agents card and share flow preferred image **A** whenever the linked definition had an avatar. ### Catalog listing remains publisher-authoritative This scenario requires a second Buzz identity so the entry is foreign to the account under test. 1. As the publisher identity, create an agent definition with a distinctive name, avatar, and instructions, then use **Share → Share to catalog**. 2. As the test identity, open **Agents → Discover agents**, find that publication, and add it. 3. In **My Agents**, open the added copy's **⋯ → Edit**, change its name, avatar, and instructions, and save. 4. Return to **Discover agents** and find the same publisher entry. 5. Verify it remains selected/added but still shows the publisher's original name, avatar, and instructions—not the test identity's local edits. ## Validation - `pnpm test` — 4,376 passed - `pnpm typecheck` — passed - `pnpm check` — passed with existing non-error notices --------- Signed-off-by: Taylor Ho --- .../agents/lib/agentCardAvatar.test.mjs | 37 +++++++++++++++++++ .../features/agents/lib/agentCardAvatar.ts | 29 +++++++++++++++ .../agents/lib/personaCatalogRelay.test.mjs | 17 +++++++-- .../agents/lib/personaCatalogRelay.ts | 12 ++++-- .../agents/ui/UnifiedAgentsSection.tsx | 18 +++------ 5 files changed, 95 insertions(+), 18 deletions(-) create mode 100644 desktop/src/features/agents/lib/agentCardAvatar.test.mjs create mode 100644 desktop/src/features/agents/lib/agentCardAvatar.ts diff --git a/desktop/src/features/agents/lib/agentCardAvatar.test.mjs b/desktop/src/features/agents/lib/agentCardAvatar.test.mjs new file mode 100644 index 0000000000..5acd9ae109 --- /dev/null +++ b/desktop/src/features/agents/lib/agentCardAvatar.test.mjs @@ -0,0 +1,37 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + isAgentCardAvatarLoading, + resolveAgentCardAvatarUrl, +} from "./agentCardAvatar.ts"; + +test("running agent card prefers the pubkey profile avatar", () => { + assert.equal( + resolveAgentCardAvatarUrl( + "https://relay.example/instance.png", + "https://relay.example/definition.png", + ), + "https://relay.example/instance.png", + ); +}); + +test("running agent card falls back to the definition avatar", () => { + assert.equal( + resolveAgentCardAvatarUrl(null, " https://relay.example/definition.png "), + "https://relay.example/definition.png", + ); +}); + +test("running agent card ignores blank avatar values", () => { + assert.equal(resolveAgentCardAvatarUrl(" ", ""), null); +}); + +test("linked agent actions wait for the authoritative profile avatar", () => { + assert.equal(isAgentCardAvatarLoading(true, true), true); + assert.equal(isAgentCardAvatarLoading(true, false), false); +}); + +test("unlinked persona actions do not wait for a profile", () => { + assert.equal(isAgentCardAvatarLoading(false, true), false); +}); diff --git a/desktop/src/features/agents/lib/agentCardAvatar.ts b/desktop/src/features/agents/lib/agentCardAvatar.ts new file mode 100644 index 0000000000..057c413daa --- /dev/null +++ b/desktop/src/features/agents/lib/agentCardAvatar.ts @@ -0,0 +1,29 @@ +/** + * Resolve the avatar for a running agent card. + * + * The card opens the concrete agent pubkey's profile, so that profile's kind:0 + * picture is authoritative. The linked definition remains a fallback while the + * profile is missing or has no picture. + */ +export function resolveAgentCardAvatarUrl( + profileAvatarUrl: string | null | undefined, + personaAvatarUrl: string | null | undefined, +): string | null { + for (const candidate of [profileAvatarUrl, personaAvatarUrl]) { + const trimmed = candidate?.trim(); + if (trimmed) return trimmed; + } + return null; +} + +/** + * A linked agent's profile is authoritative even when the definition already + * supplies a fallback. Avatar-dependent actions must wait for that profile + * query so they cannot snapshot the fallback before the profile resolves. + */ +export function isAgentCardAvatarLoading( + hasLinkedAgent: boolean, + isProfilePending: boolean, +): boolean { + return hasLinkedAgent && isProfilePending; +} diff --git a/desktop/src/features/agents/lib/personaCatalogRelay.test.mjs b/desktop/src/features/agents/lib/personaCatalogRelay.test.mjs index fbaf1f5274..ef516f4b01 100644 --- a/desktop/src/features/agents/lib/personaCatalogRelay.test.mjs +++ b/desktop/src/features/agents/lib/personaCatalogRelay.test.mjs @@ -319,12 +319,20 @@ function localPersona(overrides = {}) { // The duplicate-add bug: a copy of Alice's entry carries a fresh local UUID, so // matching by id finds nothing and the catalog offers "Add" again. Only the // stored catalogSource coordinate links the copy back to the publication. -test("test_added_foreign_catalog_entry_resolves_to_its_local_copy", () => { +test("test_added_foreign_catalog_entry_keeps_publisher_identity_and_local_selection", () => { + const publisherAvatar = "https://relay.example/publisher.png"; const publications = catalogPublicationsFromEvents([ - personaEvent({ createdAt: 1, id: "alice-reviewer" }), + personaEvent({ + createdAt: 1, + id: "alice-reviewer", + avatarUrl: publisherAvatar, + }), ]); const copy = localPersona({ id: "a-fresh-uuid", + displayName: "Locally Renamed Reviewer", + avatarUrl: "https://relay.example/local-copy.png", + systemPrompt: "Locally edited instructions.", catalogSource: { ownerPubkey: ALICE, personaId: "reviewer" }, }); @@ -334,13 +342,16 @@ test("test_added_foreign_catalog_entry_resolves_to_its_local_copy", () => { assert.equal( personas[0].id, "a-fresh-uuid", - "the projection must resolve to the existing local copy, not a synthetic id", + "the projection must retain the existing local copy's linkage id", ); assert.equal( personas[0].isActive, true, "an added foreign entry must read as already selected", ); + assert.equal(personas[0].displayName, "Relay Reviewer"); + assert.equal(personas[0].avatarUrl, publisherAvatar); + assert.equal(personas[0].systemPrompt, "Review changes."); }); test("test_foreign_entry_with_no_local_copy_stays_unselected", () => { diff --git a/desktop/src/features/agents/lib/personaCatalogRelay.ts b/desktop/src/features/agents/lib/personaCatalogRelay.ts index 02c3f8e202..a588843b1e 100644 --- a/desktop/src/features/agents/lib/personaCatalogRelay.ts +++ b/desktop/src/features/agents/lib/personaCatalogRelay.ts @@ -289,8 +289,14 @@ function publicationToPersona( isOwn: boolean, ): CatalogPersona { const timestamp = new Date(publication.createdAt * 1_000).toISOString(); - const basePersona: AgentPersona = localPersona ?? { - id: `catalog:${publication.ownerPubkey}:${publication.sourcePersonaId}`, + // The publication remains authoritative for catalog presentation. An added + // local copy contributes only the linkage id and selected state; merging the + // whole copy would leak local edits (notably its avatar) into the publisher's + // catalog entry. + const basePersona: AgentPersona = { + id: + localPersona?.id ?? + `catalog:${publication.ownerPubkey}:${publication.sourcePersonaId}`, displayName: publication.agent.displayName, avatarUrl: publication.agent.avatarUrl, systemPrompt: publication.agent.systemPrompt, @@ -299,7 +305,7 @@ function publicationToPersona( provider: publication.agent.provider, namePool: publication.agent.namePool, isBuiltIn: false, - isActive: false, + isActive: localPersona?.isActive ?? false, shared: true, sourceTeam: null, envVars: {}, diff --git a/desktop/src/features/agents/ui/UnifiedAgentsSection.tsx b/desktop/src/features/agents/ui/UnifiedAgentsSection.tsx index 212d9bc96e..73562bda35 100644 --- a/desktop/src/features/agents/ui/UnifiedAgentsSection.tsx +++ b/desktop/src/features/agents/ui/UnifiedAgentsSection.tsx @@ -1,6 +1,10 @@ import * as React from "react"; import { AlertTriangle, ChevronDown, ChevronRight } from "lucide-react"; +import { + isAgentCardAvatarLoading, + resolveAgentCardAvatarUrl, +} from "@/features/agents/lib/agentCardAvatar"; import { resolveAgentCardModelLabel } from "@/features/agents/lib/agentCardModelLabel"; import { friendlyAgentLastError } from "@/features/agents/lib/friendlyAgentLastError"; import { isManagedAgentActive } from "@/features/agents/lib/managedAgentControlActions"; @@ -290,7 +294,7 @@ function AgentPersonaCard({ const isActive = agent ? isManagedAgentActive(agent) : false; const profileQuery = useUserProfileQuery(agent?.pubkey); const avatarUrl = agent - ? firstAvatarUrl(persona.avatarUrl, profileQuery.data?.avatarUrl) + ? resolveAgentCardAvatarUrl(profileQuery.data?.avatarUrl, persona.avatarUrl) : persona.avatarUrl; const friendlyError = agent ? friendlyAgentLastError(agent.lastError, agent.lastErrorCode)?.copy @@ -301,7 +305,7 @@ function AgentPersonaCard({ -): string | null { - for (const candidate of candidates) { - const trimmed = candidate?.trim(); - if (trimmed) return trimmed; - } - return null; -} - function NewAgentCard({ isPending, onCreate, From 769ac70b741e3ad6809bff14eba29d3dd2cbd318 Mon Sep 17 00:00:00 2001 From: Jordan Mecom Date: Thu, 6 Aug 2026 19:46:42 -0500 Subject: [PATCH 10/61] fix(media): require authenticated reads (#4610) This change requires a valid signed Blossom authorization request and current relay membership for every media GET and HEAD request. It removes the unauthenticated compatibility path and updates desktop reads to send the required authorization. This blocks anonymous retrieval and access after relay-membership revocation. It does not yet bind a blob to its originating channel, so someone removed from a private channel can still read a known blob while remaining a relay member. That channel-ACL follow-up remains required before closing the full finding. ## Testing - `git diff --check origin/main...codex/security-media-read-auth` - Rebased onto `origin/main` at `5c98932` - Full CI pending Originating Buzz thread: `buzz://message?channel=3928fe05-df61-4b5d-b9c7-d623b9b10ea1&id=3c6c02312f763fbe0d2bfc33a6c1a362f91d0354f3d18b039cf7a0558c1439d1` --------- Signed-off-by: Jordan Mecom Signed-off-by: Alex Rosenzweig Signed-off-by: Eli Foster Co-authored-by: Eli Foster Co-authored-by: Claude Opus 5 --- .env.example | 9 +- .github/workflows/ci.yml | 13 +++ TESTING.md | 1 - crates/buzz-relay/src/api/media.rs | 53 +++------ crates/buzz-relay/src/config.rs | 102 +++++++++++++++--- .../tests/conformance_multitenant.rs | 22 ++-- crates/buzz-test-client/tests/e2e_media.rs | 90 +++++++++++++++- .../tests/e2e_media_extended.rs | 39 +++++-- .../buzz-test-client/tests/e2e_media_video.rs | 35 +++++- deploy/charts/buzz/templates/NOTES.txt | 5 - deploy/charts/buzz/templates/deployment.yaml | 1 - deploy/charts/buzz/tests/render_test.yaml | 29 ----- deploy/charts/buzz/values.schema.json | 1 - deploy/charts/buzz/values.yaml | 6 -- desktop/src-tauri/src/commands/media.rs | 8 +- .../src-tauri/src/commands/personas/card.rs | 6 +- docs/admin/README.md | 6 +- docs/multi-tenant-conformance.md | 2 +- 18 files changed, 295 insertions(+), 133 deletions(-) diff --git a/.env.example b/.env.example index b9bfcada0e..0f7bbba6f1 100644 --- a/.env.example +++ b/.env.example @@ -102,11 +102,10 @@ BUZZ_S3_ADDRESSING_STYLE=path # BUZZ_MEDIA_MAX_CONCURRENT_UPLOADS=8 # BUZZ_MEDIA_MAX_CONCURRENT_UPLOADS_PER_PUBKEY=2 # BUZZ_MEDIA_UPLOADS_PER_MINUTE=30 -# Require Blossom t=get auth and relay membership for GET/HEAD /media/*. -# Keep off until desktop/mobile/CLI clients that attach media read auth are deployed. -# BUZZ_REQUIRE_MEDIA_GET_AUTH=false -# Legacy alias accepted by the relay while rollout docs catch up: -# BUZZ_REQUIRE_MEDIA_READ_AUTH=false +# GET/HEAD /media/* always require Blossom t=get auth and relay membership. +# BUZZ_REQUIRE_MEDIA_GET_AUTH and BUZZ_REQUIRE_MEDIA_READ_AUTH are no longer +# read; setting either (including to false) changes nothing and the relay warns +# about it at startup. # ----------------------------------------------------------------------------- # Ephemeral Channels (TTL testing) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e65157705a..299fc9efe7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -768,6 +768,19 @@ jobs: env: RELAY_URL: ws://localhost:3000 GIT_CREDENTIAL_NOSTR_BIN: ${{ github.workspace }}/target/ci/git-credential-nostr + - name: Media read-auth e2e + # Reads require kind:24242 `t=get` auth, so these binaries are the only + # coverage that a real relay rejects bare reads and honours host- and + # hash-scoped tokens. They were #[ignore]d and selected by no CI job, so + # the lane never ran; select it here, where MinIO and the seeded + # 'localhost:3000' community already exist. + # --no-fail-fast: without it cargo stops after the first failing binary, + # so one broken case hides every later binary's result. + run: | + cargo test -p buzz-test-client --no-fail-fast --test e2e_media --test e2e_media_extended --test e2e_media_video -- --ignored --nocapture + env: + RELAY_URL: ws://localhost:3000 + RELAY_HTTP_URL: http://localhost:3000 - name: Upload relay logs if: failure() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 diff --git a/TESTING.md b/TESTING.md index 764b86d408..7c107da575 100644 --- a/TESTING.md +++ b/TESTING.md @@ -277,7 +277,6 @@ out of the box with `just setup` or `just relay`. Common overrides: | `REDIS_URL` | `redis://localhost:6379` | | | `BUZZ_REQUIRE_AUTH_TOKEN` | `false` | When true, REST requires NIP-98 (no `X-Pubkey` fallback) | | `BUZZ_REQUIRE_RELAY_MEMBERSHIP` | `false` | When true, only pubkeys in `relay_members` can connect | -| `BUZZ_REQUIRE_MEDIA_GET_AUTH` | `false` | When true, `GET`/`HEAD /media/*` require Blossom kind 24242 `t=get` auth plus relay membership. | | `BUZZ_DRAIN_JITTER_MS` | `0` (off) | Per-connection upper bound, in ms, for the random delay before each live WebSocket gets its `1012 Service Restart` close on graceful shutdown. `0` closes every socket at once (the previous behavior). A positive value spreads closes uniformly over `[1, value]` ms to avoid a reconnect thundering herd on rolling deploys. Values above `20000` are capped to `20000` (`MAX_DRAIN_JITTER_MS`) to leave close-frame delivery headroom under the relay's 30s hard-drain timeout. Empty or whitespace-only is treated as unset (off); a non-integer fails startup loudly. | | `BUZZ_AUDIT_ENABLED` | `true` | Tamper-evident event/media audit log. Set `false`/`0`/`off` to skip its DB pool and writes. Does not disable the separate moderation audit trail. | | `BUZZ_AUTO_MIGRATE` | `false` | Opt in with `true`/`1`/`yes`/`on` to run embedded SQLx migrations on relay startup | diff --git a/crates/buzz-relay/src/api/media.rs b/crates/buzz-relay/src/api/media.rs index fa0401bc26..a2f3640bde 100644 --- a/crates/buzz-relay/src/api/media.rs +++ b/crates/buzz-relay/src/api/media.rs @@ -493,10 +493,6 @@ async fn authenticate_media_read( ) -> Result { let tenant = bind_media_read_tenant(state, headers).await?; - if !state.config.require_media_get_auth { - return Ok(MediaReadAuth { tenant }); - } - let auth_event = extract_blossom_auth(headers)?; let sha256 = sha256_ext.split('.').next().unwrap_or(sha256_ext); buzz_media::auth::verify_blossom_get_auth(&auth_event, sha256, Some(tenant.host()), 3600)?; @@ -514,12 +510,8 @@ async fn authenticate_media_read( Ok(MediaReadAuth { tenant }) } -fn blob_cache_control(require_auth: bool) -> &'static str { - if require_auth { - "private, max-age=31536000, immutable" - } else { - "public, max-age=31536000, immutable" - } +fn blob_cache_control() -> &'static str { + "private, max-age=31536000, immutable" } /// Whether a path-segment extension is a safe token. @@ -623,7 +615,7 @@ pub(crate) async fn serve_blob_for_tenant( req_headers: &HeaderMap, ) -> Result { validate_media_path(sha256_ext)?; - let cache_control = blob_cache_control(state.config.require_media_get_auth); + let cache_control = blob_cache_control(); // Sidecar gate FIRST — reject before any blob I/O. Storage is not authoritative. let content_type = if sha256_ext.ends_with(".thumb.jpg") { @@ -801,10 +793,9 @@ pub async fn head_blob( Path(sha256_ext): Path, ) -> Result { validate_media_path(&sha256_ext)?; - let require_media_get_auth = state.config.require_media_get_auth; let media_auth = authenticate_media_read(&state, &headers, &sha256_ext).await?; let tenant = media_auth.tenant; - let cache_control = blob_cache_control(require_media_get_auth); + let cache_control = blob_cache_control(); // Sidecar gate FIRST — reject before any blob I/O. let content_type = if sha256_ext.ends_with(".thumb.jpg") { @@ -946,13 +937,8 @@ mod tests { } async fn test_state() -> Arc { - test_state_with_media_get_auth(false).await - } - - async fn test_state_with_media_get_auth(require_media_get_auth: bool) -> Arc { let mut config = crate::config::Config::from_env().expect("default config loads"); config.require_relay_membership = false; - config.require_media_get_auth = require_media_get_auth; config.redis_url = "redis://127.0.0.1:1".to_string(); config.media_uploads_per_minute = 1; config.media_max_concurrent_uploads = 2; @@ -994,8 +980,8 @@ mod tests { Arc::new(state) } - async fn media_get_auth_router(require_media_get_auth: bool) -> axum::Router { - let state = test_state_with_media_get_auth(require_media_get_auth).await; + async fn media_get_auth_router() -> axum::Router { + let state = test_state().await; axum::Router::new() .route( "/media/{sha256_ext}", @@ -1041,20 +1027,9 @@ mod tests { } #[tokio::test] - async fn media_get_auth_flag_off_allows_unauthenticated_read_until_sidecar_gate() { - let response = media_get_auth_router(false) - .await - .oneshot(media_request("GET", None)) - .await - .expect("response"); - - assert_eq!(response.status(), StatusCode::NOT_FOUND); - } - - #[tokio::test] - async fn media_get_auth_flag_on_rejects_unauthenticated_get_and_head_before_sidecar_gate() { + async fn media_reads_reject_unauthenticated_get_and_head_before_sidecar_gate() { for method in ["GET", "HEAD"] { - let response = media_get_auth_router(true) + let response = media_get_auth_router() .await .oneshot(media_request(method, None)) .await @@ -1065,10 +1040,10 @@ mod tests { } #[tokio::test] - async fn media_get_auth_flag_on_valid_server_scoped_token_reaches_sidecar_gate() { + async fn media_read_with_valid_server_scoped_token_reaches_sidecar_gate() { let keys = Keys::generate(); let auth = media_get_auth_header(&keys, media_get_tags_for("relay.example", None)); - let response = media_get_auth_router(true) + let response = media_get_auth_router() .await .oneshot(media_request("GET", Some(auth))) .await @@ -1078,7 +1053,7 @@ mod tests { } #[tokio::test] - async fn media_get_auth_flag_on_rejects_upload_verb_wrong_server_and_wrong_x() { + async fn media_read_rejects_upload_verb_wrong_server_and_wrong_x() { let keys = Keys::generate(); let now = Timestamp::now().as_secs(); let expiration = (now + 300).to_string(); @@ -1102,7 +1077,7 @@ mod tests { for tags in cases { let auth = media_get_auth_header(&keys, tags); - let response = media_get_auth_router(true) + let response = media_get_auth_router() .await .oneshot(media_request("GET", Some(auth))) .await @@ -1119,7 +1094,7 @@ mod tests { } #[tokio::test] - async fn media_get_auth_flag_on_accepts_range_header_only_after_auth() { + async fn media_read_accepts_range_header_only_after_auth() { let keys = Keys::generate(); let auth = media_get_auth_header(&keys, media_get_tags_for("relay.example", None)); let mut request = media_request("GET", Some(auth)); @@ -1127,7 +1102,7 @@ mod tests { .headers_mut() .insert(header::RANGE, "bytes=0-0".parse().expect("range header")); - let response = media_get_auth_router(true) + let response = media_get_auth_router() .await .oneshot(request) .await diff --git a/crates/buzz-relay/src/config.rs b/crates/buzz-relay/src/config.rs index dd50973d03..037c6b1dd3 100644 --- a/crates/buzz-relay/src/config.rs +++ b/crates/buzz-relay/src/config.rs @@ -227,10 +227,6 @@ pub struct Config { /// Maximum media upload starts accepted from one pubkey per minute. pub media_uploads_per_minute: u32, - /// Require Blossom kind:24242 `t=get` auth plus relay membership before - /// serving media GET/HEAD. Default off for staged client rollout. - pub require_media_get_auth: bool, - /// Whether tamper-evident event/media audit logging is enabled. Defaults to true. /// This does not control the separate `moderation_actions` audit trail. /// Set `BUZZ_AUDIT_ENABLED=false` for deployments that do not require it. @@ -435,6 +431,31 @@ fn ensure_git_path( Ok(git_repo_path) } +/// Env vars that once gated authenticated media reads. +/// +/// `BUZZ_REQUIRE_MEDIA_GET_AUTH` was the real flag; `BUZZ_REQUIRE_MEDIA_READ_AUTH` +/// was documented in `.env.example` as an accepted alias but was never read by +/// the relay. Media reads are now unconditionally authenticated, so both are +/// inert and an operator still setting either — especially to `false` — holds a +/// belief about their deployment that is no longer true. +const INERT_MEDIA_READ_AUTH_VARS: [&str; 2] = [ + "BUZZ_REQUIRE_MEDIA_GET_AUTH", + "BUZZ_REQUIRE_MEDIA_READ_AUTH", +]; + +/// Which of `names` are present, so startup can warn that they do nothing. +/// +/// `lookup` is injected rather than calling `std::env::var` directly: process +/// env is global mutable state, so a test that set real vars would race every +/// other test in the binary. +fn inert_env_vars<'a>(names: &[&'a str], lookup: impl Fn(&str) -> Option) -> Vec<&'a str> { + names + .iter() + .copied() + .filter(|name| lookup(name).is_some()) + .collect() +} + impl Config { /// Loads configuration from environment variables, falling back to development defaults. pub fn from_env() -> Result { @@ -776,14 +797,13 @@ impl Config { .filter(|&v| v > 0) .unwrap_or(30); - let require_media_get_auth = std::env::var("BUZZ_REQUIRE_MEDIA_GET_AUTH") - .map(|v| { - v == "true" - || v == "1" - || v.eq_ignore_ascii_case("yes") - || v.eq_ignore_ascii_case("on") - }) - .unwrap_or(false); + for name in inert_env_vars(&INERT_MEDIA_READ_AUTH_VARS, |n| std::env::var(n).ok()) { + warn!( + "{name} is set but is no longer read — GET/HEAD /media/* always require \ + Blossom t=get auth plus relay membership. Remove it; a value of `false` \ + does not re-open unauthenticated media reads." + ); + } let ephemeral_ttl_override = std::env::var("BUZZ_EPHEMERAL_TTL_OVERRIDE") .ok() @@ -1003,7 +1023,6 @@ impl Config { media_max_concurrent_uploads, media_max_concurrent_uploads_per_pubkey, media_uploads_per_minute, - require_media_get_auth, audit_enabled, ephemeral_ttl_override, git_repo_path, @@ -1035,6 +1054,59 @@ mod tests { // value set by `invalid_bind_addr_returns_error`, causing a flaky failure. static ENV_MUTEX: std::sync::Mutex<()> = std::sync::Mutex::new(()); + /// Look up against a fixed set, standing in for process env. + fn env_of<'a>(set: &'a [(&'a str, &'a str)]) -> impl Fn(&str) -> Option + use<'a> { + move |name| { + set.iter() + .find(|(key, _)| *key == name) + .map(|(_, value)| (*value).to_string()) + } + } + + /// The case that matters: an operator who pinned the old flag to `false` + /// must be told it is inert, not left believing media reads are still open. + #[test] + fn inert_media_read_auth_vars_are_reported_even_when_false() { + let found = inert_env_vars( + &INERT_MEDIA_READ_AUTH_VARS, + env_of(&[("BUZZ_REQUIRE_MEDIA_GET_AUTH", "false")]), + ); + + assert_eq!(found, vec!["BUZZ_REQUIRE_MEDIA_GET_AUTH"]); + } + + /// `BUZZ_REQUIRE_MEDIA_READ_AUTH` was advertised in `.env.example` as an + /// accepted alias but the relay never read it, so operators may hold it + /// today. It warns too. + #[test] + fn inert_media_read_auth_vars_include_the_documented_alias() { + let found = inert_env_vars( + &INERT_MEDIA_READ_AUTH_VARS, + env_of(&[ + ("BUZZ_REQUIRE_MEDIA_GET_AUTH", "true"), + ("BUZZ_REQUIRE_MEDIA_READ_AUTH", "false"), + ]), + ); + + assert_eq!( + found, + vec![ + "BUZZ_REQUIRE_MEDIA_GET_AUTH", + "BUZZ_REQUIRE_MEDIA_READ_AUTH" + ] + ); + } + + #[test] + fn inert_media_read_auth_vars_stay_quiet_when_unset() { + let found = inert_env_vars( + &INERT_MEDIA_READ_AUTH_VARS, + env_of(&[("BUZZ_REQUIRE_RELAY_MEMBERSHIP", "true")]), + ); + + assert!(found.is_empty(), "unrelated vars must not warn: {found:?}"); + } + #[test] fn defaults_are_valid() { let _guard = ENV_MUTEX.lock().unwrap(); @@ -1072,10 +1144,6 @@ mod tests { !config.serve_git_web_gui, "serve_git_web_gui should default to false" ); - assert!( - !config.require_media_get_auth, - "require_media_get_auth should default to false for staged client rollout" - ); assert_eq!( config.media.s3_addressing_style, buzz_media::config::S3AddressingStyle::Path, diff --git a/crates/buzz-test-client/tests/conformance_multitenant.rs b/crates/buzz-test-client/tests/conformance_multitenant.rs index 15002142e4..4c8c8904ac 100644 --- a/crates/buzz-test-client/tests/conformance_multitenant.rs +++ b/crates/buzz-test-client/tests/conformance_multitenant.rs @@ -2612,17 +2612,27 @@ mod pubsub_presence_typing { mod media_blossom { use super::*; - /// Obligation: public blob `GET/HEAD /media/{sha256.ext}` stays - /// unauthenticated (N=1 compat, shared CAS bytes). The community boundary is - /// the metadata/descriptor/upload-auth/quota/audit layer: B's private upload - /// metadata/errors must not be observable from A, even when the blob bytes - /// are deduplicated and shared. + /// Obligation: blob `GET/HEAD /media/{sha256.ext}` requires Blossom read auth + /// scoped to the serving host or the blob hash, and the request is bound to the + /// tenant resolved from the request headers. A bare read is rejected before any + /// storage lookup, so the endpoint does not leak blob existence. + /// + /// CAS bytes are still deduplicated across communities, so the boundary is not + /// the bytes: it is the metadata/descriptor/upload-auth/quota/audit layer plus + /// the per-tenant read binding. B's private upload metadata and errors must not + /// be observable from A even when the underlying blob is shared. + /// + /// Known limitation, deferred: relay membership plus knowledge of a hash is + /// sufficient to read a blob. Read auth binds host and tenant, not the channel + /// ACL of the message the blob was attached to. #[tokio::test] #[ignore] async fn media_metadata_boundary_holds_while_blob_bytes_shared() { pending_lane( "buzz-media", - "shared SHA bytes OK; A cannot read B's upload metadata/quota/audit; errors generic", + "reads require host/hash-scoped Blossom auth and bind to the header tenant; \ + bare reads 401 before storage; shared SHA bytes OK; A cannot read B's upload \ + metadata/quota/audit; errors generic", ); } } diff --git a/crates/buzz-test-client/tests/e2e_media.rs b/crates/buzz-test-client/tests/e2e_media.rs index 14001f641c..690fd9c8a5 100644 --- a/crates/buzz-test-client/tests/e2e_media.rs +++ b/crates/buzz-test-client/tests/e2e_media.rs @@ -48,6 +48,26 @@ fn sign_blossom_auth(keys: &Keys, sha256: &str) -> nostr::Event { .expect("sign blossom auth") } +/// Sign a kind:24242 Blossom *read* auth event for the given sha256. +/// +/// Reads are authenticated unconditionally, so every successful GET/HEAD in this +/// file has to present one of these. The `x` tag is hash-scoped and covers the +/// derived paths too -- the relay matches on the sha256 before the extension, so +/// one token serves `{sha}.jpg` and `{sha}.thumb.jpg` alike. +fn sign_blossom_get_auth(keys: &Keys, sha256: &str) -> nostr::Event { + let now = Timestamp::now().as_secs(); + let exp_str = (now + 300).to_string(); + let tags = vec![ + Tag::parse(["t", "get"]).expect("t tag"), + Tag::parse(["x", sha256]).expect("x tag"), + Tag::parse(["expiration", &exp_str]).expect("expiration tag"), + ]; + EventBuilder::new(Kind::from(24242), "Get test") + .tags(tags) + .sign_with_keys(keys) + .expect("sign blossom get auth") +} + /// Build `Authorization: Nostr ` header value. fn blossom_auth_header(event: &nostr::Event) -> String { format!( @@ -144,10 +164,14 @@ async fn test_upload_and_get() { descriptor["dim"], descriptor["blurhash"] ); + // Reads are authenticated, so mint one hash-scoped token for all three below. + let read_auth = blossom_auth_header(&sign_blossom_get_auth(&keys, &sha256)); + // GET /media/{sha256}.jpg — bytes must match let get_url = format!("{}/media/{sha256}.jpg", relay_http_url()); let get_resp = client .get(&get_url) + .header("Authorization", &read_auth) .send() .await .expect("GET /media/{sha256}.jpg failed"); @@ -162,6 +186,7 @@ async fn test_upload_and_get() { // HEAD /media/{sha256}.jpg — must return 200 with content-type let head_resp = client .head(&get_url) + .header("Authorization", &read_auth) .send() .await .expect("HEAD /media/{sha256}.jpg failed"); @@ -175,6 +200,7 @@ async fn test_upload_and_get() { let thumb_url = format!("{}/media/{sha256}.thumb.jpg", relay_http_url()); let thumb_resp = client .get(&thumb_url) + .header("Authorization", &read_auth) .send() .await .expect("GET thumbnail failed"); @@ -293,19 +319,69 @@ async fn test_upload_hash_mismatch_returns_400() { assert_eq!(resp.status(), 401, "hash mismatch must be 401"); } -/// GET a sha256 that was never uploaded must return 404. +/// GET an authenticated sha256 that was never uploaded must return 404. +/// +/// The token has to be valid for the 404 to be reachable at all: authentication +/// runs before the storage lookup, so a bare request is rejected with 401 and +/// never distinguishes "missing" from "unauthorized" (see +/// `test_unauthenticated_reads_are_rejected`). #[tokio::test] #[ignore] async fn test_get_nonexistent_returns_404() { let client = http_client(); + let keys = Keys::generate(); let missing_sha256 = "0".repeat(64); let url = format!("{}/media/{missing_sha256}.jpg", relay_http_url()); - let resp = client.get(&url).send().await.expect("GET failed"); + let resp = client + .get(&url) + .header( + "Authorization", + blossom_auth_header(&sign_blossom_get_auth(&keys, &missing_sha256)), + ) + .send() + .await + .expect("GET failed"); println!("missing blob → {}", resp.status()); assert_eq!(resp.status(), 404, "missing blob must be 404"); } +/// Bare reads are rejected with 401 before any storage lookup. +/// +/// This is the boundary PR #4610 made unconditional: there is no longer a config +/// flag that lets an unauthenticated GET through, so the acceptance lane has to +/// assert the rejection directly. Uses a never-uploaded hash deliberately -- a 401 +/// here rather than a 404 proves auth runs ahead of the storage lookup and that the +/// endpoint does not leak blob existence to an unauthenticated caller. +#[tokio::test] +#[ignore] +async fn test_unauthenticated_reads_are_rejected() { + let client = http_client(); + let missing_sha256 = "0".repeat(64); + let blob_url = format!("{}/media/{missing_sha256}.jpg", relay_http_url()); + let thumb_url = format!("{}/media/{missing_sha256}.thumb.jpg", relay_http_url()); + + let get_resp = client.get(&blob_url).send().await.expect("bare GET failed"); + println!("bare GET → {}", get_resp.status()); + assert_eq!(get_resp.status(), 401, "bare GET must be 401"); + + let head_resp = client + .head(&blob_url) + .send() + .await + .expect("bare HEAD failed"); + println!("bare HEAD → {}", head_resp.status()); + assert_eq!(head_resp.status(), 401, "bare HEAD must be 401"); + + let thumb_resp = client + .get(&thumb_url) + .send() + .await + .expect("bare thumbnail GET failed"); + println!("bare thumbnail GET → {}", thumb_resp.status()); + assert_eq!(thumb_resp.status(), 401, "bare thumbnail GET must be 401"); +} + /// Upload a real image from the filesystem (set TEST_IMAGE_PATH env var). /// Verifies the full round-trip: upload → BlobDescriptor → GET bytes match. #[tokio::test] @@ -363,7 +439,15 @@ async fn test_upload_real_image() { // GET bytes back and verify let get_url = descriptor["url"].as_str().unwrap(); - let get_resp = client.get(get_url).send().await.expect("GET failed"); + let get_resp = client + .get(get_url) + .header( + "Authorization", + blossom_auth_header(&sign_blossom_get_auth(&keys, &sha256)), + ) + .send() + .await + .expect("GET failed"); assert_eq!(get_resp.status(), 200); let returned = get_resp.bytes().await.unwrap(); assert_eq!( diff --git a/crates/buzz-test-client/tests/e2e_media_extended.rs b/crates/buzz-test-client/tests/e2e_media_extended.rs index 955bd9d6c4..8a9283c040 100644 --- a/crates/buzz-test-client/tests/e2e_media_extended.rs +++ b/crates/buzz-test-client/tests/e2e_media_extended.rs @@ -39,6 +39,21 @@ fn sign_blossom_auth(keys: &Keys, sha256: &str) -> nostr::Event { .unwrap() } +/// Sign a kind:24242 Blossom *read* auth event. Reads are authenticated +/// unconditionally, so round-trip GETs must present one of these. +fn sign_blossom_get_auth(keys: &Keys, sha256: &str) -> nostr::Event { + let now = Timestamp::now().as_secs(); + let tags = vec![ + Tag::parse(["t", "get"]).unwrap(), + Tag::parse(["x", sha256]).unwrap(), + Tag::parse(["expiration", &(now + 300).to_string()]).unwrap(), + ]; + EventBuilder::new(Kind::from(24242), "Get test") + .tags(tags) + .sign_with_keys(keys) + .unwrap() +} + fn blossom_auth_header(event: &nostr::Event) -> String { format!( "Nostr {}", @@ -98,15 +113,15 @@ fn tiny_jpeg() -> Vec { } fn tiny_png() -> Vec { - // Valid 2x2 red PNG generated by ffmpeg + // Valid 2x2 red PNG generated by ffmpeg, with ffmpeg's pHYs chunk stripped: + // `validate_png_metadata_free` rejects pHYs as an identity channel, so the + // original fixture uploaded as 422 MetadataForbidden. IHDR/IDAT/IEND only. vec![ 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0x02, 0x08, 0x02, 0x00, 0x00, 0x00, 0xfd, - 0xd4, 0x9a, 0x73, 0x00, 0x00, 0x00, 0x09, 0x70, 0x48, 0x59, 0x73, 0x00, 0x00, 0x00, 0x01, - 0x00, 0x00, 0x00, 0x01, 0x00, 0x4f, 0x25, 0xc4, 0xd6, 0x00, 0x00, 0x00, 0x10, 0x49, 0x44, - 0x41, 0x54, 0x78, 0x9c, 0x63, 0xfc, 0xc3, 0x00, 0x02, 0x2c, 0x60, 0x92, 0x01, 0x00, 0x0d, - 0x04, 0x01, 0x02, 0xbf, 0x50, 0x15, 0xb3, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, - 0xae, 0x42, 0x60, 0x82, + 0xd4, 0x9a, 0x73, 0x00, 0x00, 0x00, 0x10, 0x49, 0x44, 0x41, 0x54, 0x78, 0x9c, 0x63, 0xfc, + 0xc3, 0x00, 0x02, 0x2c, 0x60, 0x92, 0x01, 0x00, 0x0d, 0x04, 0x01, 0x02, 0xbf, 0x50, 0x15, + 0xb3, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82, ] } @@ -168,9 +183,14 @@ async fn test_upload_png_roundtrip() { assert!(desc["url"].as_str().unwrap().ends_with(".png")); println!("✅ PNG upload: {}", desc["url"]); - // GET back + // GET back — reads are authenticated, so scope a token to the uploaded hash. + let sha256 = desc["sha256"].as_str().expect("descriptor sha256"); let get = client .get(desc["url"].as_str().unwrap()) + .header( + "Authorization", + blossom_auth_header(&sign_blossom_get_auth(&keys, sha256)), + ) .send() .await .unwrap(); @@ -192,8 +212,13 @@ async fn test_upload_gif_roundtrip() { assert!(desc["url"].as_str().unwrap().ends_with(".gif")); println!("✅ GIF upload: {}", desc["url"]); + let sha256 = desc["sha256"].as_str().expect("descriptor sha256"); let get = client .get(desc["url"].as_str().unwrap()) + .header( + "Authorization", + blossom_auth_header(&sign_blossom_get_auth(&keys, sha256)), + ) .send() .await .unwrap(); diff --git a/crates/buzz-test-client/tests/e2e_media_video.rs b/crates/buzz-test-client/tests/e2e_media_video.rs index 64a5878f13..2ec0b1e698 100644 --- a/crates/buzz-test-client/tests/e2e_media_video.rs +++ b/crates/buzz-test-client/tests/e2e_media_video.rs @@ -40,6 +40,23 @@ fn sign_blossom_auth(keys: &Keys, sha256: &str) -> nostr::Event { .expect("sign blossom auth") } +/// Sign a kind:24242 Blossom *read* auth event. Reads are authenticated +/// unconditionally, so blob and range GETs must present one of these -- without it +/// the 206 and 416 range behaviour below would never be reached. +fn sign_blossom_get_auth(keys: &Keys, sha256: &str) -> nostr::Event { + let now = Timestamp::now().as_secs(); + let exp_str = (now + 300).to_string(); + let tags = vec![ + Tag::parse(["t", "get"]).expect("t tag"), + Tag::parse(["x", sha256]).expect("x tag"), + Tag::parse(["expiration", &exp_str]).expect("expiration tag"), + ]; + EventBuilder::new(Kind::from(24242), "Get test") + .tags(tags) + .sign_with_keys(keys) + .expect("sign blossom get auth") +} + fn blossom_auth_header(event: &nostr::Event) -> String { format!( "Nostr {}", @@ -272,7 +289,15 @@ async fn test_video_upload_and_get() { // GET the blob back let get_url = desc["url"].as_str().unwrap(); - let get_resp = client.get(get_url).send().await.expect("GET blob"); + let get_resp = client + .get(get_url) + .header( + "Authorization", + blossom_auth_header(&sign_blossom_get_auth(&keys, &sha256)), + ) + .send() + .await + .expect("GET blob"); assert_eq!(get_resp.status(), StatusCode::OK); let body = get_resp.bytes().await.expect("body bytes"); assert_eq!(body.len(), mp4.len()); @@ -345,6 +370,10 @@ async fn test_video_range_request_206() { // Range request: first 100 bytes let range_resp = client .get(blob_url) + .header( + "Authorization", + blossom_auth_header(&sign_blossom_get_auth(&keys, &sha256)), + ) .header("Range", "bytes=0-99") .send() .await @@ -389,6 +418,10 @@ async fn test_video_range_request_416() { // Request a range beyond the file size let range_resp = client .get(blob_url) + .header( + "Authorization", + blossom_auth_header(&sign_blossom_get_auth(&keys, &sha256)), + ) .header( "Range", format!("bytes={}-{}", mp4.len() + 1000, mp4.len() + 2000), diff --git a/deploy/charts/buzz/templates/NOTES.txt b/deploy/charts/buzz/templates/NOTES.txt index b409f4d942..a0dd96a1a4 100644 --- a/deploy/charts/buzz/templates/NOTES.txt +++ b/deploy/charts/buzz/templates/NOTES.txt @@ -62,11 +62,6 @@ {{- if not .Values.relay.requireRelayMembership }} ⚠ relay.requireRelayMembership=false — relay is OPEN. Anyone can publish. {{- end }} -{{- if not .Values.relay.requireMediaGetAuth }} - ⚠ relay.requireMediaGetAuth=false — media GET/HEAD reads are not auth-gated. - Anyone who learns a media URL/hash can fetch private attachments. Only - use for local development or fully public communities. -{{- end }} {{- if not .Values.migrate.autoMigrate }} ⚠ migrate.autoMigrate=false — relay startup will NOT run sqlx migrations. You must run `buzz-admin migrate` against the database before every diff --git a/deploy/charts/buzz/templates/deployment.yaml b/deploy/charts/buzz/templates/deployment.yaml index 5c876f7d24..0ad41ac461 100644 --- a/deploy/charts/buzz/templates/deployment.yaml +++ b/deploy/charts/buzz/templates/deployment.yaml @@ -131,7 +131,6 @@ spec: - { name: BUZZ_DRAIN_JITTER_MS, value: {{ .Values.relay.drainJitterMs | quote }} } - { name: BUZZ_REQUIRE_AUTH_TOKEN, value: {{ .Values.relay.requireAuthToken | quote }} } - { name: BUZZ_REQUIRE_RELAY_MEMBERSHIP, value: {{ .Values.relay.requireRelayMembership | quote }} } - - { name: BUZZ_REQUIRE_MEDIA_GET_AUTH, value: {{ .Values.relay.requireMediaGetAuth | quote }} } - { name: BUZZ_ALLOW_NIP_OA_AUTH, value: {{ .Values.relay.allowNipOaAuth | quote }} } - { name: BUZZ_PUBKEY_ALLOWLIST, value: {{ .Values.relay.pubkeyAllowlist | quote }} } {{- if .Values.relay.corsOrigins }} diff --git a/deploy/charts/buzz/tests/render_test.yaml b/deploy/charts/buzz/tests/render_test.yaml index cf08210781..196a4a5303 100644 --- a/deploy/charts/buzz/tests/render_test.yaml +++ b/deploy/charts/buzz/tests/render_test.yaml @@ -48,17 +48,6 @@ tests: name: BUZZ_HUDDLE_AUDIO_AVAILABLE value: "true" template: templates/deployment.yaml - # Security default: media GET/HEAD reads must be auth-gated out of the - # box. A private attachment must never be publicly readable by URL/hash - # in an unmodified render. If this assertion fails, someone flipped the - # default — treat that as a security regression, not a config tweak. - - contains: - path: spec.template.spec.containers[0].env - content: - name: BUZZ_REQUIRE_MEDIA_GET_AUTH - value: "true" - template: templates/deployment.yaml - - it: renders virtual-hosted S3 addressing for providers that require it set: relayUrl: wss://buzz.example.com @@ -85,24 +74,6 @@ tests: value: "virtual" template: templates/deployment.yaml - - it: lets an explicit value opt out of media read auth for dev/public deployments - set: - relayUrl: wss://buzz.example.com - ownerPubkey: "0000000000000000000000000000000000000000000000000000000000000000" - externalPostgresql.url: postgres://u:p@h:5432/d - externalRedis.url: redis://h:6379 - s3.endpoint: http://minio:9000 - s3.accessKey: a - s3.secretKey: s - relay.requireMediaGetAuth: false - asserts: - - contains: - path: spec.template.spec.containers[0].env - content: - name: BUZZ_REQUIRE_MEDIA_GET_AUTH - value: "false" - template: templates/deployment.yaml - - it: lets an explicit value disable huddle audio in a single-replica render set: relayUrl: wss://buzz.example.com diff --git a/deploy/charts/buzz/values.schema.json b/deploy/charts/buzz/values.schema.json index e1e362a531..d3670595b5 100644 --- a/deploy/charts/buzz/values.schema.json +++ b/deploy/charts/buzz/values.schema.json @@ -62,7 +62,6 @@ "drainJitterMs": { "type": "integer", "minimum": 0 }, "requireAuthToken": { "type": "boolean" }, "requireRelayMembership": { "type": "boolean" }, - "requireMediaGetAuth": { "type": "boolean" }, "allowNipOaAuth": { "type": "boolean" }, "huddleAudioAvailable": { "type": ["boolean", "null"], diff --git a/deploy/charts/buzz/values.yaml b/deploy/charts/buzz/values.yaml index 42b09f1b3e..8131aef432 100644 --- a/deploy/charts/buzz/values.yaml +++ b/deploy/charts/buzz/values.yaml @@ -117,12 +117,6 @@ relay: drainJitterMs: 0 requireAuthToken: true requireRelayMembership: true - # Authenticated media reads: relay GET/HEAD /media/* requires Blossom - # kind 24242 t=get plus relay membership. Enabled by default so private - # attachments are never publicly readable by URL/hash. Only set false for - # local development or fully public communities — desktop, mobile, and CLI - # clients all attach read auth. - requireMediaGetAuth: true allowNipOaAuth: true pubkeyAllowlist: false corsOrigins: [] diff --git a/desktop/src-tauri/src/commands/media.rs b/desktop/src-tauri/src/commands/media.rs index 86a91a9842..070381f55e 100644 --- a/desktop/src-tauri/src/commands/media.rs +++ b/desktop/src-tauri/src/commands/media.rs @@ -350,11 +350,9 @@ pub(crate) fn sign_blossom_get_auth_header( /// Mint a `t=get` Authorization header value for a relay media fetch, or /// `None` when signing is unavailable (identity in recovery mode). /// -/// Fail-open by design: while the relay's `BUZZ_REQUIRE_MEDIA_GET_AUTH` flag -/// is off, an unauthenticated request still succeeds, so degrading to no -/// header (instead of erroring) keeps media rendering during key recovery. -/// Once the flag is on, these requests will 403 — the correct outcome for an -/// identity that can't prove membership. +/// When signing is unavailable, callers send no header and the relay rejects +/// the read. This keeps recovery mode from accidentally treating a media URL +/// as a bearer capability. /// /// Safety contract: callers must only attach the returned header to URLs /// constructed from (or validated against) the app's own relay base URL — diff --git a/desktop/src-tauri/src/commands/personas/card.rs b/desktop/src-tauri/src/commands/personas/card.rs index 29a5c35e6a..14c7c196b2 100644 --- a/desktop/src-tauri/src/commands/personas/card.rs +++ b/desktop/src-tauri/src/commands/personas/card.rs @@ -668,9 +668,9 @@ pub async fn mint_agent_card( .ok_or_else(|| "Agent avatar data URL could not be decoded.".to_string())?, Some(url) if url.starts_with("http://") || url.starts_with("https://") => { // Relay-hosted avatars (kind:0 pictures under the relay's /media/) - // may require Blossom get-auth (`require_media_get_auth`). Mint the - // header ONLY for same-origin URLs so the token never leaves the - // relay (same contract as `media_download.rs`). + // require Blossom get-auth. Mint the header ONLY for same-origin URLs + // so the token never leaves the relay (same contract as + // `media_download.rs`). let relay_base = crate::relay::relay_api_base_url_with_override(&state); let auth = is_same_origin(url, &relay_base) .then(|| crate::commands::media::mint_media_get_auth(&state, &relay_base)) diff --git a/docs/admin/README.md b/docs/admin/README.md index e51566fb29..f49cbdd71a 100644 --- a/docs/admin/README.md +++ b/docs/admin/README.md @@ -54,9 +54,9 @@ sidecar before accessing the shared content-addressed blob. Unknown feedback, unreferenced hashes, malformed paths, and cross-community substitutions all collapse to `404`. -Only `GET` and `HEAD` are routed. Existing community `/media/*` authorization is -unchanged, including `BUZZ_REQUIRE_MEDIA_GET_AUTH`; the browser receives no -Blossom credential or reusable signed URL. Responses are uncached, `nosniff`, +Only `GET` and `HEAD` are routed. Community `/media/*` reads always require +Blossom authorization and relay membership; the browser receives no reusable +signed URL. Responses are uncached, `nosniff`, governed by a restrictive CSP, streamed from object storage, and non-previewable content retains attachment disposition. Successful reads produce a structured trace containing feedback ID, community ID, and attachment hash, but no feedback diff --git a/docs/multi-tenant-conformance.md b/docs/multi-tenant-conformance.md index 3cd56066eb..d8877b5931 100644 --- a/docs/multi-tenant-conformance.md +++ b/docs/multi-tenant-conformance.md @@ -49,7 +49,7 @@ Conformance obligations: | Workflows, runs, approvals, webhooks, schedules | Workflows are channel-scoped or project/channel-global; triggers fire on matching stored events; schedule/webhook/manual triggers create runs; approval tokens are hashed. | Workflow definition's community from `req.community` at create/update; webhook/schedule/manual routes resolve workflow id inside host-derived community. | Community-global workflow namespace; runs/approvals inherit workflow community. | `workflows`, `workflow_runs`, `workflow_approvals` include `community_id`; workflow id/token hash lookups are scoped; trigger event ids are scoped. | Trigger evaluation only sees events in the same community. Webhook URLs include host-derived community; approval token grants cannot act on another community's same hash/id. | Existing workflow APIs and YAML remain unchanged in default community. | Add tests for identical workflow UUID/approval token hash in different communities and schedule execution isolation. | | Search / FTS | Postgres FTS over the `events.search_tsv` generated `tsvector` column (GIN-indexed); searchable rows expose `id`, `content`, `kind`, `pubkey`, optional `channel_id`, `created_at`, tag terms; channel-less scope is `ChannelScope::ChannelLessOnly`; the relay refetches canonical events from Postgres by hit id. | Search query carries `req.community`; searchable rows carry `community_id`. | Community-global search results; operator-global FTS index infrastructure may be shared. | Every search query filters by `community_id`, BitmapAnd-ed with the GIN `@@` probe; refetch by `(community_id, event_id)`. | Every query carries `community_id` plus channel scope. `ChannelLessOnly` means channel-less within the community, not platform global. | One community produces the same search results as today. | Tests for same event id/content in A and B, deletion in A not deleting B. | | Redis pub/sub, presence, typing, and cache invalidation | Event fan-out uses `buzz:channel:{uuid}`; presence uses `buzz:presence:{pubkey}`; typing uses `buzz:typing:{channel_id}`; cache invalidation uses `buzz:cache-invalidate`. | Pub/sub calls receive `TenantContext` and derive keys from `community_id` plus channel/pubkey. | Pub/sub and presence are community-global; Redis deployment is operator-global shared infrastructure. | Redis keys include community: `buzz:{community}:channel:{uuid}`, `buzz:{community}:presence:{pubkey}`, `buzz:{community}:typing:{channel_id}`, and community-aware cache invalidation payloads/channels. | Cross-node fan-out must not deliver events to subscriptions in another community. Same pubkey can be online/away differently in two communities. Cache drops only affect same-community membership/visibility caches unless explicitly all-community operator maintenance. | Single-community can preserve existing key names only if deployment is isolated; shared multi-tenant Redis must use the prefixed form. | Add tests for same pubkey presence in two communities and same channel UUID collision in two communities. | -| Media / Blossom / S3 | Authenticated uploads return content-addressed descriptors; public `GET/HEAD /media/{sha256.ext}` serves blobs; upload audit has `channel_id = None`. | Upload request host provides `req.community`; Blossom/NIP-98 auth URL host must agree. | Blob CAS bytes may be operator-global shared storage; metadata, authorization, quotas, audit, and visibility are community-global. | Media metadata/audit rows include `community_id`; if object keys stay SHA-addressed, any per-community policy lives outside the raw blob key. | Upload/read authorization uses community context. Shared hash bytes are allowed only as dedup/storage optimization; metadata/errors must not reveal another community's private upload. | Existing media URLs keep working for default community. | Decide whether unauthenticated blob `GET` remains intentionally public; if not, reads need host-scoped auth/visibility checks. | +| Media / Blossom / S3 | Authenticated uploads return content-addressed descriptors; `GET/HEAD /media/{sha256.ext}` requires a Blossom `t=get` auth event scoped to the serving host or the blob hash and binds the read to the header-resolved tenant, so a bare read is rejected before any storage lookup; upload audit has `channel_id = None`. | Upload and read request host provides `req.community`; Blossom/NIP-98 auth URL host must agree. | Blob CAS bytes may be operator-global shared storage; metadata, authorization, quotas, audit, and visibility are community-global. | Media metadata/audit rows include `community_id`; if object keys stay SHA-addressed, any per-community policy lives outside the raw blob key. | Upload/read authorization uses community context. Shared hash bytes are allowed only as dedup/storage optimization; metadata/errors must not reveal another community's private upload. | Existing media URLs keep working for default community, but clients must now present read auth; there is no config flag that restores unauthenticated reads. | Resolved: blob reads are authenticated and host/tenant-scoped, not public. Remaining gap, deferred: a read is not gated on the channel ACL of the message the blob was attached to, so relay membership plus a known hash is sufficient. | | Git hosting / NIP-34 / object storage | Smart HTTP at `/git/{owner}/{repo}` hydrates from S3 object pointers; NIP-34 repo announcements use `d=repo-id`; pointer key is `repos/{owner}/{repo}/pointer`; git push emits kind:30618. | Git HTTP host gives `req.community`; NIP-98 URL and repo announcement community must agree. | Community-global repo namespace and NIP-34 state; pack/manifests CAS objects may be operator-global if pointers are scoped. | Pointer/name keys include community, e.g. `repos/{community}/{owner}/{repo}/pointer`; NIP-34 replaceable coords include `community_id`; any repo-name registry is `(community_id, owner, repo)` or `(community_id, repo)` per product rule. | Clone/push/read policy resolves repo and branch protections only inside the host community. Git hook policy callback carries community and rejects mismatches. | Existing clone URLs and repo ids work under the default community; object-store migration can move pointers under default prefix without changing git clients. | Add tests for same owner/repo in two communities and push in A not advancing B pointer. | | Mesh, agents, ACP/MCP, and CLI | Agents/CLI connect to a relay URL and use WS/REST; mesh/pairing/presence/status events are regular signed relay events. | The relay URL/host configured in the agent/CLI session selects community. | Agent membership, persona/profile, presence, jobs, memory events, and mesh status are community-global unless a future operator mesh plane is explicitly separate. | Any persisted agent profile/job/mesh status rows/events use `community_id`; Redis/presence/search keys follow the same community scoping. | A portable key may join multiple communities, but memberships, DMs, profiles, jobs, and presence do not bleed across them. | Existing `BUZZ_RELAY_URL` continues to select the one default community. | Add CLI/ACP smoke tests against two hosts using same key with different memberships/profile. | | Audit log and observability | One hash-chain audit log records event/channel/auth/media actions; errors are sanitized before reaching clients. | Every tenant-observable audit entry is labeled with `req.community` or inherited community from the object being acted on. | Community-global audit chains; operator metrics/log aggregation may be platform-global only if tenant labels are bounded and access-controlled. | `audit_log` key/sequence/head includes `community_id`; error/audit projection tables include `community_id`; uniqueness is `(community_id, seq)` and `(community_id, hash)` as appropriate. | Audit reads verify only one community chain. Error strings must not include cross-community IDs, constraint names, or existence facts. | Single-community audit verification still traverses one chain. | Eva owns model edits here; infra lane must ensure media/git/token/search rows emit community-labeled audit entries. | From ad923353a24b784df13a7c88757d6b24ebe36299 Mon Sep 17 00:00:00 2001 From: Tyler <109685178+tlongwell-block@users.noreply.github.com> Date: Thu, 6 Aug 2026 22:26:09 -0400 Subject: [PATCH 11/61] feat(relay): accept kind:30179 private managed-agent events at ingest (#5133) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What Relay-only carve-out of the ingest half of #4999: generic EVENT ingest now accepts kind:30179 (NIP-PMA private managed-agent config). One file, `crates/buzz-relay/src/handlers/ingest.rs`, 16 insertions / 15 deletions; **two semantic lines**, byte-identical to the ingest hunk of #4999 at `6f486e88`: 1. `required_scope_for_kind`: 30179 requires `Scope::UsersWrite` — same arm as its public sibling 30177 and the other owner-authored NIP-AP kinds. 2. `is_global_only_kind`: 30179 is owner-global, keyed `(pubkey, kind, d-tag)`; a stray `h` tag must not channel-scope it. The rest is import reflow plus replacing the guard test with a positive one (`private_managed_agent_kind_is_owner_scoped_global_user_data`: asserts UsersWrite scope, global-only, no h-channel scope). ## Why the guard test can be retired The removed test (`private_managed_agent_kind_remains_rejected_until_atomic_ingest_exists`) pinned a stated precondition: *"must not enter generic EVENT ingest before privacy and aggregate CAS deploy."* Both halves are resolved: - **Privacy** — the author-only read gates for 30179 shipped to main with #4593: `AUTHOR_ONLY_KINDS` membership, `req.rs` pre-filter + result gates, `count.rs`, `event.rs` fanout, and the bridge pre-filter (`bridge.rs:999-1000` returns `restricted: author-only kinds require authors=[self]` / 403). Only the author can read the event back. - **Aggregate CAS** — #4999 settled generation as **advisory**: the `g` tag is shape-validated, never relay-enforced. Last-write-wins per coordinate is the contract of record (see the kind:30179 contract blurb in #4999), so no CAS mechanism is pending on the relay side. ## Why this is inert to existing relays and clients - No production desktop code on main authors kind:30179 — the codec (`private_managed_agent.rs`) has zero non-test callers. This PR accepts a kind nobody can produce yet. - Content is opaque NIP-44 ciphertext to the relay; the relay never decrypts it. - Reads remain author-only via the already-shipped gates above. - Storage is the standard parameterized-replaceable path already exercised by kinds 30175–30178. No schema, config, or migration changes. ## Testing - Full `buzz-relay` package suite at this commit: 859 passed, 1 failed — `api::mesh_demo::tests::demo_join_forwarded_arm_round_trips_echo` (504 vs 200), which **reproduces identically on clean main `769ac70b`** with this change stashed; pre-existing/environmental, not introduced here. - New positive ingest test passes. - Pre-push hooks green (branch-skew, rust-tests, desktop-tauri-checks). ## Relationship to #4999 #4999 (relay-primary agent config, desktop half) stays DO-NOT-MERGE pending live relay receipts + real CI; once this lands and deploys, its live test simplifies to plain `desktop-standalone` against the real relay, and #4999 rebases to drop its now-duplicate ingest hunk (identical bytes → trivial rebase). Originating thread: buzz://message?channel=06f13ed3-0557-4ac2-922c-1545dd00bf97&id=2a43b3b4933a2ea78b77088619251c061355f9b7b6dc29ea0d702193f2344149 ## Brownfield FTS note (review findings, operator-ruled non-blocking for this PR) Max and Sami independently identified that the FTS privacy skip-set is regime-dependent: migration 0008 installs the positive allowlist (`kind IN (0, 9, 40002, 45001, 45003)`) **only on an empty events table**; an already-populated database keeps the 0001/0005 negative skip-list (wrapped by 0014 to add 30350), which omits 30179 — so on such an installation this PR admits 30179 rows whose NIP-44 ciphertext gets indexed by `to_tsvector`. Sami measured both regimes against real Postgres (brownfield: 30179 INDEXED; fresh: NULL) and demonstrated the existing drift test only exercises the fresh regime. `schema/schema.sql:222`'s canonical literal is also the negative list and omits 30179. Migration dates put any relay deployed with data before 0008 landed (2026-07-13) in the brownfield class. **Scope of exposure (Sami's trace):** not a content leak — `event_visible_to_reader` / `is_author_only_event` gates hold on both search surfaces (`req.rs:725`, `bridge.rs:1770`), so foreign readers receive nothing. Lost is the storage-level NULL-tsv backstop plus FTS page budget burned on post-filtered hits. **Operator ruling (Tyler, events `1472e5b6`, `cbd368ed`):** ship this PR without an exclusion migration. Safety argument that makes this sound rather than merely accepted: main has **zero non-test 30179 writers** until #4999's desktop half deploys — no 30179 rows can exist, so nothing can be indexed in any regime while this PR is the only half live. **Additional review characterizations (Sami, non-blocking, on the record):** - *Behavioral delta enumerated:* routing triple (`required_scope_for_kind` / `is_global_only_kind` / `requires_h_channel_scope`) compared for all 65,536 kinds at base `769ac70b` vs head `77eeba6e` — exactly one row differs (30179). No other kind or client changes behavior. - *"SQL visibility before LIMIT" (NIP-PMA step 2):* no `AUTHOR_ONLY_KINDS` pushdown clause exists in `buzz-db` (only `SHARED_GATED_KINDS` has one). Author-only kinds are protected by the pre-filter (`author_only_filters_authorized`) plus post-filter omission; mixed-kind filters can burn candidate-page budget on discarded rows. Pre-existing and identical for 30300/30350 — not introduced here; noted so the NIP's step-2 checkbox is not read as fully ticked. - *Envelope validation gap:* 30179 is the only parameterized-replaceable kind at ingest with no per-kind envelope validator (codec grammar checks run in the desktop writer, not the relay). Generic limits only (256 KiB, ±15 min, pubkey==identity, d-tag bound). Self-inflicted footgun bounded to the author's own coordinate — candidate companion to the exclusion migration in the #4999 rebase, deliberately not added here. **Bound follow-up (required before/with the #4999 desktop half):** a 0014-shape additive migration (`pg_get_expr` capture + `CASE WHEN kind = 30179 THEN NULL ELSE () END` wrap), add 30179 to the `schema/schema.sql:221` literal, and a brownfield-regime variant of the FTS drift test, per Sami's finding. Deploy-time spot check if ever wanted: `SELECT pg_get_expr(d.adbin, d.adrelid) FROM pg_attrdef d JOIN pg_attribute a ON a.attrelid = d.adrelid AND a.attnum = d.adnum WHERE d.adrelid = 'events'::regclass AND a.attname = 'search_tsv';` Signed-off-by: Tyler Longwell Co-authored-by: Eva <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz> Co-authored-by: Tyler Longwell --- crates/buzz-relay/src/handlers/ingest.rs | 31 ++++++++++++------------ 1 file changed, 16 insertions(+), 15 deletions(-) diff --git a/crates/buzz-relay/src/handlers/ingest.rs b/crates/buzz-relay/src/handlers/ingest.rs index cd9f20b5f4..55a468144c 100644 --- a/crates/buzz-relay/src/handlers/ingest.rs +++ b/crates/buzz-relay/src/handlers/ingest.rs @@ -28,12 +28,13 @@ use buzz_core::kind::{ KIND_NIP29_EDIT_METADATA, KIND_NIP29_JOIN_REQUEST, KIND_NIP29_LEAVE_REQUEST, KIND_NIP29_PUT_USER, KIND_NIP29_REMOVE_USER, KIND_NIP43_LEAVE_REQUEST, KIND_NIP65_RELAY_LIST_METADATA, KIND_PERSONA, KIND_PIN_LIST, KIND_PRESENCE_UPDATE, - KIND_PRODUCT_FEEDBACK, KIND_PROFILE, KIND_PROJECT, KIND_REACTION, KIND_READ_STATE, KIND_REPORT, - KIND_STREAM_MESSAGE, KIND_STREAM_MESSAGE_BOOKMARKED, KIND_STREAM_MESSAGE_DIFF, - KIND_STREAM_MESSAGE_EDIT, KIND_STREAM_MESSAGE_PINNED, KIND_STREAM_MESSAGE_SCHEDULED, - KIND_STREAM_MESSAGE_V2, KIND_STREAM_REMINDER, KIND_TEAM, KIND_TEAM_CATALOG, KIND_TEXT_NOTE, - KIND_USER_STATUS, KIND_WORKFLOW_DEF, KIND_WORKFLOW_TRIGGER, RELAY_ADMIN_ADD_MEMBER, - RELAY_ADMIN_CHANGE_ROLE, RELAY_ADMIN_REMOVE_MEMBER, RELAY_ADMIN_SET_WORKSPACE_PROFILE, + KIND_PRIVATE_MANAGED_AGENT, KIND_PRODUCT_FEEDBACK, KIND_PROFILE, KIND_PROJECT, KIND_REACTION, + KIND_READ_STATE, KIND_REPORT, KIND_STREAM_MESSAGE, KIND_STREAM_MESSAGE_BOOKMARKED, + KIND_STREAM_MESSAGE_DIFF, KIND_STREAM_MESSAGE_EDIT, KIND_STREAM_MESSAGE_PINNED, + KIND_STREAM_MESSAGE_SCHEDULED, KIND_STREAM_MESSAGE_V2, KIND_STREAM_REMINDER, KIND_TEAM, + KIND_TEAM_CATALOG, KIND_TEXT_NOTE, KIND_USER_STATUS, KIND_WORKFLOW_DEF, KIND_WORKFLOW_TRIGGER, + RELAY_ADMIN_ADD_MEMBER, RELAY_ADMIN_CHANGE_ROLE, RELAY_ADMIN_REMOVE_MEMBER, + RELAY_ADMIN_SET_WORKSPACE_PROFILE, }; use buzz_core::tenant::TenantContext; use buzz_core::verification::verify_event; @@ -263,7 +264,7 @@ fn required_scope_for_kind(kind: u32, event: &Event) -> Result Ok(Scope::MessagesWrite), KIND_CONTACT_LIST | KIND_READ_STATE | KIND_USER_STATUS | KIND_AGENT_ENGRAM | KIND_EVENT_REMINDER | KIND_PERSONA | KIND_TEAM | KIND_MANAGED_AGENT - | KIND_TEAM_CATALOG | super::push_lease::KIND_PUSH_LEASE => { + | KIND_PRIVATE_MANAGED_AGENT | KIND_TEAM_CATALOG | super::push_lease::KIND_PUSH_LEASE => { Ok(Scope::UsersWrite) } // NIP-AM: agent turn metrics are agent-authored global events (encrypted to owner). @@ -476,6 +477,7 @@ pub(crate) fn is_global_only_kind(kind: u32) -> bool { // (pubkey, kind, d_tag). A stray `h` tag must not channel-scope them. | KIND_TEAM | KIND_MANAGED_AGENT + | KIND_PRIVATE_MANAGED_AGENT | KIND_TEAM_CATALOG // NIP-34: git events use `a` tags (repo reference), not `h` tags (channel scope). // Parameterized replaceable kinds are keyed by (pubkey, kind, d_tag). @@ -3338,15 +3340,14 @@ mod tests { } #[test] - fn private_managed_agent_kind_remains_rejected_until_atomic_ingest_exists() { - assert!( - required_scope_for_kind( - buzz_core::kind::KIND_PRIVATE_MANAGED_AGENT, - &make_dummy_event(), - ) - .is_err(), - "kind 30179 must not enter generic EVENT ingest before privacy and aggregate CAS deploy" + fn private_managed_agent_kind_is_owner_scoped_global_user_data() { + let event = make_dummy_event(); + assert_eq!( + required_scope_for_kind(KIND_PRIVATE_MANAGED_AGENT, &event), + Ok(Scope::UsersWrite) ); + assert!(is_global_only_kind(KIND_PRIVATE_MANAGED_AGENT)); + assert!(!requires_h_channel_scope(KIND_PRIVATE_MANAGED_AGENT)); } #[test] From f53bbd1152464ecbb1de495e2d1d959e156138f0 Mon Sep 17 00:00:00 2001 From: Tyler <109685178+tlongwell-block@users.noreply.github.com> Date: Thu, 6 Aug 2026 22:27:29 -0400 Subject: [PATCH 12/61] fix(bench): mention the orchestrator by pubkey when posting the task (#5136) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Problem The harness posts each trial's task via `buzz messages send`, relying on `@` name resolution. Task text is untrusted payload: when it contains @-tokens of its own, the CLI's mention resolver tries to resolve them as channel members, fails, and refuses to send — killing the trial with `RuntimeLaunchError` before the agent ever saw the task. Live occurrence: TB 2.1's `large-scale-text-editing` task embeds Vim macros (`:%normal! @a`). In the tb21-solo-1 run the trial died at launch: ``` RuntimeLaunchError: buzz messages send ... exited 1: {"error":"user_error","message":"mention '@a' does not match a current channel member; retry with --mention "} ``` Any TB task whose statement contains @-syntax is silently zeroed this way. ## Fix Pass the orchestrator's pubkey as an explicit `--mention` when posting the task. The CLI demotes unresolved @-tokens in the text to presentation-only when any explicit identity is supplied, so delivery still targets exactly the orchestrator and every @-token in the task statement becomes inert. The harness already holds the orchestrator's `AgentCredential` (it writes that pubkey into the worker roster tables), so no persistence is needed — fresh key per trial, fresh `--mention` per trial. Verified both halves against a live relay: a fenced `@a` without `--mention` still hard-fails (the resolver is not markdown-aware); the same content with an explicit `--mention` sends clean with `mention_pubkeys` containing only the target. ## Testing - `benchmarks/harbor-buzz-orchestra`: full pytest suite — 35 passed (34 baseline + new `test_send_mentions_by_pubkey_so_task_text_stays_inert`), ruff clean. Run against `origin/main` 769ac70b with exactly this patch applied. - `testbed`: full pytest suite — 23 passed, 1 skipped; ruff clean. ## Acceptance A task statement containing arbitrary @-tokens (Vim registers, emails, decorators) launches and delivers to the orchestrator instead of dying in `_send`. Originating Buzz thread: `buzz://message?channel=c3252dd2-0142-4e01-88c7-a2183c3960a5&id=74a65a0990fd2197882b66b5ea2707169d4a3dbd2020d1610c45150fb99f140b` Signed-off-by: Eva <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz> Co-authored-by: Eva <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz> --- .../container_runtime.py | 28 ++++++++++++----- .../tests/test_container_runtime.py | 31 +++++++++++++++++++ 2 files changed, 52 insertions(+), 7 deletions(-) diff --git a/benchmarks/harbor-buzz-orchestra/src/harbor_buzz_orchestra/container_runtime.py b/benchmarks/harbor-buzz-orchestra/src/harbor_buzz_orchestra/container_runtime.py index 149a5295a7..ed883a820a 100644 --- a/benchmarks/harbor-buzz-orchestra/src/harbor_buzz_orchestra/container_runtime.py +++ b/benchmarks/harbor-buzz-orchestra/src/harbor_buzz_orchestra/container_runtime.py @@ -157,9 +157,17 @@ async def run( ) # The task arrives exactly as it would in production Buzz: a # user prompt @mentioning the orchestrator. The harness never - # speaks as any agent. + # speaks as any agent. The orchestrator is mentioned by pubkey, + # not by name resolution: task text is untrusted payload, and any + # @-token inside it (e.g. Vim's `:%normal! @a`) would otherwise + # fail member resolution and kill the trial before the agent + # ever saw the task. An explicit --mention demotes unresolved + # @-tokens in the text to presentation-only. await self._send( - trial.user, trial, f"@{orchestrator.agent_id} {instruction}" + trial.user, + trial, + f"@{orchestrator.agent_id} {instruction}", + mention=orchestrator.nostr_pubkey, ) final_message = await asyncio.wait_for( self._wait_for_done(environment, orchestrator, trial, agents + infra), @@ -519,18 +527,24 @@ async def _verify_m1_output( ) async def _send( - self, credential: AgentCredential, trial: TrialHandle, content: str + self, + credential: AgentCredential, + trial: TrialHandle, + content: str, + *, + mention: str | None = None, ) -> None: - await self._buzz_json( - credential, - trial, + args = [ "messages", "send", "--channel", trial.channel_id, "--content", content, - ) + ] + if mention is not None: + args += ["--mention", mention] + await self._buzz_json(credential, trial, *args) async def _buzz_json( self, credential: AgentCredential, trial: TrialHandle, *args: str diff --git a/benchmarks/harbor-buzz-orchestra/tests/test_container_runtime.py b/benchmarks/harbor-buzz-orchestra/tests/test_container_runtime.py index ebf0eb4b5d..5fc0e63e54 100644 --- a/benchmarks/harbor-buzz-orchestra/tests/test_container_runtime.py +++ b/benchmarks/harbor-buzz-orchestra/tests/test_container_runtime.py @@ -372,6 +372,37 @@ async def test_m1_output_probe_matches_grader_and_is_condition_scoped( assert bool(probed) == (condition == "M1-hello-world") +async def test_send_mentions_by_pubkey_so_task_text_stays_inert( + tmp_path, monkeypatch +): + """Task text is untrusted payload: `:%normal! @a` in a task statement must + not be fed to member-name resolution (it would fail and kill the trial). + An explicit --mention pins delivery to the orchestrator's pubkey.""" + rt = runtime(tmp_path) + orch = credential("orch-1", "orchestrator", "orch-model") + trial = trial_handle((orch,)) + calls = [] + + async def buzz_json(credential, trial, *args): + calls.append(args) + return {} + + monkeypatch.setattr(rt, "_buzz_json", buzz_json) + + await rt._send( + trial.user, + trial, + "@orch-1 run `:%normal! @a` on the file", + mention=orch.nostr_pubkey, + ) + assert calls[-1][-2:] == ("--mention", "pubkey-orch-1") + + # Without an explicit mention the send is unchanged (name resolution). + await rt._send(trial.user, trial, "plain content") + assert "--mention" not in calls[-1] + assert calls[-1][-2:] == ("--content", "plain content") + + async def test_wait_for_done_requires_orchestrator_authorship(tmp_path, monkeypatch): rt = runtime(tmp_path, poll_seconds=0) orch = credential("orch-1", "orchestrator", "orch-model") From ee9690a93c315d7049e9f7a4def05191c398303c Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Fri, 7 Aug 2026 10:14:43 -0400 Subject: [PATCH 13/61] fix(cli): emit structured JSON warning when archive/unarchive owner-auth extraction fails (#4824) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit emit structured JSON diagnostics when NIP-OA owner-auth extraction fails during `buzz agents archive`/`unarchive` ## Problem When owner-auth extraction returned `None`, the CLI silently sent a bare request. The relay replied with `400: missing auth tag` and the caller had no way to know why extraction failed. ## Solution Extract `resolve_auth_from_profile` — a sync function that owns all three warning branches and the success path. `resolve_auth` reduces to: self-check → fetch kind:0 → delegate. - **Four distinct diagnostics**: no kind:0 profile / no tags array / `classify_owner_auth_tag` failure (typed `AuthFailure` enum: `NoAuthTag`, `AmbiguousAuthTag`, `WrongArity`, `NonStringElement`, `InvalidOwnerHex`, `InvalidSigHex`, `OwnerMismatch`) - **JSON format**: each fallback emits exactly one `{"warning":"..."}` line to stderr, matching the CLI's documented structured-stderr contract and the precedent in `channels.rs:597` - **Relay-supplied values** (target pubkey, actual owner pubkey) pass through `serde_json` serialization — no unescaped text - **Admin bare path preserved**: request is always sent after the warning; bare non-self requests are legitimate for relay admins - **Self path unchanged**: silent, no relay query ## Boundary tests Tests call `resolve_auth_from_profile` directly with `&mut Vec`. Each of the three production `writeln!` calls is covered: deleting any one fails at least one test. Success path asserts zero bytes written. ## Changes `crates/buzz-cli/src/commands/agents.rs` only: - `AuthFailure` enum with `message()` formatter - `classify_owner_auth_tag` returning `Result<[String;4], AuthFailure>` - `extract_owner_auth_tag` reduced to `#[cfg(test)]` `.ok()` wrapper - `resolve_auth_from_profile` sync helper (testable without `BuzzClient`) - `resolve_auth` reduced to self-check + fetch + delegate - 9 new boundary tests replacing the prior test-local helper --------- Signed-off-by: Will Pfleger Co-authored-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 --- crates/buzz-cli/src/commands/agents.rs | 469 ++++++++++++++++++++++--- 1 file changed, 413 insertions(+), 56 deletions(-) diff --git a/crates/buzz-cli/src/commands/agents.rs b/crates/buzz-cli/src/commands/agents.rs index 58564a45c2..fcdee15060 100644 --- a/crates/buzz-cli/src/commands/agents.rs +++ b/crates/buzz-cli/src/commands/agents.rs @@ -93,7 +93,8 @@ pub async fn dispatch(command: AgentsCmd, client: &BuzzClient) -> Result<(), Cli } => { validate_hex64(&target_pubkey)?; let signer_hex = client.keys().public_key().to_hex(); - let auth = resolve_auth(client, &target_pubkey, &signer_hex).await?; + let auth = + resolve_auth(client, &target_pubkey, &signer_hex, &mut std::io::stderr()).await?; let builder = build_archive_identity_request( &target_pubkey, &content, @@ -124,7 +125,8 @@ pub async fn dispatch(command: AgentsCmd, client: &BuzzClient) -> Result<(), Cli } => { validate_hex64(&target_pubkey)?; let signer_hex = client.keys().public_key().to_hex(); - let auth = resolve_auth(client, &target_pubkey, &signer_hex).await?; + let auth = + resolve_auth(client, &target_pubkey, &signer_hex, &mut std::io::stderr()).await?; let builder = build_unarchive_identity_request( &target_pubkey, &content, @@ -160,19 +162,175 @@ fn require_owner(client: &BuzzClient) -> Result { PublicKey::parse(&hex).map_err(|e| CliError::Auth(format!("invalid owner attestation: {e}"))) } +/// Typed reason why NIP-OA owner-auth could not be extracted from a kind:0. +/// +/// Produced by [`classify_owner_auth_tag`] and formatted into a JSON warning +/// by [`resolve_auth`]. One variant per distinguishable failure cause so the +/// diagnostic is always accurate and never duplicates validation logic. +#[derive(Debug, PartialEq)] +enum AuthFailure { + /// kind:0 has no `tags` array or the array is empty of `auth`-labelled entries. + NoAuthTag, + /// kind:0 has more than one `auth`-labelled tag; count included. + AmbiguousAuthTag(usize), + /// Sole `auth` tag has wrong element count; actual count included. + WrongArity(usize), + /// Sole `auth` tag contains a non-string element. + NonStringElement, + /// Sole `auth` tag owner field is not a valid 64-hex pubkey; value included. + InvalidOwnerHex(String), + /// Sole `auth` tag sig field is not a valid 128-hex signature. + InvalidSigHex, + /// Tag is structurally valid but names a different owner; actual owner included. + OwnerMismatch(String), +} + +impl AuthFailure { + /// Human-readable description suitable for the `"warning"` JSON field. + fn message(&self) -> String { + match self { + AuthFailure::NoAuthTag => "target kind:0 has no \"auth\" tag".to_owned(), + AuthFailure::AmbiguousAuthTag(n) => format!( + "target kind:0 has {n} \"auth\" tags (expected exactly 1) — ambiguous ownership" + ), + AuthFailure::WrongArity(n) => format!( + "sole \"auth\" tag has {n} element(s) (expected 4: label, owner, conditions, sig)" + ), + AuthFailure::NonStringElement => { + "sole \"auth\" tag contains a non-string element".to_owned() + } + AuthFailure::InvalidOwnerHex(v) => { + format!("sole \"auth\" tag owner field is not a valid 64-hex pubkey: {v}") + } + AuthFailure::InvalidSigHex => { + "sole \"auth\" tag sig field is not a valid 128-hex signature".to_owned() + } + AuthFailure::OwnerMismatch(actual) => { + format!("sole \"auth\" tag names owner {actual} which does not match your key") + } + } + } +} + +/// Single classifier: either extract the auth tag or return the typed reason +/// for failure. [`extract_owner_auth_tag`] is a thin `.ok()` wrapper kept for +/// the existing tests that assert on `Option`. +fn classify_owner_auth_tag( + tags: &[serde_json::Value], + signer_hex: &str, +) -> Result<[String; 4], AuthFailure> { + let auth_tags: Vec<&serde_json::Value> = tags + .iter() + .filter(|tag| { + tag.as_array() + .and_then(|elems| elems.first()) + .and_then(|v| v.as_str()) + == Some("auth") + }) + .collect(); + match auth_tags.len() { + 0 => return Err(AuthFailure::NoAuthTag), + n if n > 1 => return Err(AuthFailure::AmbiguousAuthTag(n)), + _ => {} + } + + // Exactly one auth tag. + let elems = auth_tags[0] + .as_array() + .ok_or(AuthFailure::NonStringElement)?; + if elems.len() != 4 { + return Err(AuthFailure::WrongArity(elems.len())); + } + let label = elems[0].as_str().ok_or(AuthFailure::NonStringElement)?; + let owner = elems[1].as_str().ok_or(AuthFailure::NonStringElement)?; + let conditions = elems[2].as_str().ok_or(AuthFailure::NonStringElement)?; + let sig = elems[3].as_str().ok_or(AuthFailure::NonStringElement)?; + if owner.len() != 64 || !owner.chars().all(|c| c.is_ascii_hexdigit()) { + return Err(AuthFailure::InvalidOwnerHex(owner.to_owned())); + } + if sig.len() != 128 || !sig.chars().all(|c| c.is_ascii_hexdigit()) { + return Err(AuthFailure::InvalidSigHex); + } + if !owner.eq_ignore_ascii_case(signer_hex) { + return Err(AuthFailure::OwnerMismatch(owner.to_owned())); + } + Ok([ + label.to_owned(), + owner.to_owned(), + conditions.to_owned(), + sig.to_owned(), + ]) +} + +/// Pure sync core of auth resolution: given a fetched kind:0 profile (or +/// `None` when no event was found), either return the extracted auth tag or +/// emit one `{"warning":"..."}` JSON line to `warn_sink` and return `None`. +/// +/// Separated from [`resolve_auth`] so unit tests can call this directly with +/// a `Vec` sink and assert on exactly what hits the wire — without needing +/// a live `BuzzClient` or async runtime. +/// +/// Three warning branches, one success path: +/// 1. `profile == None` → no kind:0 found for target. +/// 2. `profile.get("tags")` absent or non-array → no tags array. +/// 3. [`classify_owner_auth_tag`] returns `Err` → typed failure reason. +/// 4. `classify_owner_auth_tag` returns `Ok` → `Some(tag)`, no warning. +fn resolve_auth_from_profile( + profile: Option<&serde_json::Value>, + target_hex: &str, + signer_hex: &str, + warn_sink: &mut dyn std::io::Write, +) -> Option<[String; 4]> { + let event = match profile { + Some(e) => e, + None => { + let msg = format!( + "no kind:0 profile found for target {target_hex}; \ + proceeding without owner attestation — this succeeds only if your key is a relay admin" + ); + let _ = writeln!(warn_sink, "{}", serde_json::json!({"warning": msg})); + return None; + } + }; + let tags = match event.get("tags").and_then(|v| v.as_array()) { + Some(t) => t, + None => { + let msg = format!( + "target {target_hex} kind:0 has no tags array; \ + proceeding without owner attestation — this succeeds only if your key is a relay admin" + ); + let _ = writeln!(warn_sink, "{}", serde_json::json!({"warning": msg})); + return None; + } + }; + match classify_owner_auth_tag(tags, signer_hex) { + Ok(tag) => Some(tag), + Err(failure) => { + let msg = format!( + "{}; proceeding without owner attestation — \ + this succeeds only if your key is a relay admin", + failure.message() + ); + let _ = writeln!(warn_sink, "{}", serde_json::json!({"warning": msg})); + None + } + } +} + /// Resolve the optional NIP-OA `auth` tag for archive/unarchive requests. /// /// Mirrors the desktop's `maybe_owner_auth_tag`: -/// - `target == signer`: self path — no auth needed → `Ok(None)`. -/// - Otherwise: fetch target's kind:0, look for an `auth` tag whose owner -/// (index 1) matches the signer. Return it when present; `Ok(None)` when -/// absent or structurally malformed. Query/network failures surface as -/// `Err` — silent degradation to bare would make the relay reject the -/// request with a misleading error. +/// - `target == signer`: self path — no auth needed → `Ok(None)`, silent. +/// - Otherwise: fetch target's kind:0, delegate to [`resolve_auth_from_profile`] +/// which either returns the extracted tag or emits one `{"warning":"..."}` JSON +/// line to `warn_sink` and returns `None` — the bare request is still sent so +/// relay admins can succeed without owner attestation. Query/network failures +/// surface as `Err`. async fn resolve_auth( client: &BuzzClient, target_hex: &str, signer_hex: &str, + warn_sink: &mut dyn std::io::Write, ) -> Result, CliError> { if target_hex.eq_ignore_ascii_case(signer_hex) { return Ok(None); @@ -184,15 +342,13 @@ async fn resolve_auth( .map_err(|e| CliError::Other(format!("failed to fetch target kind:0: {e}")))?; let events: Vec = serde_json::from_str(&raw) .map_err(|e| CliError::Other(format!("invalid kind:0 query response: {e}")))?; - let event = match events.into_iter().next() { - Some(e) => e, - None => return Ok(None), - }; - let tags = match event.get("tags").and_then(|v| v.as_array()) { - Some(t) => t, - None => return Ok(None), - }; - Ok(extract_owner_auth_tag(tags, signer_hex)) + let profile = events.into_iter().next(); + Ok(resolve_auth_from_profile( + profile.as_ref(), + target_hex, + signer_hex, + warn_sink, + )) } /// Pure extraction helper: require exactly one kind:0 tag whose first @@ -201,46 +357,11 @@ async fn resolve_auth( /// then structurally validate that sole tag as /// `["auth", owner, conditions, sig]` matching `signer_hex`. /// -/// Malformed tags (wrong arity, non-string elements, non-hex fields) are -/// silently skipped — the contract is "bare" (None), not error. +/// Thin wrapper around [`classify_owner_auth_tag`] that collapses the typed +/// failure reason to `None`. Malformed tags → `None`; valid tag → `Some`. +#[cfg(test)] fn extract_owner_auth_tag(tags: &[serde_json::Value], signer_hex: &str) -> Option<[String; 4]> { - let auth_tags: Vec<&serde_json::Value> = tags - .iter() - .filter(|tag| { - tag.as_array() - .and_then(|elems| elems.first()) - .and_then(|v| v.as_str()) - == Some("auth") - }) - .collect(); - if auth_tags.len() != 1 { - return None; - } - - let elems = auth_tags[0].as_array()?; - if elems.len() != 4 { - return None; - } - let label = elems[0].as_str()?; - let owner = elems[1].as_str()?; - if !owner.eq_ignore_ascii_case(signer_hex) { - return None; - } - let conditions = elems[2].as_str()?; - let sig = elems[3].as_str()?; - if owner.len() != 64 - || !owner.chars().all(|c| c.is_ascii_hexdigit()) - || sig.len() != 128 - || !sig.chars().all(|c| c.is_ascii_hexdigit()) - { - return None; - } - Some([ - label.to_owned(), - owner.to_owned(), - conditions.to_owned(), - sig.to_owned(), - ]) + classify_owner_auth_tag(tags, signer_hex).ok() } /// Validate the NIP-11 relay-info `self` field is a 64-hex pubkey and @@ -521,6 +642,242 @@ mod tests { assert!(extract_owner_auth_tag(&tags, &signer).is_none()); } + // --- (c) auth-failure classifier: classify_owner_auth_tag --- + // + // Tests the typed failure taxonomy. Each case asserts the exact + // AuthFailure variant so a wrong classification causes a compile-time or + // assertion failure — not just a message-substring miss. + + #[test] + fn classify_no_auth_tag_returns_no_auth_tag() { + // Case 3 (zero auth tags): tags array has entries but none labelled "auth". + let signer = hex64('a'); + let tags = vec![json!(["p", hex64('b')]), json!(["e", hex64('c')])]; + assert_eq!( + classify_owner_auth_tag(&tags, &signer), + Err(AuthFailure::NoAuthTag) + ); + } + + #[test] + fn classify_empty_tags_returns_no_auth_tag() { + assert_eq!( + classify_owner_auth_tag(&[], &hex64('a')), + Err(AuthFailure::NoAuthTag) + ); + } + + #[test] + fn classify_duplicate_auth_tags_returns_ambiguous() { + let signer = hex64('a'); + let sig = hex128('b'); + let tags = vec![ + json!(["auth", signer, "conditions", sig]), + json!(["auth", signer, "conditions", sig]), + ]; + assert_eq!( + classify_owner_auth_tag(&tags, &signer), + Err(AuthFailure::AmbiguousAuthTag(2)) + ); + } + + #[test] + fn classify_wrong_arity_returns_wrong_arity() { + let signer = hex64('a'); + let tags = vec![json!(["auth", signer, "conditions"])]; + assert_eq!( + classify_owner_auth_tag(&tags, &signer), + Err(AuthFailure::WrongArity(3)) + ); + } + + #[test] + fn classify_non_string_element_returns_non_string() { + let signer = hex64('a'); + let tags = vec![json!(["auth", signer, 42, hex128('b')])]; + assert_eq!( + classify_owner_auth_tag(&tags, &signer), + Err(AuthFailure::NonStringElement) + ); + } + + #[test] + fn classify_invalid_owner_hex_returns_invalid_owner_hex() { + let bad_owner = "z".repeat(64); + let tags = vec![json!(["auth", bad_owner, "", hex128('a')])]; + assert_eq!( + classify_owner_auth_tag(&tags, &bad_owner), + Err(AuthFailure::InvalidOwnerHex(bad_owner)) + ); + } + + #[test] + fn classify_invalid_sig_hex_returns_invalid_sig_hex() { + let signer = hex64('a'); + let bad_sig = "z".repeat(128); + let tags = vec![json!(["auth", signer, "", bad_sig])]; + assert_eq!( + classify_owner_auth_tag(&tags, &signer), + Err(AuthFailure::InvalidSigHex) + ); + } + + #[test] + fn classify_owner_mismatch_returns_owner_mismatch_with_actual_owner() { + // Case 4: structurally valid tag but owner ≠ signer. The failure must + // carry the actual owner so resolve_auth can print it in the warning. + let actual_owner = hex64('a'); + let signer = hex64('b'); + let sig = hex128('c'); + let tags = vec![json!(["auth", actual_owner, "conditions", sig])]; + assert_eq!( + classify_owner_auth_tag(&tags, &signer), + Err(AuthFailure::OwnerMismatch(actual_owner.clone())) + ); + // Message must include the actual owner for actionability. + let msg = AuthFailure::OwnerMismatch(actual_owner.clone()).message(); + assert!( + msg.contains(&actual_owner), + "OwnerMismatch message must include actual owner, got: {msg}" + ); + } + + // --- (c2) resolve_auth_from_profile emission boundary --- + // + // Observable-boundary tests: each test calls the production function + // `resolve_auth_from_profile` directly with a `Vec` sink and asserts + // on exactly what the production code writes. Deleting any `writeln!` + // call in that function makes at least one of these tests fail. + // + // `resolve_auth` is async and requires a live `BuzzClient`; the sync + // decomposition lets us test the warning logic without a relay connection. + + fn assert_one_json_warning(sink: &[u8], expected_fragment: &str) { + let text = std::str::from_utf8(sink).expect("sink is valid UTF-8"); + let lines: Vec<&str> = text.lines().collect(); + assert_eq!( + lines.len(), + 1, + "expected exactly one warning line, got: {text:?}" + ); + let parsed: serde_json::Value = + serde_json::from_str(lines[0]).expect("warning line must be parseable JSON"); + let warning = parsed["warning"] + .as_str() + .expect("warning line must have a string 'warning' field"); + assert!( + warning.contains(expected_fragment), + "warning must contain {expected_fragment:?}, got: {warning}" + ); + } + + fn assert_no_warning(sink: &[u8]) { + let text = std::str::from_utf8(sink).expect("sink is valid UTF-8"); + assert!(text.is_empty(), "expected no warning output, got: {text:?}"); + } + + // Branch 1: profile == None → no kind:0 found. + #[test] + fn resolve_auth_from_profile_no_kind0_emits_json_warning() { + let target = hex64('t'); + let signer = hex64('s'); + let mut sink: Vec = Vec::new(); + let result = resolve_auth_from_profile(None, &target, &signer, &mut sink); + assert!(result.is_none(), "bare path: must return None"); + assert_one_json_warning(&sink, "no kind:0 profile found"); + } + + // Branch 2: profile present but no tags array. + #[test] + fn resolve_auth_from_profile_no_tags_array_emits_json_warning() { + let target = hex64('t'); + let signer = hex64('s'); + let profile = json!({"kind": 0, "content": "{}"}); + let mut sink: Vec = Vec::new(); + let result = resolve_auth_from_profile(Some(&profile), &target, &signer, &mut sink); + assert!(result.is_none(), "bare path: must return None"); + assert_one_json_warning(&sink, "no tags array"); + } + + // Branch 3a: tags present but no auth tag (NoAuthTag). + #[test] + fn resolve_auth_from_profile_no_auth_tag_emits_json_warning() { + let target = hex64('t'); + let signer = hex64('s'); + let profile = json!({"tags": [["p", hex64('b')]]}); + let mut sink: Vec = Vec::new(); + let result = resolve_auth_from_profile(Some(&profile), &target, &signer, &mut sink); + assert!(result.is_none(), "bare path: must return None"); + assert_one_json_warning(&sink, "no \"auth\" tag"); + } + + // Branch 3b: duplicate auth tags (AmbiguousAuthTag). + #[test] + fn resolve_auth_from_profile_ambiguous_auth_tag_emits_json_warning() { + let signer = hex64('s'); + let sig = hex128('b'); + let profile = json!({"tags": [ + ["auth", signer, "conditions", sig], + ["auth", signer, "conditions", sig], + ]}); + let mut sink: Vec = Vec::new(); + let result = resolve_auth_from_profile(Some(&profile), &hex64('t'), &signer, &mut sink); + assert!(result.is_none(), "bare path: must return None"); + assert_one_json_warning(&sink, "ambiguous"); + } + + // Branch 3c: sole auth tag malformed (WrongArity). + #[test] + fn resolve_auth_from_profile_malformed_tag_emits_json_warning() { + let signer = hex64('s'); + // arity 3 — missing sig field + let profile = json!({"tags": [["auth", signer, "conditions"]]}); + let mut sink: Vec = Vec::new(); + let result = resolve_auth_from_profile(Some(&profile), &hex64('t'), &signer, &mut sink); + assert!(result.is_none(), "bare path: must return None"); + assert_one_json_warning(&sink, "element"); + } + + // Branch 3d: owner mismatch — warning must include the actual owner pubkey. + #[test] + fn resolve_auth_from_profile_owner_mismatch_emits_json_warning_with_actual_owner() { + let actual_owner = hex64('a'); + let signer = hex64('b'); + let sig = hex128('c'); + let profile = json!({"tags": [["auth", actual_owner, "conditions", sig]]}); + let mut sink: Vec = Vec::new(); + let result = resolve_auth_from_profile(Some(&profile), &hex64('t'), &signer, &mut sink); + assert!(result.is_none(), "bare path: must return None"); + assert_one_json_warning(&sink, &actual_owner); + } + + // Success path: valid auth tag → Some returned, sink stays empty. + #[test] + fn resolve_auth_from_profile_valid_auth_tag_returns_some_emits_nothing() { + let signer = hex64('a'); + let sig = hex128('b'); + let profile = json!({"tags": [["auth", signer, "conditions", sig]]}); + let mut sink: Vec = Vec::new(); + let result = resolve_auth_from_profile(Some(&profile), &hex64('t'), &signer, &mut sink); + assert!(result.is_some(), "must return the extracted tag"); + assert_no_warning(&sink); + } + + // Warning output must be valid JSON (serde_json serializes safely). + #[test] + fn resolve_auth_from_profile_warning_is_valid_json() { + let actual_owner = hex64('a'); + let signer = hex64('b'); + let sig = hex128('c'); + let profile = json!({"tags": [["auth", actual_owner, "conditions", sig]]}); + let mut sink: Vec = Vec::new(); + let _ = resolve_auth_from_profile(Some(&profile), &hex64('t'), &signer, &mut sink); + let text = std::str::from_utf8(&sink).unwrap(); + let parsed: serde_json::Value = + serde_json::from_str(text.trim()).expect("warning output must be valid JSON"); + assert!(parsed["warning"].is_string()); + } + // --- (d) NIP-11 self normalization: normalize_relay_self_hex --- #[test] From c71f6585391421843b552150cef2d927966ee3ba Mon Sep 17 00:00:00 2001 From: klopez4212 Date: Fri, 7 Aug 2026 15:15:34 +0100 Subject: [PATCH 14/61] Polish advanced agent setup and Welcome composer (#4926) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - move **Run on** into Advanced, directly after **Who can send instructions** - reuse the modal’s shared dropdown styling - give the Welcome guidance and composer matching glass treatment while preserving the corrected exit layering ## Validation - `pnpm -C desktop typecheck` - focused Playwright: Run on configuration (3 passed) - focused Playwright: Welcome onboarding flow (1 passed) - desktop unit suite (4,290 passed) --------- Signed-off-by: kenny lopez Signed-off-by: Kenny Lopez Signed-off-by: Watcher Co-authored-by: Watcher --- desktop/src/features/agents/AGENTS.md | 17 ++-- .../agents/ui/AgentDefinitionDialog.tsx | 10 +-- .../agents/ui/PersonaAdvancedFields.tsx | 5 ++ .../features/agents/ui/WhereToRunSection.tsx | 33 ++++--- .../src/features/channels/ui/ChannelPane.tsx | 18 ++-- .../channels/ui/WelcomeComposerBanner.tsx | 27 ++++++ .../messages/ui/ComposerDockBackdrop.tsx | 27 +++++- desktop/tests/e2e/onboarding.spec.ts | 75 +++++++++++++++- desktop/tests/e2e/where-to-run-config.spec.ts | 90 +++++++++++++++++-- 9 files changed, 259 insertions(+), 43 deletions(-) diff --git a/desktop/src/features/agents/AGENTS.md b/desktop/src/features/agents/AGENTS.md index b929dbb613..b578326eba 100644 --- a/desktop/src/features/agents/AGENTS.md +++ b/desktop/src/features/agents/AGENTS.md @@ -113,13 +113,16 @@ with a TypeScript lookup table or an id comparison in a component. Once the Advanced toggle is visible, its expanded state is exclusively user-controlled: provider, harness, and required-env changes must never open it automatically in defaults, create, or edit flows. In Create mode, - the defaults summary follows preferred-harness changes saved while the - dialog is open, and its configured state includes required credentials as - well as provider/model values. If no available harness can resolve, Create - starts in Customize and lets unavailable catalog entries be selected only - to expose their setup guidance; submission remains blocked. - Advanced-only required credentials mark the collapsed Advanced toggle - without opening it in Global Defaults and Edit, and block incomplete saves. + `Run on` belongs in Advanced directly after **Who can send instructions**; + keep it out of the basic create fields. The defaults summary follows + preferred-harness changes saved while the dialog is open, and its configured + state includes required credentials as well as provider/model values. If no + available harness can resolve, Create starts in Customize and lets unavailable + catalog entries be selected only to expose their setup guidance; submission + remains blocked. + Advanced-only required credentials and incomplete remote **Run on** setup + mark the collapsed Advanced toggle without opening it, and block incomplete + saves. Runtime-file credentials satisfy Global Defaults just as they do Create and Edit. In Edit, selecting Custom command keeps its required command field beside the harness diff --git a/desktop/src/features/agents/ui/AgentDefinitionDialog.tsx b/desktop/src/features/agents/ui/AgentDefinitionDialog.tsx index 12702f45ac..409ae6a821 100644 --- a/desktop/src/features/agents/ui/AgentDefinitionDialog.tsx +++ b/desktop/src/features/agents/ui/AgentDefinitionDialog.tsx @@ -109,7 +109,6 @@ type AgentDefinitionDialogProps = { ) => Promise; /** Publishes saved changes when the edited agent is shared in the catalog. */ publishCatalogUpdatesOnSave?: boolean; - /** Rendered below the form fields in create mode only ("Where to run"). */ createRunSection?: React.ReactNode; /** Extra create-mode submit gate (e.g. incomplete provider config). */ createSubmitBlocked?: boolean; @@ -962,9 +961,6 @@ export function AgentDefinitionDialog({ onSaved={selectSavedHarness} open={isAddHarnessOpen} /> - - {isCreateMode ? createRunSection : null} -
) : null} @@ -1109,33 +1111,22 @@ export function VideoPlayer({ ) : null} - {/* Slide (not fade) the pill out: animating opacity on an ancestor - of a backdrop-filter flattens the glass into a plain fill - mid-transition, which reads as a flicker. The video container's - overflow-hidden clips the slid-out pill. */} - {showControls ? ( + {!hasError ? (
-
- - + +
(null); + const [hasVisibleFrame, setHasVisibleFrame] = React.useState(false); const [videoAreaSize, setVideoAreaSize] = React.useState<{ height: number; width: number; @@ -1364,6 +1356,7 @@ function VideoReviewDialog({ React.useEffect(() => { if (!open) { setIsComposerMounted(false); + setHasVisibleFrame(false); return; } // Two frames: one for the dialog to paint, one for the browser to @@ -1715,7 +1708,7 @@ function VideoReviewDialog({ className="h-full w-full min-h-0 object-contain" playsInline poster={poster} - preload="metadata" + preload="auto" src={src} onClick={togglePlay} onDurationChange={(event) => @@ -1740,6 +1733,7 @@ function VideoReviewDialog({ syncCurrentTime(pendingSeekSeconds); } }} + onLoadedData={() => setHasVisibleFrame(true)} onPause={(event) => { syncCurrentTime(event.currentTarget.currentTime); setIsPlaying(false); @@ -1748,7 +1742,15 @@ function VideoReviewDialog({ syncCurrentTime(event.currentTarget.currentTime); setIsPlaying(true); }} - onSeeked={reviewSeek.handleSeeked} + onSeeked={(event) => { + reviewSeek.handleSeeked(); + if ( + event.currentTarget.readyState >= + HTMLMediaElement.HAVE_CURRENT_DATA + ) { + setHasVisibleFrame(true); + } + }} onTimeUpdate={(event) => { syncCurrentTime(event.currentTarget.currentTime); }} @@ -1757,6 +1759,10 @@ function VideoReviewDialog({ setMuted(event.currentTarget.muted); }} /> +
@@ -1903,12 +1909,12 @@ function VideoReviewDialog({ {showCommentsPanel ? (