From ec2d28cd1047ce4108cdebe95a4ba62d753f0fc6 Mon Sep 17 00:00:00 2001 From: Other Brother Darryl Date: Thu, 13 Aug 2026 18:27:03 -0400 Subject: [PATCH] fix(desktop): close section workspace cutover races Signed-off-by: Other Brother Darryl --- .../sidebar/lib/sectionWorkspace.test.mjs | 3 + .../features/sidebar/lib/sectionWorkspace.ts | 127 +++++++--------- .../sidebar/lib/sectionWorkspaceStorage.ts | 65 ++++++++ .../sidebar/lib/useChannelSections.test.mjs | 142 ++++++++++++++++++ .../sidebar/lib/useChannelSections.ts | 42 +++++- 5 files changed, 303 insertions(+), 76 deletions(-) create mode 100644 desktop/src/features/sidebar/lib/sectionWorkspaceStorage.ts diff --git a/desktop/src/features/sidebar/lib/sectionWorkspace.test.mjs b/desktop/src/features/sidebar/lib/sectionWorkspace.test.mjs index 360ab3484..592a67b3a 100644 --- a/desktop/src/features/sidebar/lib/sectionWorkspace.test.mjs +++ b/desktop/src/features/sidebar/lib/sectionWorkspace.test.mjs @@ -24,6 +24,8 @@ const OWNER_BYTES = new Uint8Array(32).fill(1); const OWNER = "1111111111111111111111111111111111111111111111111111111111111111"; const RELAY = "wss://Relay.Example/"; +const RELAY_SELF = + "1b84c5567b126440995d3ed5aaba0565d71e1834604819ff9c17f5e9d5dd078f"; const PROJECTION_EVENT = { id: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", pubkey: "9999999999999999999999999999999999999999999999999999999999999999", @@ -149,6 +151,7 @@ test("projection is decrypted, cached by normalized relay, and rendered during o subscribeLive: async () => async () => {}, }); const restoreWindow = installWindow(storage, async (command, args) => { + if (command === "get_relay_self") return RELAY_SELF; if (command === "nip44_decrypt_from_self") return "workspace-key"; if (command === "decrypt_workspace_metadata") { if (args.envelope === "ciphertext-alpha") return "Alpha"; diff --git a/desktop/src/features/sidebar/lib/sectionWorkspace.ts b/desktop/src/features/sidebar/lib/sectionWorkspace.ts index 52e8f12f7..42c9199a5 100644 --- a/desktop/src/features/sidebar/lib/sectionWorkspace.ts +++ b/desktop/src/features/sidebar/lib/sectionWorkspace.ts @@ -16,7 +16,15 @@ import { import type { RelayEvent } from "@/shared/api/types"; import type { LegacySectionSource } from "./channelSectionsSync"; +import { getRelaySelf } from "@/features/moderation/lib/relaySelf"; import { normalizeRelayUrl } from "@/shared/lib/normalizeRelayUrl"; +import { + hasImportMarker, + readStoredImportAction, + readStoredImportCommand, + clearImportAction, + writeImportState, +} from "./sectionWorkspaceStorage"; import { KIND_SECTION_WORKSPACE_IMPORT, KIND_SECTION_WORKSPACE_PROJECTION, @@ -36,9 +44,6 @@ const MAX_ASSIGNMENTS = 1_000; const MAX_ENCRYPTED_METADATA_BYTES = 65_535; const MAX_KEY_ENVELOPE_BYTES = 4_096; const CACHE_PREFIX = "buzz-section-workspace.v1"; -const IMPORT_ACTION_PREFIX = `${CACHE_PREFIX}:import-action`; -const IMPORT_COMMAND_PREFIX = `${CACHE_PREFIX}:import-command`; - type ProjectionSection = { id: string; rank: number; @@ -471,54 +476,20 @@ export function canonicalJson(input: unknown): string { .map((key) => `${JSON.stringify(key)}:${canonicalJson(input[key])}`) .join(",")}}`; } +export function canonicalRelayAuthority(relayUrl: string): string { + const parsed = new URL(relayUrl.trim()); + if ( + !["ws:", "wss:", "http:", "https:"].includes(parsed.protocol) || + !parsed.hostname + ) { + throw new Error("invalid relay URL"); + } + return parsed.host.toLowerCase(); +} + function cacheKey(pubkey: string, relayUrl: string): string { return `${CACHE_PREFIX}:${pubkey}:${encodeURIComponent(normalizeRelayUrl(relayUrl))}`; } -function importActionKey(pubkey: string, relayUrl: string): string { - return `${IMPORT_ACTION_PREFIX}:${pubkey}:${encodeURIComponent(normalizeRelayUrl(relayUrl))}`; -} -function importCommandKey(pubkey: string, relayUrl: string): string { - return `${IMPORT_COMMAND_PREFIX}:${pubkey}:${encodeURIComponent(normalizeRelayUrl(relayUrl))}`; -} -function readStoredValue(key: string): string | null { - try { - return window.localStorage.getItem(key); - } catch { - return null; - } -} -function readStoredImportCommand( - pubkey: string, - relayUrl: string, -): string | null { - return readStoredValue(importCommandKey(pubkey, relayUrl)); -} -function hasImportMarker(pubkey: string, relayUrl: string): boolean { - return ( - readStoredValue(importActionKey(pubkey, relayUrl)) !== null || - readStoredImportCommand(pubkey, relayUrl) !== null - ); -} -function markImportStarted( - pubkey: string, - relayUrl: string, - actionId: string, -): void { - try { - window.localStorage.setItem(importActionKey(pubkey, relayUrl), actionId); - } catch {} -} -function writeImportState( - pubkey: string, - relayUrl: string, - actionId: string, - command: string, -): void { - try { - window.localStorage.setItem(importCommandKey(pubkey, relayUrl), command); - window.localStorage.setItem(importActionKey(pubkey, relayUrl), actionId); - } catch {} -} function readCache(pubkey: string, relayUrl: string): WorkspaceCache | null { try { const raw = window.localStorage.getItem(cacheKey(pubkey, relayUrl)); @@ -600,7 +571,13 @@ async function encryptMetadata( await encryptWorkspaceMetadata({ keyHex: key, plaintext: value, - aad: aad(community, owner, section.id, 1, purpose), + aad: aad( + canonicalRelayAuthority(community), + owner, + section.id, + 1, + purpose, + ), }), "encrypted_metadata", MAX_ENCRYPTED_METADATA_BYTES, @@ -618,7 +595,13 @@ async function decryptMetadata( return decryptWorkspaceMetadata({ keyHex: key, envelope: value, - aad: aad(community, owner, sectionId, epoch, purpose), + aad: aad( + canonicalRelayAuthority(community), + owner, + sectionId, + epoch, + purpose, + ), }); } async function projectionStore( @@ -683,10 +666,10 @@ function validateEventRouting(event: RelayEvent, owner: string): void { throw new Error("projection routing mismatch"); } -function projectionFromEvent( +async function projectionFromEvent( event: RelayEvent, owner: string, -): SectionWorkspaceProjection { +): Promise { if ( event.id.length !== 64 || !/^[0-9a-f]{64}$/.test(event.id) || @@ -703,6 +686,14 @@ function projectionFromEvent( }) ) throw new Error("invalid projection event signature"); + let relaySelf: string | null; + try { + relaySelf = await getRelaySelf(); + } catch { + throw new Error("relay signer is untrusted"); + } + if (!relaySelf || relaySelf !== event.pubkey) + throw new Error("invalid projection relay signer"); const projection = parseSectionWorkspaceProjection( parseStrictJson(event.content), ); @@ -776,23 +767,14 @@ async function submitImport( sha256(new TextEncoder().encode(canonicalPlaintext)), ); let actionId: string; - let command: string | null; - try { - command = window.localStorage.getItem( - importCommandKey(pubkey, normalizedRelay), - ); - } catch { - command = null; - } + let command: string | null = readStoredImportCommand(pubkey, normalizedRelay); if (command) { const parsed = parseStrictJson(command); const imported = parseWorkspaceImport(parsed); actionId = imported.action_id; } else { actionId = - window.localStorage.getItem(importActionKey(pubkey, normalizedRelay)) ?? - crypto.randomUUID(); - markImportStarted(pubkey, normalizedRelay, actionId); + readStoredImportAction(pubkey, normalizedRelay) ?? crypto.randomUUID(); const key = await generateWorkspaceKey(); const sections: ImportSection[] = []; const sectionIds = new Set(); @@ -850,7 +832,9 @@ async function submitImport( }; command = canonicalJson(imported); parseWorkspaceImport(parseStrictJson(command)); - writeImportState(pubkey, normalizedRelay, actionId, command); + if (!writeImportState(pubkey, normalizedRelay, actionId, command)) { + throw new Error("cannot durably persist workspace import command"); + } } await publishImportCommand(pubkey, actionId, command); } @@ -928,12 +912,17 @@ export class SectionWorkspaceSyncManager { return this.acceptProjection(projection); } if (this.migrationStarted) { - const replayed = await replayStoredImport(this.pubkey, this.relayUrl); - if (replayed) return this.getCachedStore(); - if (readStoredValue(importActionKey(this.pubkey, this.relayUrl))) { + const command = readStoredImportCommand(this.pubkey, this.relayUrl); + if (command) { + const replayed = await replayStoredImport(this.pubkey, this.relayUrl); + if (replayed) return this.getCachedStore(); + } + const actionOnly = readStoredImportAction(this.pubkey, this.relayUrl); + if (actionOnly && clearImportAction(this.pubkey, this.relayUrl)) { + this.migrationStarted = false; + } else { return this.getCachedStore(); } - this.migrationStarted = false; } if (this.pubkey && !this.destroyed) { const legacy = await legacyFetch(); @@ -967,7 +956,7 @@ export class SectionWorkspaceSyncManager { if (this.destroyed) return; void (async () => { try { - let remote = projectionFromEvent(event, this.pubkey); + let remote = await projectionFromEvent(event, this.pubkey); let action = projectionRevisionAction( this.projection?.revision ?? null, remote.revision, diff --git a/desktop/src/features/sidebar/lib/sectionWorkspaceStorage.ts b/desktop/src/features/sidebar/lib/sectionWorkspaceStorage.ts new file mode 100644 index 000000000..5485452c6 --- /dev/null +++ b/desktop/src/features/sidebar/lib/sectionWorkspaceStorage.ts @@ -0,0 +1,65 @@ +import { normalizeRelayUrl } from "@/shared/lib/normalizeRelayUrl"; + +const CACHE_PREFIX = "buzz-section-workspace.v1"; +const IMPORT_ACTION_PREFIX = `${CACHE_PREFIX}:import-action`; +const IMPORT_COMMAND_PREFIX = `${CACHE_PREFIX}:import-command`; + +function importActionKey(pubkey: string, relayUrl: string): string { + return `${IMPORT_ACTION_PREFIX}:${pubkey}:${encodeURIComponent(normalizeRelayUrl(relayUrl))}`; +} +function importCommandKey(pubkey: string, relayUrl: string): string { + return `${IMPORT_COMMAND_PREFIX}:${pubkey}:${encodeURIComponent(normalizeRelayUrl(relayUrl))}`; +} +export function readStoredValue(key: string): string | null { + try { + return window.localStorage.getItem(key); + } catch { + return null; + } +} +export function readStoredImportCommand( + pubkey: string, + relayUrl: string, +): string | null { + return readStoredValue(importCommandKey(pubkey, relayUrl)); +} +export function hasImportMarker(pubkey: string, relayUrl: string): boolean { + return ( + readStoredValue(importActionKey(pubkey, relayUrl)) !== null || + readStoredImportCommand(pubkey, relayUrl) !== null + ); +} +export function readStoredImportAction( + pubkey: string, + relayUrl: string, +): string | null { + return readStoredValue(importActionKey(pubkey, relayUrl)); +} +export function clearImportAction(pubkey: string, relayUrl: string): boolean { + try { + const key = importActionKey(pubkey, relayUrl); + window.localStorage.removeItem(key); + return window.localStorage.getItem(key) === null; + } catch { + return false; + } +} +export function writeImportState( + pubkey: string, + relayUrl: string, + actionId: string, + command: string, +): boolean { + try { + const commandKey = importCommandKey(pubkey, relayUrl); + const actionKey = importActionKey(pubkey, relayUrl); + window.localStorage.setItem(commandKey, command); + window.localStorage.setItem(actionKey, actionId); + return ( + window.localStorage.getItem(commandKey) === command && + window.localStorage.getItem(actionKey) === actionId + ); + } catch { + return false; + } +} diff --git a/desktop/src/features/sidebar/lib/useChannelSections.test.mjs b/desktop/src/features/sidebar/lib/useChannelSections.test.mjs index 401b59d9c..88ddcb7c0 100644 --- a/desktop/src/features/sidebar/lib/useChannelSections.test.mjs +++ b/desktop/src/features/sidebar/lib/useChannelSections.test.mjs @@ -76,3 +76,145 @@ test("assignChannel refreshes an existing assignment before the next eviction", relayClient.subscribeToReconnects = originalSubscribeToReconnects; } }); + +test("legacy subscription resolving after workspace cutover is disposed and cannot update canonical state", async () => { + const { act, cleanup, renderHook } = await import("@testing-library/react"); + const { finalizeEvent } = await import("nostr-tools/pure"); + const { relayClient } = await import("@/shared/api/relayClient"); + const { useChannelSections } = await import("./useChannelSections.ts"); + + const originalFetchEvents = relayClient.fetchEvents; + const originalSubscribeLive = relayClient.subscribeLive; + const originalSubscribeToReconnects = relayClient.subscribeToReconnects; + const owner = + "1111111111111111111111111111111111111111111111111111111111111111"; + const relaySelf = + "1b84c5567b126440995d3ed5aaba0565d71e1834604819ff9c17f5e9d5dd078f"; + const ownerBytes = new Uint8Array(32).fill(1); + const projection = { + version: 1, + owner_pubkey: owner, + revision: 1, + layout_revision: 1, + key_epoch: 1, + migration: { + source_event_id: + "2222222222222222222222222222222222222222222222222222222222222222", + source_hash: + "3333333333333333333333333333333333333333333333333333333333333333", + }, + reader_key_envelope: "workspace-key-envelope", + sections: [ + { + id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + rank: 0, + encrypted_label: "canonical-label", + encrypted_icon: null, + }, + ], + assignments: [], + }; + const projectionTemplate = { + pubkey: owner, + created_at: 1, + kind: 30623, + tags: [ + ["d", owner], + ["p", owner], + ], + content: JSON.stringify(projection), + }; + const projectionEvent = finalizeEvent(projectionTemplate, ownerBytes); + const legacyStore = { + version: 1, + sections: [ + { + id: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", + name: "legacy-name", + order: 0, + }, + ], + assignments: {}, + }; + const legacyEvent = { + id: "4444444444444444444444444444444444444444444444444444444444444444", + pubkey: owner, + created_at: 2, + kind: 30078, + tags: [["d", "channel-sections"]], + content: "legacy-ciphertext", + sig: "legacy-signature", + }; + let resolveLegacySubscription; + let legacyLiveCallback; + let disposerCalls = 0; + relayClient.fetchEvents = async (filter) => + filter.kinds.includes(30623) ? [projectionEvent] : []; + relayClient.subscribeLive = (filter, onEvent) => { + if (filter.kinds.includes(30078)) { + legacyLiveCallback = onEvent; + return new Promise((resolve) => { + resolveLegacySubscription = resolve; + }); + } + return Promise.resolve(async () => {}); + }; + relayClient.subscribeToReconnects = () => () => {}; + const previousInternals = window.__TAURI_INTERNALS__; + window.__TAURI_INTERNALS__ = { + invoke: async (command, args) => { + if (command === "get_relay_self") return relaySelf; + if (command === "nip44_decrypt_from_self") { + return args.ciphertext === "legacy-ciphertext" + ? JSON.stringify(legacyStore) + : "workspace-key"; + } + if (command === "decrypt_workspace_metadata") return "canonical-name"; + throw new Error(`unexpected command ${command}`); + }, + }; + + try { + const { result, unmount } = renderHook(() => + useChannelSections(owner, "wss://relay.example"), + ); + const flush = async () => { + for (let index = 0; index < 5; index += 1) { + await new Promise((resolve) => setImmediate(resolve)); + } + }; + await act(flush); + assert.deepEqual(result.current.sections, [ + { + id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + name: "canonical-name", + order: 0, + }, + ]); + assert.equal(typeof resolveLegacySubscription, "function"); + assert.equal(typeof legacyLiveCallback, "function"); + + resolveLegacySubscription(async () => { + disposerCalls += 1; + }); + await act(flush); + assert.equal(disposerCalls, 1); + + legacyLiveCallback(legacyEvent); + await act(flush); + assert.deepEqual(result.current.sections, [ + { + id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + name: "canonical-name", + order: 0, + }, + ]); + unmount(); + } finally { + cleanup(); + window.__TAURI_INTERNALS__ = previousInternals; + relayClient.fetchEvents = originalFetchEvents; + relayClient.subscribeLive = originalSubscribeLive; + relayClient.subscribeToReconnects = originalSubscribeToReconnects; + } +}); diff --git a/desktop/src/features/sidebar/lib/useChannelSections.ts b/desktop/src/features/sidebar/lib/useChannelSections.ts index 4ffe648a6..f4a270ee7 100644 --- a/desktop/src/features/sidebar/lib/useChannelSections.ts +++ b/desktop/src/features/sidebar/lib/useChannelSections.ts @@ -49,6 +49,9 @@ export function useChannelSections( ); const lastAppliedRemoteTs = React.useRef(0); const lastAppliedEventId = React.useRef(""); + const legacySubscriptionRef = React.useRef<(() => Promise) | null>( + null, + ); React.useEffect(() => { if (!pubkey || !relayUrl) { @@ -68,6 +71,10 @@ export function useChannelSections( return () => { managerRef.current?.destroy(); managerRef.current = null; + if (legacySubscriptionRef.current) { + void legacySubscriptionRef.current(); + legacySubscriptionRef.current = null; + } workspaceManagerRef.current?.destroy(); workspaceManagerRef.current = null; }; @@ -79,7 +86,7 @@ export function useChannelSections( } const key = storageKey(pubkey, relayUrl); const handler = (e: StorageEvent) => { - if (e.key !== key) { + if (e.key !== key || workspaceManagerRef.current?.isCanonical()) { return; } setStore(readChannelSectionsStore(pubkey, relayUrl)); @@ -124,6 +131,11 @@ export function useChannelSections( const legacy = await legacyManager.fetchLegacySource(); return legacy; }); + if (workspaceManager.isCanonical() && legacySubscriptionRef.current) { + void legacySubscriptionRef.current(); + legacySubscriptionRef.current = null; + legacyManager.cancelPendingPublish(); + } if (cancelled) return; if (workspaceStore) setStore(workspaceStore); if (workspaceManager.isCanonical()) { @@ -134,6 +146,7 @@ export function useChannelSections( readChannelSectionsStore(pubkey, relayUrl), ); if (cancelled) return; + if (workspaceManager.isCanonical()) return; if (result.action === "apply-remote") { setStore(applyRemote(result.data)); } @@ -169,23 +182,25 @@ export function useChannelSections( React.useEffect(() => { if (!pubkey) return; - let unsub: (() => Promise) | null = null; let cancelled = false; void managerRef.current ?.subscribeToSections((remote) => { - if (cancelled) return; + if (cancelled || workspaceManagerRef.current?.isCanonical()) return; setStore(applyRemote(remote)); }) .then((dispose) => { - if (cancelled) { + if (cancelled || workspaceManagerRef.current?.isCanonical()) { void dispose(); } else { - unsub = dispose; + legacySubscriptionRef.current = dispose; } }); return () => { cancelled = true; - if (unsub) void unsub(); + if (legacySubscriptionRef.current) { + void legacySubscriptionRef.current(); + legacySubscriptionRef.current = null; + } }; }, [pubkey, applyRemote]); @@ -193,11 +208,16 @@ export function useChannelSections( if (!pubkey) return; let cancelled = false; const unsub = relayClient.subscribeToReconnects(() => { + if (workspaceManagerRef.current?.isCanonical()) { + managerRef.current?.cancelPendingPublish(); + return; + } void managerRef.current?.fetchRemoteSections().then((result) => { - if (cancelled) return; + if (cancelled || workspaceManagerRef.current?.isCanonical()) return; if (result.status === "found") { setStore(applyRemote(result.data)); } + if (workspaceManagerRef.current?.isCanonical()) return; const pending = managerRef.current?.getPendingStore(); if (pending) { managerRef.current?.publishSections(pending); @@ -218,6 +238,7 @@ export function useChannelSections( const createSection = React.useCallback( (name: string, icon?: string): ChannelSection | null => { if (!pubkey) return null; + if (workspaceManagerRef.current?.isCanonical()) return null; const prev = readChannelSectionsStore(pubkey, relayUrl); const maxOrder = prev.sections.length > 0 @@ -252,6 +273,7 @@ export function useChannelSections( if (!pubkey) { return; } + if (workspaceManagerRef.current?.isCanonical()) return; setStore((prev) => { const next: ChannelSectionStore = { ...prev, @@ -285,6 +307,7 @@ export function useChannelSections( if (!pubkey) { return; } + if (workspaceManagerRef.current?.isCanonical()) return; setStore((prev) => { const assignments = { ...prev.assignments }; for (const channelId of Object.keys(assignments)) { @@ -314,6 +337,7 @@ export function useChannelSections( const moveSectionUp = React.useCallback( (sectionId: string) => { if (!pubkey) return; + if (workspaceManagerRef.current?.isCanonical()) return; setStore((prev) => { const next = swapSectionOrder(prev, sectionId, "up"); if (!next || !writeChannelSectionsStore(pubkey, next, relayUrl)) @@ -332,6 +356,7 @@ export function useChannelSections( const moveSectionDown = React.useCallback( (sectionId: string) => { if (!pubkey) return; + if (workspaceManagerRef.current?.isCanonical()) return; setStore((prev) => { const next = swapSectionOrder(prev, sectionId, "down"); if (!next || !writeChannelSectionsStore(pubkey, next, relayUrl)) @@ -350,6 +375,7 @@ export function useChannelSections( const reorderSections = React.useCallback( (orderedIds: string[]) => { if (!pubkey) return; + if (workspaceManagerRef.current?.isCanonical()) return; setStore((prev) => { const sections = prev.sections.map((s) => { const newOrder = orderedIds.indexOf(s.id); @@ -373,6 +399,7 @@ export function useChannelSections( if (!pubkey) { return; } + if (workspaceManagerRef.current?.isCanonical()) return; setStore((prev) => { const assignments = { ...prev.assignments }; delete assignments[channelId]; @@ -400,6 +427,7 @@ export function useChannelSections( if (!pubkey) { return; } + if (workspaceManagerRef.current?.isCanonical()) return; setStore((prev) => { const assignments = { ...prev.assignments }; delete assignments[channelId];