From bfc0953158b1745e0964758e1b2678b66b41e48b Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Thu, 16 Jul 2026 14:47:08 +1000 Subject: [PATCH] fix(desktop): remove the dead community API token plumbing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit applyCommunity sent a `token` arg that the Rust apply_workspace command stopped declaring when the relay moved to pure Nostr key auth ("Sprout speaks Nostr. Nothing else.", #475). Tauri silently drops invoke args the command doesn't declare, so the Community.token collected by the add/edit dialogs was persisted to localStorage and never applied anywhere — a secret-shaped field with no consumer. Resolve the arg mismatch by deleting the plumbing rather than re-accepting the token: - Stop sending `token` from applyCommunity and align its invoke args with the Rust signature (relayUrl, nsec, reposDir). - Remove the API Token field from the Add/Edit Community dialogs, and drop token handling from useCommunities (update-result matrix, dup-merge on add, reinit trigger) and the sidebar/switcher prop types. - Retire Community.token to a `@deprecated token?: never` marker and strip the stale secret from persisted entries in loadCommunities (extending the existing nsec-strip migration), so it cannot leak from localStorage into future sessions. - Regression tests: applyCommunity must send exactly the arg keys apply_workspace declares (guards the silent-drop IPC contract), and loadCommunities strips token/nsec and persists the cleaned list. Tested: desktop pnpm test (2941 passed, incl. the 2 new regression tests), tsc --noEmit, biome check. Co-Authored-By: Claude Fable 5 Signed-off-by: Matt Toohey --- desktop/src/app/App.tsx | 3 +- .../communities/communityStorage.test.mjs | 59 +++++++++++++++++++ .../features/communities/communityStorage.ts | 24 +++++--- .../resolveCommunityUpdateResult.test.mjs | 4 +- desktop/src/features/communities/types.ts | 8 ++- .../communities/ui/AddCommunityDialog.tsx | 31 +--------- .../communities/ui/CommunitySwitcher.tsx | 2 +- .../communities/ui/EditCommunityDialog.tsx | 33 +---------- .../features/communities/useCommunities.tsx | 11 ++-- .../features/communities/useCommunityInit.ts | 4 +- .../onboarding/communityOnboarding.tsx | 4 -- .../src/features/sidebar/ui/AppSidebar.tsx | 2 +- .../src/features/sidebar/ui/CommunityRail.tsx | 2 +- .../sidebar/ui/SidebarProfileCard.tsx | 2 +- .../shared/api/tauri.applyCommunity.test.mjs | 48 +++++++++++++++ desktop/src/shared/api/tauri.ts | 4 +- 16 files changed, 148 insertions(+), 93 deletions(-) create mode 100644 desktop/src/shared/api/tauri.applyCommunity.test.mjs diff --git a/desktop/src/app/App.tsx b/desktop/src/app/App.tsx index beb7775e3..3fcfa66b3 100644 --- a/desktop/src/app/App.tsx +++ b/desktop/src/app/App.tsx @@ -269,7 +269,7 @@ function CommunityApp({ sharedIdentity }: { sharedIdentity: boolean }) { useNestNotifications(); // Composite key: changes when community ID changes OR when - // the active community's config is updated (relayUrl/token). + // the active community's config is updated (relayUrl/reposDir). const communityKey = `${activeCommunity?.id ?? "none"}-${reinitKey}`; // Latch once the community key deviates from its cold-boot value: from then @@ -299,7 +299,6 @@ function CommunityApp({ sharedIdentity }: { sharedIdentity: boolean }) { id: crypto.randomUUID(), name: transaction.communityName, relayUrl: transaction.relayUrl, - token: transaction.token, reposDir: transaction.reposDir, addedAt: new Date().toISOString(), }); diff --git a/desktop/src/features/communities/communityStorage.test.mjs b/desktop/src/features/communities/communityStorage.test.mjs index 4187d55d5..c77a6a10b 100644 --- a/desktop/src/features/communities/communityStorage.test.mjs +++ b/desktop/src/features/communities/communityStorage.test.mjs @@ -3,6 +3,7 @@ import test from "node:test"; import { clearCommunityStorage, + loadCommunities, migrateLegacyCommunityStorage, } from "./communityStorage.ts"; @@ -59,3 +60,61 @@ test("clearCommunityStorage removes new and legacy state", () => { assert.equal(storage.length, 0); }); + +test("loadCommunities strips legacy nsec and token secrets and persists the cleaned list", () => { + // `token` was a relay API token sent to an `apply_workspace` arg the Rust + // command no longer declares — Tauri silently dropped it, so the secret sat + // unused in localStorage. `nsec` was superseded by the on-disk identity.key. + const storage = createMemoryStorage({ + "buzz-communities": JSON.stringify([ + { + id: "ws-1", + name: "Community A", + relayUrl: "wss://relay-a.example.com", + token: "buzz_legacy-secret", + addedAt: "2024-01-01", + }, + { + id: "ws-2", + name: "Community B", + relayUrl: "wss://relay-b.example.com", + nsec: "nsec1legacysecret", + addedAt: "2024-01-02", + }, + { + id: "ws-3", + name: "Community C", + relayUrl: "wss://relay-c.example.com", + addedAt: "2024-01-03", + }, + ]), + }); + // loadCommunities reads the real localStorage global (and writes back via + // window.localStorage), so point both at the in-memory store for the test. + globalThis.localStorage = storage; + const hadWindow = "window" in globalThis; + const previousWindow = globalThis.window; + globalThis.window = { localStorage: storage }; + try { + const communities = loadCommunities(); + + assert.equal(communities.length, 3); + for (const community of communities) { + assert.equal("token" in community, false); + assert.equal("nsec" in community, false); + } + // The cleaned list is persisted back so the secrets cannot leak into + // future sessions. + const persisted = storage.getItem("buzz-communities"); + assert.equal(persisted.includes("token"), false); + assert.equal(persisted.includes("nsec"), false); + assert.equal(persisted.includes("relay-b.example.com"), true); + } finally { + delete globalThis.localStorage; + if (hadWindow) { + globalThis.window = previousWindow; + } else { + delete globalThis.window; + } + } +}); diff --git a/desktop/src/features/communities/communityStorage.ts b/desktop/src/features/communities/communityStorage.ts index e406d9087..da5bd276e 100644 --- a/desktop/src/features/communities/communityStorage.ts +++ b/desktop/src/features/communities/communityStorage.ts @@ -57,16 +57,24 @@ export function loadCommunities(): Community[] { if (!Array.isArray(parsed)) { return []; } - // Migration: older builds stored the user's `nsec` in localStorage and - // re-applied it to the backend on every reload, which silently overwrote - // any `import_identity` result with the original generated key. The - // on-disk `identity.key` file is the only source of truth now. Strip - // any lingering `nsec` from existing entries on read and persist the - // cleaned list back so it cannot leak into future sessions. + // Migration: strip secrets older builds persisted but nothing reads. + // - `nsec`: older builds stored the user's nsec in localStorage and + // re-applied it to the backend on every reload, which silently overwrote + // any `import_identity` result with the original generated key. The + // on-disk `identity.key` file is the only source of truth now. + // - `token`: a relay API token from before the relay moved to pure Nostr + // key auth (NIP-42/NIP-98). The backend command that once consumed it + // stopped accepting it, so the secret sat unused in localStorage. + // Strip both from existing entries on read and persist the cleaned list + // back so they cannot leak into future sessions. let didStrip = false; const cleaned = (parsed as Array>).map((entry) => { - if (entry && typeof entry === "object" && "nsec" in entry) { - const { nsec: _nsec, ...rest } = entry; + if ( + entry && + typeof entry === "object" && + ("nsec" in entry || "token" in entry) + ) { + const { nsec: _nsec, token: _token, ...rest } = entry; didStrip = true; return rest; } diff --git a/desktop/src/features/communities/resolveCommunityUpdateResult.test.mjs b/desktop/src/features/communities/resolveCommunityUpdateResult.test.mjs index 0a52ef845..847715fbf 100644 --- a/desktop/src/features/communities/resolveCommunityUpdateResult.test.mjs +++ b/desktop/src/features/communities/resolveCommunityUpdateResult.test.mjs @@ -83,9 +83,9 @@ test("resolveCommunityUpdateResult_relay_edit_on_inactive_community_no_reinit", assert.deepEqual(result, { kind: "updated", requiresReinit: false }); }); -test("resolveCommunityUpdateResult_token_change_on_active_requires_reinit", () => { +test("resolveCommunityUpdateResult_repos_dir_change_on_active_requires_reinit", () => { const result = resolveCommunityUpdateResult(COMMUNITIES, "ws-1", "ws-1", { - token: "new-token", + reposDir: "/Users/dev/repos", }); assert.deepEqual(result, { kind: "updated", requiresReinit: true }); }); diff --git a/desktop/src/features/communities/types.ts b/desktop/src/features/communities/types.ts index 7087ca7ce..73eb642d1 100644 --- a/desktop/src/features/communities/types.ts +++ b/desktop/src/features/communities/types.ts @@ -2,7 +2,6 @@ export type Community = { id: string; name: string; relayUrl: string; - token?: string; /** * The pubkey associated with the active identity at the time the community * was created. Display-only — auth always uses the persisted `identity.key` @@ -24,4 +23,11 @@ export type Community = { * authoritative private key is the on-disk `identity.key` file. */ nsec?: never; + /** + * @deprecated Never read. A relay API token from before the relay moved to + * pure Nostr key auth (NIP-42/NIP-98) — the backend stopped accepting it, + * so the secret sat unused in localStorage. New entries never set this + * field, and `loadCommunities()` strips it on read. + */ + token?: never; }; diff --git a/desktop/src/features/communities/ui/AddCommunityDialog.tsx b/desktop/src/features/communities/ui/AddCommunityDialog.tsx index ff2596a59..6672bc851 100644 --- a/desktop/src/features/communities/ui/AddCommunityDialog.tsx +++ b/desktop/src/features/communities/ui/AddCommunityDialog.tsx @@ -31,7 +31,6 @@ export function AddCommunityDialog({ }: AddCommunityDialogProps) { const [name, setName] = React.useState(""); const [relayUrl, setRelayUrl] = React.useState(""); - const [token, setToken] = React.useState(""); const [inviteCode, setInviteCode] = React.useState(""); const [reposDir, setReposDir] = React.useState(""); const communityOnboarding = useCommunityOnboarding(); @@ -41,7 +40,6 @@ export function AddCommunityDialog({ onOpenChange(false); setName(""); setRelayUrl(""); - setToken(""); setInviteCode(""); setReposDir(""); setReposDirError(null); @@ -72,20 +70,11 @@ export function AddCommunityDialog({ relayUrl: normalizedRelayUrl, inviteCode: inviteCode.trim() || undefined, communityName: name.trim() || deriveCommunityName(normalizedRelayUrl), - token: token.trim() || undefined, reposDir: expandedReposDir, }); handleClose(); }, - [ - name, - relayUrl, - token, - inviteCode, - reposDir, - communityOnboarding, - handleClose, - ], + [name, relayUrl, inviteCode, reposDir, communityOnboarding, handleClose], ); return ( @@ -136,24 +125,6 @@ export function AddCommunityDialog({ value={name} /> -
- - setToken(e.target.value)} - placeholder="buzz_..." - type="password" - value={token} - /> -
-
- - setToken(e.target.value)} - placeholder="buzz_..." - type="password" - value={token} - /> -