diff --git a/desktop/src/features/communities/useCommunityInit.ts b/desktop/src/features/communities/useCommunityInit.ts index eda5fb43a0..06ba8ff4f1 100644 --- a/desktop/src/features/communities/useCommunityInit.ts +++ b/desktop/src/features/communities/useCommunityInit.ts @@ -6,7 +6,10 @@ import { getIdentity } from "@/shared/api/tauriIdentity"; import { getOverrides } from "@/shared/features"; import { resetMediaCaches } from "@/shared/lib/mediaUrl"; import { clearSearchHitEventCache } from "@/app/navigation/searchHitEventCache"; -import { initDraftStore } from "@/features/messages/lib/useDrafts"; +import { + clearAllDrafts, + initDraftStore, +} from "@/features/messages/lib/useDrafts"; import { resetRenderScopedReactionHydration } from "@/features/messages/lib/renderScopedReactions"; import { resetActiveAgentTurnsStore, @@ -31,6 +34,7 @@ import type { Community } from "./types"; */ function resetCommunityState(): void { relayClient.disconnect(); + clearAllDrafts(); resetAgentObserverStore(); resetActiveAgentTurnsStore(); resetAgentWorkingSignal(); @@ -193,10 +197,16 @@ export function useCommunityInit( // and bypass the localhost proxy. resetMediaCaches(); - // Initialise the draft store for this identity so localStorage drafts - // are scoped to the correct pubkey before the app renders. - if (activeCommunity.pubkey) { - initDraftStore(activeCommunity.pubkey); + try { + const identity = await getIdentity(); + if (cancelled) return; + initDraftStore(identity.pubkey, activeCommunity.relayUrl); + } catch (err) { + if (cancelled) return; + console.error( + "[useCommunityInit] getIdentity failed, draft store uninitialized:", + err, + ); } // Restore any turn state saved for this community (a prior A→B round- // trip). This runs after applyCommunity succeeds and before the app diff --git a/desktop/src/features/messages/lib/useDrafts.test.mjs b/desktop/src/features/messages/lib/useDrafts.test.mjs index 49de2c98f4..c66c607b9a 100644 --- a/desktop/src/features/messages/lib/useDrafts.test.mjs +++ b/desktop/src/features/messages/lib/useDrafts.test.mjs @@ -630,7 +630,7 @@ function makeFullImeta(overrides = {}) { }; } -/** Read the raw persisted store blob for the current pubkey. */ +/** Read the raw persisted store blob for the current pubkey (legacy v1 key). */ function readRawStore(pubkey) { const raw = localStorage.getItem(`buzz-drafts.v1:${pubkey}`); return raw ? JSON.parse(raw) : {}; @@ -985,3 +985,409 @@ test("invalid_mention_ref_rejects_corrupt_draft", () => { initDraftStore("corrupt-mention-owner"); assert.equal(loadDraftEntry("chan-corrupt"), undefined); }); + +// ── Relay-scoped draft store (v2 key) ───────────────────────────────────────── + +test("cross_relay_isolation_via_initDraftStore_without_clearAllDrafts", () => { + const storage = installFreshLocalStorage(); + clearAllDrafts(); + const pk = "pubkey-shared"; + + // Setup: write drafts in two relay scopes via direct initDraftStore calls + // with NO intervening clearAllDrafts — this pins the production invariant + // that initDraftStore resets _memCache when relayScope changes. + initDraftStore(pk, "wss://relay-a.example.com"); + saveDraftEntry("chan-1", makeDraft({ content: "Draft on relay A" })); + assert.equal(loadDraftEntry("chan-1")?.content, "Draft on relay A"); + + // Switch to relay B directly — no clearAllDrafts. + initDraftStore(pk, "wss://relay-b.example.com"); + assert.equal( + loadDraftEntry("chan-1"), + undefined, + "relay B must not see relay A drafts", + ); + + saveDraftEntry("chan-1", makeDraft({ content: "Draft on relay B" })); + assert.equal(loadDraftEntry("chan-1")?.content, "Draft on relay B"); + + // Switch back to relay A directly — no clearAllDrafts. + initDraftStore(pk, "wss://relay-a.example.com"); + assert.equal( + loadDraftEntry("chan-1")?.content, + "Draft on relay A", + "relay A draft must survive relay B session", + ); + + // Verify localStorage has two distinct keys. + const v2a = storage.getItem( + "buzz-drafts.v2:wss://relay-a.example.com:pubkey-shared", + ); + const v2b = storage.getItem( + "buzz-drafts.v2:wss://relay-b.example.com:pubkey-shared", + ); + assert.ok(v2a, "v2 key for relay A must exist"); + assert.ok(v2b, "v2 key for relay B must exist"); + assert.notEqual(v2a, v2b, "buckets must differ"); +}); + +test("legacy_migration_v1_entries_readable_after_scoped_init_v1_key_removed", () => { + const storage = installFreshLocalStorage(); + clearAllDrafts(); + const pk = "pubkey-migrating"; + const now = new Date().toISOString(); + + // Seed a v1 bucket (pre-upgrade state). + storage.setItem( + `buzz-drafts.v1:${pk}`, + JSON.stringify({ + "chan-old": { + content: "Legacy draft", + selectionStart: 12, + selectionEnd: 12, + channelId: "chan-old", + createdAt: now, + updatedAt: now, + pendingImeta: [], + spoileredAttachmentUrls: [], + status: "active", + }, + }), + ); + + // Init with a relay scope — should trigger migration. + initDraftStore(pk, "wss://relay.example.com"); + + // Legacy draft must be readable through the scoped store. + const loaded = loadDraftEntry("chan-old"); + assert.ok(loaded, "migrated draft must be readable"); + assert.equal(loaded.content, "Legacy draft"); + + // v1 key must be deleted after migration. + assert.equal( + storage.getItem(`buzz-drafts.v1:${pk}`), + null, + "v1 key must be removed after migration", + ); + + // v2 key must exist. + const v2 = storage.getItem(`buzz-drafts.v2:wss://relay.example.com:${pk}`); + assert.ok(v2, "v2 key must hold the migrated data"); +}); + +test("legacy_migration_second_relay_init_does_not_reimport_deleted_v1", () => { + const storage = installFreshLocalStorage(); + clearAllDrafts(); + const pk = "pubkey-double-migrate"; + const now = new Date().toISOString(); + + // Seed v1. + storage.setItem( + `buzz-drafts.v1:${pk}`, + JSON.stringify({ + "chan-first": { + content: "First workspace draft", + selectionStart: 0, + selectionEnd: 0, + channelId: "chan-first", + createdAt: now, + updatedAt: now, + pendingImeta: [], + spoileredAttachmentUrls: [], + status: "active", + }, + }), + ); + + // First workspace loads — migrates v1 into relay-a's v2 bucket. + initDraftStore(pk, "wss://relay-a.example.com"); + assert.ok(loadDraftEntry("chan-first"), "relay A got the legacy draft"); + + // v1 key is gone. + assert.equal(storage.getItem(`buzz-drafts.v1:${pk}`), null); + + // Second workspace initializes — must NOT see the legacy draft. + clearAllDrafts(); + initDraftStore(pk, "wss://relay-b.example.com"); + assert.equal( + loadDraftEntry("chan-first"), + undefined, + "relay B must not inherit legacy drafts already migrated to relay A", + ); +}); + +test("v2_already_exists_legacy_bucket_ignored", () => { + const storage = installFreshLocalStorage(); + clearAllDrafts(); + const pk = "pubkey-v2-exists"; + const now = new Date().toISOString(); + const relay = "wss://relay.example.com"; + + // Seed both v1 and v2 — simulates a user who already ran post-upgrade + // but still has a leftover v1 key. + storage.setItem( + `buzz-drafts.v1:${pk}`, + JSON.stringify({ + "chan-legacy": { + content: "Should be ignored", + selectionStart: 0, + selectionEnd: 0, + channelId: "chan-legacy", + createdAt: now, + updatedAt: now, + pendingImeta: [], + spoileredAttachmentUrls: [], + status: "active", + }, + }), + ); + storage.setItem( + `buzz-drafts.v2:${relay}:${pk}`, + JSON.stringify({ + "chan-v2": { + content: "V2 draft", + selectionStart: 8, + selectionEnd: 8, + channelId: "chan-v2", + createdAt: now, + updatedAt: now, + pendingImeta: [], + spoileredAttachmentUrls: [], + status: "active", + }, + }), + ); + + initDraftStore(pk, relay); + + // v2 content wins. + assert.equal(loadDraftEntry("chan-v2")?.content, "V2 draft"); + // Legacy content NOT merged. + assert.equal( + loadDraftEntry("chan-legacy"), + undefined, + "v1 entries must not leak when v2 key exists", + ); + // v1 key is NOT deleted (no migration path ran). + assert.ok( + storage.getItem(`buzz-drafts.v1:${pk}`), + "v1 key preserved when v2 already existed (no migration)", + ); +}); + +// ── F1: clearAllDrafts fail-closed teardown ────────────────────────────────── + +test("clearAllDrafts_without_reinit_blocks_reads_and_writes_to_previous_bucket", () => { + const storage = installFreshLocalStorage(); + clearAllDrafts(); + const pk = "pubkey-teardown"; + + // Init relay A, write a draft. + initDraftStore(pk, "wss://relay-a.example.com"); + saveDraftEntry("chan-1", makeDraft({ content: "A's draft" })); + assert.equal(loadDraftEntry("chan-1")?.content, "A's draft"); + + // Snapshot A's bucket before teardown. + const bucketBefore = storage.getItem( + "buzz-drafts.v2:wss://relay-a.example.com:pubkey-teardown", + ); + + // Simulate community switch teardown — clearAllDrafts with NO re-init. + clearAllDrafts(); + + // Reads must return undefined (no active scope). + assert.equal( + loadDraftEntry("chan-1"), + undefined, + "reads must return undefined after clearAllDrafts without re-init", + ); + + // A write attempt must not modify A's stored bucket. + saveDraftEntry("chan-1", makeDraft({ content: "Rogue write" })); + assert.equal( + storage.getItem("buzz-drafts.v2:wss://relay-a.example.com:pubkey-teardown"), + bucketBefore, + "A's stored bucket must be byte-unchanged after unscoped write", + ); + + // Re-init with relay B — must see a clean bucket, not A's drafts. + initDraftStore(pk, "wss://relay-b.example.com"); + assert.equal( + loadDraftEntry("chan-1"), + undefined, + "relay B must not see relay A drafts after teardown + re-init", + ); +}); + +// ── F2: failed v2 flush preserves the only v1 copy ────────────────────────── + +test("failed_v2_flush_during_migration_preserves_v1_key", () => { + const storage = installFreshLocalStorage(); + clearAllDrafts(); + const pk = "pubkey-flush-fail"; + const now = new Date().toISOString(); + + // Seed v1 bucket. + storage.setItem( + `buzz-drafts.v1:${pk}`, + JSON.stringify({ + "chan-legacy": { + content: "Precious legacy", + selectionStart: 0, + selectionEnd: 0, + channelId: "chan-legacy", + createdAt: now, + updatedAt: now, + pendingImeta: [], + spoileredAttachmentUrls: [], + status: "active", + }, + }), + ); + + // Force setItem to throw persistently (quota full, no recovery possible). + const origSetItem = storage.setItem; + storage.setItem = () => { + throw new DOMException("QuotaExceededError"); + }; + + // Init with relay scope — triggers migration attempt. + initDraftStore(pk, "wss://relay.example.com"); + + // Restore setItem so assertions can read storage. + storage.setItem = origSetItem; + + // v1 key must still be present (flush failed, so legacy NOT deleted). + assert.ok( + storage.getItem(`buzz-drafts.v1:${pk}`), + "v1 key must be preserved when v2 flush fails", + ); + + // v2 key must NOT exist (flush failed). + assert.equal( + storage.getItem(`buzz-drafts.v2:wss://relay.example.com:${pk}`), + null, + "v2 key must not exist when flush failed", + ); +}); + +test("successful_v2_flush_during_migration_deletes_v1_key", () => { + const storage = installFreshLocalStorage(); + clearAllDrafts(); + const pk = "pubkey-flush-ok"; + const now = new Date().toISOString(); + + storage.setItem( + `buzz-drafts.v1:${pk}`, + JSON.stringify({ + "chan-old": { + content: "Migrating", + selectionStart: 0, + selectionEnd: 0, + channelId: "chan-old", + createdAt: now, + updatedAt: now, + pendingImeta: [], + spoileredAttachmentUrls: [], + status: "active", + }, + }), + ); + + initDraftStore(pk, "wss://relay.example.com"); + + assert.equal( + storage.getItem(`buzz-drafts.v1:${pk}`), + null, + "v1 key deleted after successful migration", + ); + assert.ok( + storage.getItem(`buzz-drafts.v2:wss://relay.example.com:${pk}`), + "v2 key created after successful migration", + ); + assert.equal(loadDraftEntry("chan-old")?.content, "Migrating"); +}); + +// ── F4: relay URL canonicalization ────────────────────────────────────────── + +test("relay_canonicalization_host_case_and_trailing_slash_coalesce", () => { + installFreshLocalStorage(); + clearAllDrafts(); + const pk = "pubkey-canon"; + + // Write with mixed-case host. + initDraftStore(pk, "WSS://Relay.Example.COM/"); + saveDraftEntry("chan-1", makeDraft({ content: "canonical draft" })); + + // Re-init with lowercase + no trailing slash — must see same draft. + initDraftStore(pk, "wss://relay.example.com"); + assert.equal( + loadDraftEntry("chan-1")?.content, + "canonical draft", + "host-case + trailing-slash variants must coalesce", + ); +}); + +test("relay_canonicalization_path_case_stays_isolated", () => { + installFreshLocalStorage(); + clearAllDrafts(); + const pk = "pubkey-path-case"; + + initDraftStore(pk, "wss://gateway.example.com/Team"); + saveDraftEntry("chan-1", makeDraft({ content: "Team draft" })); + + // Different path case — must NOT see the draft. + initDraftStore(pk, "wss://gateway.example.com/team"); + assert.equal( + loadDraftEntry("chan-1"), + undefined, + "path-case variants must remain isolated", + ); + + // Original path case still intact. + initDraftStore(pk, "wss://gateway.example.com/Team"); + assert.equal( + loadDraftEntry("chan-1")?.content, + "Team draft", + "original path-case draft must survive", + ); +}); + +test("no_relay_legacy_caller_form_still_reads_writes_v1_key", () => { + const storage = installFreshLocalStorage(); + clearAllDrafts(); + const pk = "pubkey-no-relay"; + const now = new Date().toISOString(); + + // Seed a v1 bucket. + storage.setItem( + `buzz-drafts.v1:${pk}`, + JSON.stringify({ + "chan-legacy": { + content: "Legacy no-relay", + selectionStart: 0, + selectionEnd: 0, + channelId: "chan-legacy", + createdAt: now, + updatedAt: now, + pendingImeta: [], + spoileredAttachmentUrls: [], + status: "active", + }, + }), + ); + + // Init with no relay (legacy caller form). + initDraftStore(pk); + + // Must read from v1 key. + assert.equal(loadDraftEntry("chan-legacy")?.content, "Legacy no-relay"); + + // Write a new draft. + saveDraftEntry("chan-new", makeDraft({ content: "New no-relay" })); + + // Verify it lands in the v1 key. + const raw = JSON.parse(storage.getItem(`buzz-drafts.v1:${pk}`)); + assert.ok(raw["chan-new"], "new draft must be in v1 key"); + assert.equal(raw["chan-new"].content, "New no-relay"); +}); diff --git a/desktop/src/features/messages/lib/useDrafts.ts b/desktop/src/features/messages/lib/useDrafts.ts index 5e756f66db..1e9fee6e56 100644 --- a/desktop/src/features/messages/lib/useDrafts.ts +++ b/desktop/src/features/messages/lib/useDrafts.ts @@ -79,28 +79,57 @@ export type DraftState = { /** Serialised shape stored in localStorage (same as DraftState for round-trips). */ type StoredDrafts = Record; -const DRAFT_STORE_KEY_PREFIX = "buzz-drafts.v1"; +const DRAFT_STORE_KEY_PREFIX = "buzz-drafts.v2"; +const LEGACY_DRAFT_STORE_KEY_PREFIX = "buzz-drafts.v1"; const MAX_DRAFTS = 100; -/** Module-level pubkey set by `initDraftStore`. Empty string = no identity. */ +/** + * Canonicalize a relay URL for use as a storage key scope. + * Unlike the shared `normalizeRelayUrl` (which lowercases the entire URL), + * this preserves path/query case so that distinct path-bearing relays + * (e.g. `wss://host/Team` vs `wss://host/team`) produce separate buckets. + */ +function canonicalizeRelayScope(relayUrl: string): string { + const trimmed = relayUrl.trim().replace(/\/+$/, ""); + if (!trimmed) return ""; + try { + const u = new URL(trimmed); + const path = u.pathname.replace(/\/+$/, ""); + return `${u.protocol}//${u.host}${path}${u.search}`; + } catch { + return trimmed.toLowerCase(); + } +} + +/** Module-level workspace identity set by `initDraftStore`. Empty = no workspace. */ let currentPubkey = ""; +let currentRelayScope = ""; function storageKey(): string { - return `${DRAFT_STORE_KEY_PREFIX}:${currentPubkey}`; + // The no-relay form is retained for direct legacy callers/tests. App startup + // always supplies a normalized relay and therefore uses the v2 scoped key. + return currentRelayScope + ? `${DRAFT_STORE_KEY_PREFIX}:${currentRelayScope}:${currentPubkey}` + : legacyStorageKey(); +} + +function legacyStorageKey(): string { + return `${LEGACY_DRAFT_STORE_KEY_PREFIX}:${currentPubkey}`; } /** * Initialise (or re-initialise) the draft store for a given identity. * Called from `useCommunityInit` alongside the other singleton resets. - * Resets the in-memory cache whenever the pubkey changes so a direct - * identity switch (without a prior `clearAllDrafts`) never serves the - * wrong identity's drafts. + * Resets the in-memory cache whenever the pubkey or relay scope changes + * so a workspace switch never serves the wrong workspace's drafts. */ -export function initDraftStore(pubkey: string): void { - if (currentPubkey !== pubkey) { +export function initDraftStore(pubkey: string, relayUrl = ""): void { + const relayScope = canonicalizeRelayScope(relayUrl); + if (currentPubkey !== pubkey || currentRelayScope !== relayScope) { _memCache = null; } currentPubkey = pubkey; + currentRelayScope = relayScope; // Eagerly load to surface corruption errors in console at startup rather // than on first draft interaction. readStore(); @@ -112,6 +141,7 @@ export function initDraftStore(pubkey: string): void { */ export function clearAllDrafts(): void { currentPubkey = ""; + currentRelayScope = ""; _memCache = null; } @@ -131,13 +161,19 @@ function readStore(): Map { } const raw = localStorage.getItem(storageKey()); - if (!raw) { + // One-time forward migration: read legacy v1 entries only when no v2 store + // exists yet AND a relay scope is set. Once migrated, the v1 key is deleted + // so no other workspace can import the same legacy bucket. + const legacyRaw = + raw || !currentRelayScope ? null : localStorage.getItem(legacyStorageKey()); + const source = raw ?? legacyRaw; + if (!source) { _memCache = map; return map; } try { - const parsed: unknown = JSON.parse(raw); + const parsed: unknown = JSON.parse(source); if ( parsed !== null && typeof parsed === "object" && @@ -160,6 +196,11 @@ function readStore(): Map { } _memCache = map; + if (legacyRaw !== null) { + if (flushStore(map)) { + localStorage.removeItem(legacyStorageKey()); + } + } return map; } @@ -210,13 +251,13 @@ function isValidDraftState(v: unknown): v is DraftState { return true; } -function flushStore(map: Map): void { - if (!currentPubkey) return; +function flushStore(map: Map): boolean { + if (!currentPubkey) return false; const obj: StoredDrafts = {}; for (const [k, v] of map) { obj[k] = v; } - setLocalStorageItemWithRecovery(storageKey(), JSON.stringify(obj)); + return setLocalStorageItemWithRecovery(storageKey(), JSON.stringify(obj)); } /**