diff --git a/desktop/src-tauri/src/local_relay.rs b/desktop/src-tauri/src/local_relay.rs index c7d69e44c..f81ae0bd6 100644 --- a/desktop/src-tauri/src/local_relay.rs +++ b/desktop/src-tauri/src/local_relay.rs @@ -96,13 +96,17 @@ pub(crate) fn start( ) -> Result { let relay_port = requested_port.unwrap_or(free_loopback_port()?); let owner_pubkey = keys.public_key().to_hex(); + let data_dir = local_data_dir(app, &owner_pubkey)?; + let relay_keys = load_or_create_relay_keys(&data_dir)?; let config = LocalRelayConfig { relay_addr: SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), relay_port), health_port: free_loopback_port()?, metrics_port: free_loopback_port()?, - data_dir: local_data_dir(app, &owner_pubkey)?, + data_dir, owner_pubkey, - relay_private_key: keys.secret_key().to_secret_hex(), + // Relay-originated events must have a separate service identity. The + // human key authorizes membership only and is never passed to the child. + relay_private_key: relay_keys.secret_key().to_secret_hex(), }; let url = config.url(); let media_dir = config.data_dir.join("media"); @@ -223,6 +227,26 @@ fn sqlite_url(path: &Path) -> String { format!("sqlite://{}", path.display()) } +/// Load the relay's service identity from its identity-scoped nest, or create +/// it once with the same atomic, owner-only file semantics used for private +/// desktop identities. This key signs relay-originated metadata; it is never +/// the human/owner key supplied by `apply_workspace`. +fn load_or_create_relay_keys(data_dir: &Path) -> Result { + let path = data_dir.join("relay-service.key"); + if path.exists() { + let value = std::fs::read_to_string(&path) + .map_err(|e| format!("read local relay service key: {e}"))?; + return Keys::parse(value.trim()) + .map_err(|e| format!("parse local relay service key: {e}")); + } + + std::fs::create_dir_all(data_dir).map_err(|e| format!("create local relay data dir: {e}"))?; + let keys = Keys::generate(); + crate::app_state::save_key_file(&path, &keys) + .map_err(|e| format!("persist local relay service key: {e}"))?; + Ok(keys) +} + fn free_loopback_port() -> Result { let listener = TcpListener::bind("127.0.0.1:0").map_err(|e| format!("pick local relay port: {e}"))?; @@ -256,7 +280,8 @@ fn relay_binary() -> Result { #[cfg(test)] mod tests { use super::{ - is_local_relay_url, local_relay_url, sqlite_url, LocalRelayConfig, LOCAL_RELAY_SENTINEL, + is_local_relay_url, load_or_create_relay_keys, local_relay_url, sqlite_url, + LocalRelayConfig, LOCAL_RELAY_SENTINEL, }; use std::{ collections::HashMap, @@ -281,6 +306,27 @@ mod tests { assert!(!is_local_relay_url("wss://127.0.0.1:4317")); } + #[test] + fn relay_service_key_is_durable_and_distinct_from_owner_identity() { + let directory = tempfile::tempdir().expect("temp data dir"); + let owner = nostr::Keys::generate(); + let first = load_or_create_relay_keys(directory.path()).expect("create service key"); + let second = load_or_create_relay_keys(directory.path()).expect("reload service key"); + + assert_ne!(first.public_key(), owner.public_key()); + assert_eq!(first.public_key(), second.public_key()); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mode = std::fs::metadata(directory.path().join("relay-service.key")) + .expect("service key metadata") + .permissions() + .mode() + & 0o777; + assert_eq!(mode, 0o600); + } + } + #[test] fn local_relay_environment_uses_only_the_single_node_contract() { let config = LocalRelayConfig { diff --git a/desktop/src/features/communities/communityMarkRead.ts b/desktop/src/features/communities/communityMarkRead.ts index ff5af6541..192730f9c 100644 --- a/desktop/src/features/communities/communityMarkRead.ts +++ b/desktop/src/features/communities/communityMarkRead.ts @@ -1,4 +1,5 @@ import { READ_STATE_MAX_PLAINTEXT_BYTES } from "@/features/channels/readState/readStateFormat"; +import { hasCommunityNetworkEndpoint } from "@/features/communities/communityStorage"; import type { Community } from "@/features/communities/types"; import { fetchObservedChannels } from "@/features/communities/communityUnreadObserver"; import { withReadOnlyRelayClient } from "@/shared/api/readOnlyRelayClient"; @@ -120,6 +121,9 @@ export async function markCommunityRead( community: Community, pubkey: string, ): Promise { + // The sentinel is storage-only. Inactive local communities do not run a + // sidecar, so they cannot receive a background read-state publication. + if (!hasCommunityNetworkEndpoint(community.relayUrl)) return; await withReadOnlyRelayClient(community.relayUrl, (client) => publishCommunityReadState({ client, diff --git a/desktop/src/features/communities/communityStorage.test.mjs b/desktop/src/features/communities/communityStorage.test.mjs index 73d9c01e4..e1781751e 100644 --- a/desktop/src/features/communities/communityStorage.test.mjs +++ b/desktop/src/features/communities/communityStorage.test.mjs @@ -3,12 +3,14 @@ import test from "node:test"; import { clearCommunityStorage, + deriveCommunityName, + hasCommunityNetworkEndpoint, initFirstCommunity, isLocalCommunityRelayUrl, - LOCAL_COMMUNITY_NAME, - LOCAL_COMMUNITY_RELAY_URL, loadCommunities, loadCommunityDiscoveryAfterLeave, + LOCAL_COMMUNITY_NAME, + LOCAL_COMMUNITY_RELAY_URL, markCommunityDiscoveryAfterLeave, migrateLegacyCommunityStorage, saveCommunities, @@ -56,6 +58,18 @@ test("migrateLegacyCommunityStorage does not overwrite new community state", () assert.equal(storage.getItem("buzz-active-community-id"), "new"); }); +test("only the local sentinel identifies the desktop-managed workspace", () => { + assert.equal(isLocalCommunityRelayUrl(LOCAL_COMMUNITY_RELAY_URL), true); + assert.equal( + deriveCommunityName(LOCAL_COMMUNITY_RELAY_URL), + LOCAL_COMMUNITY_NAME, + ); + assert.equal(isLocalCommunityRelayUrl("ws://127.0.0.1:4317"), false); + assert.equal(isLocalCommunityRelayUrl("ws://localhost:4317"), false); + assert.equal(hasCommunityNetworkEndpoint(LOCAL_COMMUNITY_RELAY_URL), false); + assert.equal(hasCommunityNetworkEndpoint("ws://127.0.0.1:4317"), true); +}); + test("signed-build relay defaults auto-connect during first-run onboarding", () => { assert.equal( shouldAutoConnectDefaultRelay("wss://buzz.block.builderlab.xyz"), @@ -135,3 +149,17 @@ test("clearCommunityStorage preserves completed final-leave discovery", () => { assert.equal(storage.length, 1); assert.equal(loadCommunityDiscoveryAfterLeave(storage), true); }); + +test("clearCommunityStorage removes new and legacy state", () => { + const storage = createMemoryStorage({ + "buzz-communities": "new", + "buzz-active-community-id": "new", + "buzz-workspaces": "old", + "buzz-active-workspace-id": "old", + }); + + clearCommunityStorage(storage); + migrateLegacyCommunityStorage(storage); + + assert.equal(storage.length, 0); +}); diff --git a/desktop/src/features/communities/communityStorage.ts b/desktop/src/features/communities/communityStorage.ts index 1cf2033be..deac54f50 100644 --- a/desktop/src/features/communities/communityStorage.ts +++ b/desktop/src/features/communities/communityStorage.ts @@ -17,6 +17,11 @@ export function isLocalCommunityRelayUrl(relayUrl: string): boolean { return relayUrl === LOCAL_COMMUNITY_RELAY_URL; } +/** Stored local sentinel is never a network endpoint. */ +export function hasCommunityNetworkEndpoint(relayUrl: string): boolean { + return !isLocalCommunityRelayUrl(relayUrl); +} + /** * Expand a leading `~` to the user's home directory. The backend rejects * `~`-prefixed paths (`std::fs` does not expand the shell tilde), so the UI diff --git a/desktop/src/features/communities/communityUnreadObserver.ts b/desktop/src/features/communities/communityUnreadObserver.ts index e729831ea..99cf97a3e 100644 --- a/desktop/src/features/communities/communityUnreadObserver.ts +++ b/desktop/src/features/communities/communityUnreadObserver.ts @@ -19,6 +19,7 @@ import { mutedChannelIdsFromStore, parseMutePayload, } from "@/features/sidebar/lib/channelMutesStorage"; +import { hasCommunityNetworkEndpoint } from "@/features/communities/communityStorage"; import type { Community } from "@/features/communities/types"; import { withReadOnlyRelayClient } from "@/shared/api/readOnlyRelayClient"; import type { RelaySubscriptionFilter } from "@/shared/api/relayClientShared"; @@ -148,6 +149,12 @@ export async function pollCommunityUnread( community: Community, pubkey: string, ): Promise { + // Local mode owns exactly one active sidecar. An inactive local community has + // no durable network endpoint: its stored sentinel must never reach a relay + // client. The active workspace already receives ordinary unread handling. + if (!hasCommunityNetworkEndpoint(community.relayUrl)) { + return { hasUnread: false, mentionCount: 0 }; + } return withReadOnlyRelayClient(community.relayUrl, (client) => fetchCommunityUnread({ client, pubkey }), ); diff --git a/desktop/src/features/communities/ui/EditCommunityDialog.tsx b/desktop/src/features/communities/ui/EditCommunityDialog.tsx index 3baa52372..f235032a5 100644 --- a/desktop/src/features/communities/ui/EditCommunityDialog.tsx +++ b/desktop/src/features/communities/ui/EditCommunityDialog.tsx @@ -5,6 +5,7 @@ import { useMyRelayMembershipLookupQuery } from "@/features/community-members/ho import type { Community } from "@/features/communities/types"; import { expandTilde, + isLocalCommunityRelayUrl, normalizeRelayUrl, } from "@/features/communities/communityStorage"; import { validateReposDir } from "@/shared/api/tauri"; @@ -82,9 +83,11 @@ export function EditCommunityDialog({ updates.name = trimmedName; } - const normalizedUrl = normalizeRelayUrl(relayUrl.trim()); - if (normalizedUrl !== community.relayUrl) { - updates.relayUrl = normalizedUrl; + if (!isLocalCommunityRelayUrl(community.relayUrl)) { + const normalizedUrl = normalizeRelayUrl(relayUrl.trim()); + if (normalizedUrl !== community.relayUrl) { + updates.relayUrl = normalizedUrl; + } } const trimmedToken = token.trim() || undefined; @@ -170,6 +173,7 @@ export function EditCommunityDialog({ Relay URL setRelayUrl(e.target.value)} placeholder="wss://relay.example.com" diff --git a/desktop/src/features/communities/useCommunityIcons.ts b/desktop/src/features/communities/useCommunityIcons.ts index 2abf208e3..1cf2903f9 100644 --- a/desktop/src/features/communities/useCommunityIcons.ts +++ b/desktop/src/features/communities/useCommunityIcons.ts @@ -2,6 +2,7 @@ import { useQueries, useQuery } from "@tanstack/react-query"; import { fetchCommunityIcon } from "@/shared/api/communityProfile"; +import { isLocalCommunityRelayUrl } from "./communityStorage"; import type { Community } from "./types"; import { loadCachedCommunityIcon, @@ -22,9 +23,13 @@ async function fetchIconForCommunity( } function iconQueryOptions(community: Community) { + const local = isLocalCommunityRelayUrl(community.relayUrl); return { queryKey: communityIconQueryKey(community.relayUrl), queryFn: () => fetchIconForCommunity(community), + // A local sentinel is storage-only and has no HTTP origin. Its active + // relay is resolved privately by desktop; inactive local mode is stopped. + enabled: !local, // Cached icon renders immediately; the fetch still runs and replaces it. placeholderData: loadCachedCommunityIcon(community.relayUrl), staleTime: ICON_STALE_MS, @@ -55,6 +60,7 @@ export function useCommunityIcons( /** Icon of the ACTIVE community, for settings preview. */ export function useActiveCommunityIcon(relayUrl: string | undefined) { + const local = relayUrl !== undefined && isLocalCommunityRelayUrl(relayUrl); return useQuery({ queryKey: communityIconQueryKey(relayUrl ?? ""), queryFn: async () => { @@ -62,7 +68,7 @@ export function useActiveCommunityIcon(relayUrl: string | undefined) { if (relayUrl) saveCachedCommunityIcon(relayUrl, icon); return icon; }, - enabled: relayUrl !== undefined, + enabled: relayUrl !== undefined && !local, staleTime: ICON_STALE_MS, }); }