fix(desktop): scope draft store per workspace relay (#2179)

This commit is contained in:
Will Pfleger
2026-07-20 18:20:35 -04:00
committed by GitHub
parent fb8c90cf59
commit 2eff2cb391
3 changed files with 476 additions and 19 deletions

View File

@@ -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

View File

@@ -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");
});

View File

@@ -79,28 +79,57 @@ export type DraftState = {
/** Serialised shape stored in localStorage (same as DraftState for round-trips). */
type StoredDrafts = Record<string, DraftState>;
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<string, DraftState> {
}
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<string, DraftState> {
}
_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<string, DraftState>): void {
if (!currentPubkey) return;
function flushStore(map: Map<string, DraftState>): 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));
}
/**