fix(desktop): remove the dead community API token plumbing

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 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
This commit is contained in:
Matt Toohey
2026-07-16 23:58:10 +10:00
co-authored by Claude Fable 5
parent f05ccd9ad1
commit bfc0953158
16 changed files with 148 additions and 93 deletions
+1 -2
View File
@@ -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(),
});
@@ -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;
}
}
});
@@ -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<Record<string, unknown>>).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;
}
@@ -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 });
});
+7 -1
View File
@@ -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;
};
@@ -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}
/>
</div>
<div className="flex flex-col gap-1.5">
<label
className="text-sm font-medium text-foreground"
htmlFor="ws-token"
>
API Token
<span className="ml-1 text-xs font-normal text-muted-foreground">
(optional)
</span>
</label>
<Input
id="ws-token"
onChange={(e) => setToken(e.target.value)}
placeholder="buzz_..."
type="password"
value={token}
/>
</div>
<div className="flex flex-col gap-1.5">
<label
className="text-sm font-medium text-foreground"
@@ -49,7 +49,7 @@ type CommunitySwitcherProps = {
onAddCommunity: () => void;
onUpdateCommunity: (
id: string,
updates: Partial<Pick<Community, "name" | "relayUrl" | "token">>,
updates: Partial<Pick<Community, "name" | "relayUrl">>,
) => void;
onRemoveCommunity: (id: string) => void;
};
@@ -22,9 +22,7 @@ type EditCommunityDialogProps = {
onOpenChange: (open: boolean) => void;
onSave: (
id: string,
updates: Partial<
Pick<Community, "name" | "relayUrl" | "token" | "reposDir">
>,
updates: Partial<Pick<Community, "name" | "relayUrl" | "reposDir">>,
) => void;
onRemove?: (id: string) => void;
canRemove?: boolean;
@@ -40,7 +38,6 @@ export function EditCommunityDialog({
}: EditCommunityDialogProps) {
const [name, setName] = React.useState("");
const [relayUrl, setRelayUrl] = React.useState("");
const [token, setToken] = React.useState("");
const [reposDir, setReposDir] = React.useState("");
const [reposDirError, setReposDirError] = React.useState<string | null>(null);
@@ -49,7 +46,6 @@ export function EditCommunityDialog({
if (community && open) {
setName(community.name);
setRelayUrl(community.relayUrl);
setToken(community.token ?? "");
setReposDir(community.reposDir ?? "");
setReposDirError(null);
}
@@ -67,7 +63,7 @@ export function EditCommunityDialog({
}
const updates: Partial<
Pick<Community, "name" | "relayUrl" | "token" | "reposDir">
Pick<Community, "name" | "relayUrl" | "reposDir">
> = {};
const trimmedName = name.trim();
@@ -80,11 +76,6 @@ export function EditCommunityDialog({
updates.relayUrl = normalizedUrl;
}
const trimmedToken = token.trim() || undefined;
if (trimmedToken !== community.token) {
updates.token = trimmedToken;
}
// Expand `~` to an absolute path before save — the backend rejects
// tilde paths. An empty field clears the override (REPOS reverts to a
// real dir). Validate the expanded value (the bytes the backend
@@ -108,7 +99,7 @@ export function EditCommunityDialog({
handleClose();
},
[community, name, relayUrl, token, reposDir, onSave, handleClose],
[community, name, relayUrl, reposDir, onSave, handleClose],
);
const handleRemove = React.useCallback(() => {
@@ -166,24 +157,6 @@ export function EditCommunityDialog({
value={relayUrl}
/>
</div>
<div className="flex flex-col gap-1.5">
<label
className="text-sm font-medium text-foreground"
htmlFor="edit-ws-token"
>
API Token
<span className="ml-1 text-xs font-normal text-muted-foreground">
(optional)
</span>
</label>
<Input
id="edit-ws-token"
onChange={(e) => setToken(e.target.value)}
placeholder="buzz_..."
type="password"
value={token}
/>
</div>
<div className="flex flex-col gap-1.5">
<label
className="text-sm font-medium text-foreground"
@@ -38,7 +38,7 @@ export function resolveCommunityUpdateResult(
activeId: string | null,
id: string,
updates: Partial<
Pick<Community, "name" | "relayUrl" | "token" | "pubkey" | "reposDir">
Pick<Community, "name" | "relayUrl" | "pubkey" | "reposDir">
>,
): UpdateCommunityResult {
const current = communities.find((w) => w.id === id);
@@ -55,7 +55,6 @@ export function resolveCommunityUpdateResult(
const hasChange =
(updates.name !== undefined && updates.name !== current.name) ||
(updates.relayUrl !== undefined && updates.relayUrl !== current.relayUrl) ||
(updates.token !== undefined && updates.token !== current.token) ||
(updates.pubkey !== undefined && updates.pubkey !== current.pubkey) ||
(updates.reposDir !== undefined && updates.reposDir !== current.reposDir);
@@ -66,7 +65,6 @@ export function resolveCommunityUpdateResult(
isActive &&
((updates.relayUrl !== undefined &&
updates.relayUrl !== current.relayUrl) ||
(updates.token !== undefined && updates.token !== current.token) ||
(updates.reposDir !== undefined &&
updates.reposDir !== current.reposDir));
@@ -76,7 +74,7 @@ export function resolveCommunityUpdateResult(
export type UseCommunitiesReturn = {
communities: Community[];
activeCommunity: Community | null;
/** Counter bumped when the active community's config changes (relayUrl/token). */
/** Counter bumped when the active community's config changes (relayUrl/reposDir). */
reinitKey: number;
/** Add a community, deduplicating by relayUrl. Returns the final ID in the list. */
addCommunity: (community: Community) => string;
@@ -88,7 +86,7 @@ export type UseCommunitiesReturn = {
updateCommunity: (
id: string,
updates: Partial<
Pick<Community, "name" | "relayUrl" | "token" | "pubkey" | "reposDir">
Pick<Community, "name" | "relayUrl" | "pubkey" | "reposDir">
>,
) => UpdateCommunityResult;
};
@@ -154,7 +152,6 @@ function useCommunitiesInternal(): UseCommunitiesReturn {
? {
...w,
name: community.name || w.name,
token: community.token ?? w.token,
pubkey: community.pubkey ?? w.pubkey,
}
: w,
@@ -227,7 +224,7 @@ function useCommunitiesInternal(): UseCommunitiesReturn {
(
id: string,
updates: Partial<
Pick<Community, "name" | "relayUrl" | "token" | "pubkey" | "reposDir">
Pick<Community, "name" | "relayUrl" | "pubkey" | "reposDir">
>,
): UpdateCommunityResult => {
const result = resolveCommunityUpdateResult(
@@ -78,7 +78,7 @@ export function useCommunityInit(
// before resetting when the user switches to a different community.
const prevCommunityIdRef = useRef<string | null>(null);
// biome-ignore lint/correctness/useExhaustiveDependencies: we intentionally depend on specific properties (id/relayUrl/token/reposDir) — depending on the whole object would trigger resets on name-only changes
// biome-ignore lint/correctness/useExhaustiveDependencies: we intentionally depend on specific properties (id/relayUrl/reposDir) — depending on the whole object would trigger resets on name-only changes
useEffect(() => {
let cancelled = false;
@@ -155,7 +155,6 @@ export function useCommunityInit(
await applyCommunity(
activeCommunity.relayUrl,
undefined,
activeCommunity.token,
activeCommunity.reposDir,
);
} catch (error) {
@@ -211,7 +210,6 @@ export function useCommunityInit(
}, [
activeCommunity?.id,
activeCommunity?.relayUrl,
activeCommunity?.token,
activeCommunity?.reposDir,
isSharedIdentity,
communityKey,
@@ -27,7 +27,6 @@ export type CommunityOnboardingTransaction = {
relayUrl: string;
inviteCode?: string;
communityName: string;
token?: string;
reposDir?: string;
communityId?: string;
createdAt: string;
@@ -40,7 +39,6 @@ export type StartCommunityOnboardingInput = {
relayUrl: string;
inviteCode?: string;
communityName?: string;
token?: string;
reposDir?: string;
};
@@ -115,7 +113,6 @@ export function startCommunityOnboarding(
...existing,
inviteCode: input.inviteCode?.trim() || existing.inviteCode,
communityName: input.communityName?.trim() || existing.communityName,
token: input.token?.trim() || existing.token,
reposDir: input.reposDir ?? existing.reposDir,
updatedAt: now.toISOString(),
error: undefined,
@@ -132,7 +129,6 @@ export function startCommunityOnboarding(
relayUrl,
inviteCode: input.inviteCode?.trim() || undefined,
communityName: input.communityName?.trim() || deriveCommunityName(relayUrl),
token: input.token?.trim() || undefined,
reposDir: input.reposDir,
createdAt: timestamp,
updatedAt: timestamp,
@@ -130,7 +130,7 @@ type AppSidebarProps = {
onOpenDm: (input: { pubkeys: string[] }) => Promise<void>;
onUpdateCommunity: (
id: string,
updates: Partial<Pick<Community, "name" | "relayUrl" | "token">>,
updates: Partial<Pick<Community, "name" | "relayUrl">>,
) => void;
onRemoveCommunity: (id: string) => void;
onCreateAgent: () => void;
@@ -29,7 +29,7 @@ type CommunityRailProps = {
onAddCommunity: () => void;
onUpdateCommunity: (
id: string,
updates: Partial<Pick<Community, "name" | "relayUrl" | "token">>,
updates: Partial<Pick<Community, "name" | "relayUrl">>,
) => void;
onRemoveCommunity: (id: string) => void;
};
@@ -28,7 +28,7 @@ type SidebarProfileCardProps = {
onSwitchCommunity: (id: string) => void;
onUpdateCommunity: (
id: string,
updates: Partial<Pick<Community, "name" | "relayUrl" | "token">>,
updates: Partial<Pick<Community, "name" | "relayUrl">>,
) => void;
profile?: Profile;
resolvedDisplayName: string;
@@ -0,0 +1,48 @@
import assert from "node:assert/strict";
import test from "node:test";
import { applyCommunity } from "./tauri.ts";
/**
* Regression test for the applyCommunity → apply_workspace arg contract.
*
* Tauri silently drops any invoke arg the Rust command does not declare: the
* frontend once sent a `token` arg that `apply_workspace` had stopped
* accepting, so the persisted Community.token was never applied — with no
* error anywhere. Guard the exact key set against the Rust signature
* (`apply_workspace(relay_url, nsec, repos_dir)` in
* desktop/src-tauri/src/commands/workspace.rs) so a drifting arg fails here
* instead of vanishing at the IPC boundary.
*/
test("applyCommunity sends exactly the args apply_workspace declares", async () => {
const calls = [];
const hadWindow = "window" in globalThis;
const previousWindow = globalThis.window;
globalThis.window = {
__TAURI_INTERNALS__: {
invoke: async (cmd, args) => {
calls.push({ cmd, args });
},
},
};
try {
await applyCommunity("wss://relay.example.com", undefined, "/repos");
assert.equal(calls.length, 1);
assert.equal(calls[0].cmd, "apply_workspace");
assert.deepEqual(Object.keys(calls[0].args).sort(), [
"nsec",
"relayUrl",
"reposDir",
]);
assert.equal(calls[0].args.relayUrl, "wss://relay.example.com");
assert.equal(calls[0].args.nsec, null);
assert.equal(calls[0].args.reposDir, "/repos");
} finally {
if (hadWindow) {
globalThis.window = previousWindow;
} else {
delete globalThis.window;
}
}
});
+2 -2
View File
@@ -1273,16 +1273,16 @@ export async function cancelPairing(): Promise<void> {
await invokeTauri("cancel_pairing");
}
// Arg keys must match the Rust `apply_workspace` parameters exactly — Tauri
// silently drops undeclared keys (a legacy `token` arg was lost this way).
export async function applyCommunity(
relayUrl: string,
nsec?: string,
token?: string,
reposDir?: string,
): Promise<void> {
await invokeTauri("apply_workspace", {
relayUrl,
nsec: nsec ?? null,
token: token ?? null,
reposDir: reposDir ?? null,
});
}