From bef7369de9bc6b875251daee0afd9c69a08ad7c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20G=C3=B3mez?= Date: Sun, 14 Dec 2025 11:51:02 +0100 Subject: [PATCH] fix: normalize relay URLs --- src/components/DecodeViewer.tsx | 3 +- src/components/EncodeViewer.tsx | 3 +- src/components/ReqViewer.tsx | 11 +- src/components/nostr/RelayLink.tsx | 53 +++++++- src/hooks/useAccountSync.ts | 56 ++++++--- src/hooks/useRelayInfo.ts | 26 ++-- src/hooks/useRelayState.ts | 4 +- src/lib/encode-parser.ts | 3 +- src/lib/nip11.ts | 69 +++++++--- src/lib/open-parser.ts | 29 ++++- src/lib/relay-parser.ts | 5 +- src/lib/relay-url.test.ts | 119 ++++++++++++++++++ src/lib/relay-url.ts | 50 ++++++++ src/lib/req-parser.test.ts | 159 +++++++++++++++++++++-- src/lib/req-parser.ts | 57 ++++++--- src/services/db.ts | 83 ++++++++++++ src/services/relay-state-manager.ts | 187 +++++++++++++++++----------- 17 files changed, 756 insertions(+), 161 deletions(-) create mode 100644 src/lib/relay-url.test.ts create mode 100644 src/lib/relay-url.ts diff --git a/src/components/DecodeViewer.tsx b/src/components/DecodeViewer.tsx index a2fa191..598d5a2 100644 --- a/src/components/DecodeViewer.tsx +++ b/src/components/DecodeViewer.tsx @@ -11,6 +11,7 @@ import { useCopy } from "../hooks/useCopy"; import { Button } from "./ui/button"; import { Input } from "./ui/input"; import { KindBadge } from "./KindBadge"; +import { normalizeRelayURL } from "@/lib/relay-url"; interface DecodeViewerProps { args: string[]; @@ -77,7 +78,7 @@ export default function DecodeViewer({ args }: DecodeViewerProps) { setError("Relay must be a WebSocket URL (ws:// or wss://)"); return; } - setRelays([...relays, relayUrl]); + setRelays([...relays, normalizeRelayURL(relayUrl)]); setNewRelay(""); setError(null); } catch { diff --git a/src/components/EncodeViewer.tsx b/src/components/EncodeViewer.tsx index 5af3a1c..eadc9e0 100644 --- a/src/components/EncodeViewer.tsx +++ b/src/components/EncodeViewer.tsx @@ -8,6 +8,7 @@ import { import { useCopy } from "../hooks/useCopy"; import { Button } from "./ui/button"; import { Input } from "./ui/input"; +import { normalizeRelayURL } from "@/lib/relay-url"; interface EncodeViewerProps { args: string[]; @@ -66,7 +67,7 @@ export default function EncodeViewer({ args }: EncodeViewerProps) { setError("Relay must be a WebSocket URL (ws:// or wss://)"); return; } - setRelays([...relays, relayUrl]); + setRelays([...relays, normalizeRelayURL(relayUrl)]); setNewRelay(""); setError(null); } catch { diff --git a/src/components/ReqViewer.tsx b/src/components/ReqViewer.tsx index 37aef7b..3e1b180 100644 --- a/src/components/ReqViewer.tsx +++ b/src/components/ReqViewer.tsx @@ -473,21 +473,24 @@ export default function ReqViewer({ {/* Results */}
- {loading && events.length === 0 && ( + {/* Loading: Before EOSE received */} + {loading && events.length === 0 && !eoseReceived && (
Loading events...
)} - {!loading && !stream && events.length === 0 && !error && ( + {/* EOSE received, no events, not streaming */} + {eoseReceived && events.length === 0 && !stream && !error && (
No events found matching filter
)} - {stream && events.length === 0 && !loading && ( + {/* EOSE received, no events, streaming (live mode) */} + {eoseReceived && events.length === 0 && stream && (
- Waiting for events... + Listening for new events...
)} diff --git a/src/components/nostr/RelayLink.tsx b/src/components/nostr/RelayLink.tsx index df188cd..659303e 100644 --- a/src/components/nostr/RelayLink.tsx +++ b/src/components/nostr/RelayLink.tsx @@ -1,4 +1,4 @@ -import { Inbox, Send } from "lucide-react"; +import { Inbox, Send, ShieldAlert } from "lucide-react"; import { useGrimoire } from "@/core/state"; import { useRelayInfo } from "@/hooks/useRelayInfo"; import { @@ -8,6 +8,22 @@ import { } from "@/components/ui/hover-card"; import { cn } from "@/lib/utils"; +/** + * Format relay URL for display by removing protocol and trailing slashes + */ +function formatRelayUrlForDisplay(url: string): string { + return url + .replace(/^wss?:\/\//, "") // Remove ws:// or wss:// + .replace(/\/$/, ""); // Remove trailing slash +} + +/** + * Check if relay uses insecure ws:// protocol + */ +function isInsecureRelay(url: string): boolean { + return url.startsWith("ws://"); +} + export interface RelayLinkProps { url: string; read?: boolean; @@ -46,6 +62,9 @@ export function RelayLink({ prompt: "cursor-crosshair hover:underline hover:decoration-dotted", }; + const displayUrl = formatRelayUrlForDisplay(url); + const isInsecure = isInsecureRelay(url); + return (
)} - {url} + {isInsecure && ( + + +
+ +
+
+ e.stopPropagation()} + > +
+
Insecure Connection
+

+ This relay uses unencrypted ws:// protocol. This is typically + only safe for localhost/development. Production relays should + use wss:// (secure WebSocket). +

+
+
+
+ )} + + {displayUrl} +
{showInboxOutbox && read && ( diff --git a/src/hooks/useAccountSync.ts b/src/hooks/useAccountSync.ts index 551bf9c..e6946e3 100644 --- a/src/hooks/useAccountSync.ts +++ b/src/hooks/useAccountSync.ts @@ -5,6 +5,7 @@ import { useGrimoire } from "@/core/state"; import { getInboxes, getOutboxes } from "applesauce-core/helpers"; import { addressLoader } from "@/services/loaders"; import type { RelayInfo, UserRelays } from "@/types/app"; +import { normalizeRelayURL } from "@/lib/relay-url"; /** * Hook that syncs active account with Grimoire state and fetches relay lists @@ -56,31 +57,46 @@ export function useAccountSync() { const seenUrls = new Set(); for (const tag of relayListEvent.tags) { - if (tag[0] === "r") { - const url = tag[1]; - if (seenUrls.has(url)) continue; - seenUrls.add(url); + if (tag[0] === "r" && tag[1]) { + try { + const url = normalizeRelayURL(tag[1]); + if (seenUrls.has(url)) continue; + seenUrls.add(url); - const type = tag[2]; - allRelays.push({ - url, - read: !type || type === "read", - write: !type || type === "write", - }); + const type = tag[2]; + allRelays.push({ + url, + read: !type || type === "read", + write: !type || type === "write", + }); + } catch (error) { + console.warn( + `Skipping invalid relay URL in Kind 10002 event: ${tag[1]}`, + error + ); + } } } const relays: UserRelays = { - inbox: inboxRelays.map((url) => ({ - url, - read: true, - write: false, - })), - outbox: outboxRelays.map((url) => ({ - url, - read: false, - write: true, - })), + inbox: inboxRelays + .map((url) => { + try { + return { url: normalizeRelayURL(url), read: true, write: false }; + } catch { + return null; + } + }) + .filter((r): r is RelayInfo => r !== null), + outbox: outboxRelays + .map((url) => { + try { + return { url: normalizeRelayURL(url), read: false, write: true }; + } catch { + return null; + } + }) + .filter((r): r is RelayInfo => r !== null), all: allRelays, }; diff --git a/src/hooks/useRelayInfo.ts b/src/hooks/useRelayInfo.ts index e803181..3cd2e58 100644 --- a/src/hooks/useRelayInfo.ts +++ b/src/hooks/useRelayInfo.ts @@ -1,8 +1,9 @@ import { useLiveQuery } from "dexie-react-hooks"; -import { useEffect } from "react"; +import { useEffect, useMemo } from "react"; import { RelayInformation } from "../types/nip11"; import { fetchRelayInfo } from "../lib/nip11"; import db from "../services/db"; +import { normalizeRelayURL } from "../lib/relay-url"; /** * React hook to fetch and cache relay information (NIP-11) @@ -12,26 +13,37 @@ import db from "../services/db"; export function useRelayInfo( wsUrl: string | undefined, ): RelayInformation | undefined { + // Normalize URL once + const normalizedUrl = useMemo(() => { + if (!wsUrl) return undefined; + try { + return normalizeRelayURL(wsUrl); + } catch (error) { + console.warn(`useRelayInfo: Invalid relay URL ${wsUrl}:`, error); + return undefined; + } + }, [wsUrl]); + const cached = useLiveQuery( - () => (wsUrl ? db.relayInfo.get(wsUrl) : undefined), - [wsUrl], + () => (normalizedUrl ? db.relayInfo.get(normalizedUrl) : undefined), + [normalizedUrl], ); useEffect(() => { - if (!wsUrl) return; + if (!normalizedUrl) return; if (cached) return; // Fetch relay info if not in cache - fetchRelayInfo(wsUrl).then((info) => { + fetchRelayInfo(normalizedUrl).then((info) => { if (info) { db.relayInfo.put({ - url: wsUrl, + url: normalizedUrl, info, fetchedAt: Date.now(), }); } }); - }, [cached, wsUrl]); + }, [cached, normalizedUrl]); return cached?.info; } diff --git a/src/hooks/useRelayState.ts b/src/hooks/useRelayState.ts index d014da7..1286889 100644 --- a/src/hooks/useRelayState.ts +++ b/src/hooks/useRelayState.ts @@ -3,6 +3,7 @@ import { useAtom } from "jotai"; import { grimoireStateAtom } from "@/core/state"; import relayStateManager from "@/services/relay-state-manager"; import type { AuthPreference, RelayState } from "@/types/relay-state"; +import { normalizeRelayURL } from "@/lib/relay-url"; /** * Hook for accessing and managing global relay state @@ -45,7 +46,8 @@ export function useRelayState() { // Get single relay state getRelay: (url: string): RelayState | undefined => { - return relayState?.relays[url]; + const normalizedUrl = normalizeRelayURL(url); + return relayState?.relays[normalizedUrl]; }, // Get auth preference diff --git a/src/lib/encode-parser.ts b/src/lib/encode-parser.ts index 299a1a2..7e9b5c2 100644 --- a/src/lib/encode-parser.ts +++ b/src/lib/encode-parser.ts @@ -1,5 +1,6 @@ import { nip19 } from "nostr-tools"; import { isValidHexEventId, isValidHexPubkey } from "./nostr-validation"; +import { normalizeRelayURL } from "./relay-url"; export type EncodeType = "npub" | "note" | "nevent" | "nprofile" | "naddr"; @@ -55,7 +56,7 @@ export function parseEncodeCommand(args: string[]): ParsedEncodeCommand { if (i + 1 >= args.length) { throw new Error(`${flag} requires a relay URL`); } - relays.push(args[i + 1]); + relays.push(normalizeRelayURL(args[i + 1])); i += 2; continue; } diff --git a/src/lib/nip11.ts b/src/lib/nip11.ts index 43d75fd..7bc6fa6 100644 --- a/src/lib/nip11.ts +++ b/src/lib/nip11.ts @@ -1,5 +1,6 @@ import { RelayInformation } from "../types/nip11"; import db from "../services/db"; +import { normalizeRelayURL } from "./relay-url"; /** * NIP-11: Relay Information Document @@ -16,8 +17,11 @@ export async function fetchRelayInfo( wsUrl: string, ): Promise { try { + // Normalize URL for consistency + const normalizedUrl = normalizeRelayURL(wsUrl); + // Convert ws:// or wss:// to https:// - const httpUrl = wsUrl.replace(/^ws(s)?:/, "https:"); + const httpUrl = normalizedUrl.replace(/^ws(s)?:/, "https:"); const response = await fetch(httpUrl, { headers: { Accept: "application/nostr+json" }, @@ -38,17 +42,23 @@ export async function fetchRelayInfo( export async function getRelayInfo( wsUrl: string, ): Promise { - const cached = await db.relayInfo.get(wsUrl); - const isExpired = !cached || Date.now() - cached.fetchedAt > CACHE_DURATION; + try { + const normalizedUrl = normalizeRelayURL(wsUrl); + const cached = await db.relayInfo.get(normalizedUrl); + const isExpired = !cached || Date.now() - cached.fetchedAt > CACHE_DURATION; - if (!isExpired) return cached.info; + if (!isExpired) return cached.info; - const info = await fetchRelayInfo(wsUrl); - if (info) { - await db.relayInfo.put({ url: wsUrl, info, fetchedAt: Date.now() }); + const info = await fetchRelayInfo(normalizedUrl); + if (info) { + await db.relayInfo.put({ url: normalizedUrl, info, fetchedAt: Date.now() }); + } + + return info; + } catch (error) { + console.warn(`NIP-11: Failed to get relay info for ${wsUrl}:`, error); + return null; } - - return info; } /** @@ -57,8 +67,14 @@ export async function getRelayInfo( export async function getCachedRelayInfo( wsUrl: string, ): Promise { - const cached = await db.relayInfo.get(wsUrl); - return cached?.info ?? null; + try { + const normalizedUrl = normalizeRelayURL(wsUrl); + const cached = await db.relayInfo.get(normalizedUrl); + return cached?.info ?? null; + } catch (error) { + console.warn(`NIP-11: Failed to get cached relay info for ${wsUrl}:`, error); + return null; + } } /** @@ -68,10 +84,20 @@ export async function getRelayInfoBatch( wsUrls: string[], ): Promise> { const results = new Map(); - const infos = await Promise.all(wsUrls.map((url) => getRelayInfo(url))); + + // Normalize URLs first + const normalizedUrls = wsUrls.map((url) => { + try { + return normalizeRelayURL(url); + } catch { + return null; + } + }).filter((url): url is string => url !== null); + + const infos = await Promise.all(normalizedUrls.map((url) => getRelayInfo(url))); infos.forEach((info, i) => { - if (info) results.set(wsUrls[i], info); + if (info) results.set(normalizedUrls[i], info); }); return results; @@ -82,7 +108,12 @@ export async function getRelayInfoBatch( */ export async function clearRelayInfoCache(wsUrl?: string): Promise { if (wsUrl) { - await db.relayInfo.delete(wsUrl); + try { + const normalizedUrl = normalizeRelayURL(wsUrl); + await db.relayInfo.delete(normalizedUrl); + } catch (error) { + console.warn(`NIP-11: Failed to clear cache for ${wsUrl}:`, error); + } } else { await db.relayInfo.clear(); } @@ -95,6 +126,12 @@ export async function relaySupportsNip( wsUrl: string, nipNumber: number, ): Promise { - const info = await getRelayInfo(wsUrl); - return info?.supported_nips?.includes(nipNumber) ?? false; + try { + const normalizedUrl = normalizeRelayURL(wsUrl); + const info = await getRelayInfo(normalizedUrl); + return info?.supported_nips?.includes(nipNumber) ?? false; + } catch (error) { + console.warn(`NIP-11: Failed to check NIP support for ${wsUrl}:`, error); + return false; + } } diff --git a/src/lib/open-parser.ts b/src/lib/open-parser.ts index 6c6fb20..69889e0 100644 --- a/src/lib/open-parser.ts +++ b/src/lib/open-parser.ts @@ -4,6 +4,7 @@ import { isValidHexPubkey, normalizeHex, } from "./nostr-validation"; +import { normalizeRelayURL } from "./relay-url"; // Define pointer types locally since they're not exported from nostr-tools export interface EventPointer { @@ -60,14 +61,38 @@ export function parseOpenCommand(args: string[]): ParsedOpenCommand { if (decoded.type === "nevent") { // nevent1... -> EventPointer (already has id and optional relays) return { - pointer: decoded.data, + pointer: { + ...decoded.data, + relays: decoded.data.relays + ?.map((url) => { + try { + return normalizeRelayURL(url); + } catch (error) { + console.warn(`Skipping invalid relay hint in nevent: ${url}`, error); + return null; + } + }) + .filter((url): url is string => url !== null), + }, }; } if (decoded.type === "naddr") { // naddr1... -> AddressPointer (already has kind, pubkey, identifier) return { - pointer: decoded.data, + pointer: { + ...decoded.data, + relays: decoded.data.relays + ?.map((url) => { + try { + return normalizeRelayURL(url); + } catch (error) { + console.warn(`Skipping invalid relay hint in naddr: ${url}`, error); + return null; + } + }) + .filter((url): url is string => url !== null), + }, }; } } catch (error) { diff --git a/src/lib/relay-parser.ts b/src/lib/relay-parser.ts index 4a90a6c..9bf1535 100644 --- a/src/lib/relay-parser.ts +++ b/src/lib/relay-parser.ts @@ -1,3 +1,5 @@ +import { normalizeRelayURL } from "./relay-url"; + export interface ParsedRelayCommand { url: string; } @@ -32,5 +34,6 @@ export function parseRelayCommand(args: string[]): ParsedRelayCommand { throw new Error(`Invalid relay URL: ${url}`); } - return { url }; + // Normalize the URL (adds trailing slash, lowercases) + return { url: normalizeRelayURL(url) }; } diff --git a/src/lib/relay-url.test.ts b/src/lib/relay-url.test.ts new file mode 100644 index 0000000..120d427 --- /dev/null +++ b/src/lib/relay-url.test.ts @@ -0,0 +1,119 @@ +import { describe, it, expect } from "vitest"; +import { normalizeRelayURL } from "./relay-url"; + +describe("normalizeRelayURL", () => { + it("should add trailing slash to URL without one", () => { + const result = normalizeRelayURL("wss://relay.example.com"); + expect(result).toBe("wss://relay.example.com/"); + }); + + it("should preserve trailing slash", () => { + const result = normalizeRelayURL("wss://relay.example.com/"); + expect(result).toBe("wss://relay.example.com/"); + }); + + it("should normalize URLs with and without trailing slash to the same value", () => { + const withTrailingSlash = normalizeRelayURL("wss://theforest.nostr1.com/"); + const withoutTrailingSlash = normalizeRelayURL("wss://theforest.nostr1.com"); + expect(withTrailingSlash).toBe(withoutTrailingSlash); + }); + + it("should add wss:// protocol when missing", () => { + const result = normalizeRelayURL("relay.example.com"); + expect(result).toBe("wss://relay.example.com/"); + }); + + it("should lowercase the URL", () => { + const result = normalizeRelayURL("wss://Relay.Example.COM"); + expect(result).toBe("wss://relay.example.com/"); + }); + + it("should handle URLs with paths", () => { + const result = normalizeRelayURL("wss://relay.example.com/path"); + expect(result).toBe("wss://relay.example.com/path"); + }); + + it("should handle URLs with ports", () => { + const result = normalizeRelayURL("wss://relay.example.com:8080"); + expect(result).toBe("wss://relay.example.com:8080/"); + }); + + it("should trim whitespace", () => { + const result = normalizeRelayURL(" wss://relay.example.com "); + expect(result).toBe("wss://relay.example.com/"); + }); + + it("should handle mixed case with missing protocol and trailing slash", () => { + const result = normalizeRelayURL("RELAY.EXAMPLE.COM"); + expect(result).toBe("wss://relay.example.com/"); + }); + + it("should handle URLs with query strings", () => { + const result = normalizeRelayURL("wss://relay.example.com?key=value"); + expect(result).toBe("wss://relay.example.com/?key=value"); + }); + + it("should handle URLs with fragments", () => { + const result = normalizeRelayURL("wss://relay.example.com#section"); + expect(result).toBe("wss://relay.example.com/#section"); + }); + + it("should preserve ws:// protocol", () => { + const result = normalizeRelayURL("ws://relay.example.com"); + expect(result).toBe("ws://relay.example.com/"); + }); + + it("should handle complex URLs with path, port, and query", () => { + const result = normalizeRelayURL("wss://relay.example.com:8080/path?key=value"); + expect(result).toBe("wss://relay.example.com:8080/path?key=value"); + }); + + it("should normalize duplicate slashes to single slash", () => { + const result = normalizeRelayURL("wss://relay.example.com//"); + expect(result).toBe("wss://relay.example.com/"); + }); + + describe("Error Handling", () => { + it("should throw on empty string", () => { + expect(() => normalizeRelayURL("")).toThrow("Relay URL cannot be empty"); + }); + + it("should throw on whitespace-only string", () => { + expect(() => normalizeRelayURL(" ")).toThrow("Relay URL cannot be empty"); + }); + + it("should throw TypeError on null input", () => { + expect(() => normalizeRelayURL(null as any)).toThrow(TypeError); + expect(() => normalizeRelayURL(null as any)).toThrow("must be a string"); + }); + + it("should throw TypeError on undefined input", () => { + expect(() => normalizeRelayURL(undefined as any)).toThrow(TypeError); + expect(() => normalizeRelayURL(undefined as any)).toThrow("must be a string"); + }); + + it("should throw TypeError on non-string input (number)", () => { + expect(() => normalizeRelayURL(123 as any)).toThrow(TypeError); + expect(() => normalizeRelayURL(123 as any)).toThrow("must be a string"); + }); + + it("should throw TypeError on non-string input (object)", () => { + expect(() => normalizeRelayURL({} as any)).toThrow(TypeError); + expect(() => normalizeRelayURL({} as any)).toThrow("must be a string"); + }); + + it("should handle very long URLs without crashing", () => { + const longPath = "a".repeat(5000); + const longUrl = `wss://relay.example.com/${longPath}`; + const result = normalizeRelayURL(longUrl); + expect(result).toContain("wss://relay.example.com/"); + expect(result.length).toBeGreaterThan(5000); + }); + + it("should handle URLs with special characters in query", () => { + const result = normalizeRelayURL("wss://relay.example.com?key="); + expect(result).toContain("wss://relay.example.com/"); + // Note: URL encoding is handled by browser's URL parsing + }); + }); +}); diff --git a/src/lib/relay-url.ts b/src/lib/relay-url.ts new file mode 100644 index 0000000..db30d92 --- /dev/null +++ b/src/lib/relay-url.ts @@ -0,0 +1,50 @@ +import { normalizeURL as applesauceNormalizeURL } from "applesauce-core/helpers"; + +/** + * Normalize a relay URL to ensure consistent comparison + * - Validates input is a non-empty string + * - Ensures wss:// protocol + * - Ensures trailing slash + * - Lowercases the URL + * + * Examples: + * - "wss://relay.com" → "wss://relay.com/" + * - "wss://relay.com/" → "wss://relay.com/" + * - "relay.com" → "wss://relay.com/" + * + * @throws {TypeError} If url is not a string + * @throws {Error} If url is empty or normalization fails + */ +export function normalizeRelayURL(url: string): string { + // Input validation + if (typeof url !== "string") { + throw new TypeError( + `Relay URL must be a string, received: ${typeof url}` + ); + } + + const trimmed = url.trim(); + if (!trimmed) { + throw new Error("Relay URL cannot be empty"); + } + + try { + // Ensure protocol + let normalized = trimmed; + if (!normalized.startsWith("ws://") && !normalized.startsWith("wss://")) { + normalized = `wss://${normalized}`; + } + + // Use applesauce's normalization (adds trailing slash) + normalized = applesauceNormalizeURL(normalized); + + // Lowercase for consistent comparison + return normalized.toLowerCase(); + } catch (error) { + throw new Error( + `Failed to normalize relay URL "${url}": ${ + error instanceof Error ? error.message : String(error) + }` + ); + } +} diff --git a/src/lib/req-parser.test.ts b/src/lib/req-parser.test.ts index 639bfb5..d3813df 100644 --- a/src/lib/req-parser.test.ts +++ b/src/lib/req-parser.test.ts @@ -46,6 +46,63 @@ describe("parseReqCommand", () => { expect(result.filter.authors).toEqual([hex]); }); + it("should parse npub", () => { + // Real npub for pubkey: 3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d + const npub = + "npub180cvv07tjdrrgpa0j7j7tmnyl2yr6yr7l8j4s3evf6u64th6gkwsyjh6w6"; + const result = parseReqCommand(["-a", npub]); + expect(result.filter.authors).toEqual([ + "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d", + ]); + }); + + it("should parse nprofile", () => { + // Real nprofile for same pubkey with relay hints + const nprofile = + "nprofile1qqsrhuxx8l9ex335q7he0f09aej04zpazpl0ne2cgukyawd24mayt8gpp4mhxue69uhhytnc9e3k7mgpz4mhxue69uhkg6nzv9ejuumpv34kytnrdaksjlyr9p"; + const result = parseReqCommand(["-a", nprofile]); + expect(result.filter.authors).toEqual([ + "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d", + ]); + }); + + it("should extract and normalize relay hints from nprofile in -a flag", () => { + // nprofile with relay hints + const nprofile = + "nprofile1qqsrhuxx8l9ex335q7he0f09aej04zpazpl0ne2cgukyawd24mayt8gpp4mhxue69uhhytnc9e3k7mgpz4mhxue69uhkg6nzv9ejuumpv34kytnrdaksjlyr9p"; + const result = parseReqCommand(["-a", nprofile]); + // Relay hints should be normalized (lowercase, trailing slash) + expect(result.relays).toContain("wss://r.x.com/"); + expect(result.relays).toContain("wss://djbas.sadkb.com/"); + }); + + it("should combine explicit relays with nprofile relay hints and normalize all", () => { + const nprofile = + "nprofile1qqsrhuxx8l9ex335q7he0f09aej04zpazpl0ne2cgukyawd24mayt8gpp4mhxue69uhhytnc9e3k7mgpz4mhxue69uhkg6nzv9ejuumpv34kytnrdaksjlyr9p"; + const result = parseReqCommand([ + "-a", + nprofile, + "wss://relay.damus.io", + ]); + // All relays should be normalized + expect(result.relays).toEqual([ + "wss://r.x.com/", + "wss://djbas.sadkb.com/", + "wss://relay.damus.io/", + ]); + }); + + it("should extract relays from comma-separated nprofiles", () => { + const nprofile1 = + "nprofile1qqsrhuxx8l9ex335q7he0f09aej04zpazpl0ne2cgukyawd24mayt8gpp4mhxue69uhhytnc9e3k7mgpz4mhxue69uhkg6nzv9ejuumpv34kytnrdaksjlyr9p"; + const nprofile2 = + "nprofile1qqsrhuxx8l9ex335q7he0f09aej04zpazpl0ne2cgukyawd24mayt8gpp4mhxue69uhhytnc9e3k7mgpz4mhxue69uhkg6nzv9ejuumpv34kytnrdaksjlyr9p"; + const result = parseReqCommand(["-a", `${nprofile1},${nprofile2}`]); + // Should get normalized relays from both (even though they're duplicates in this test) + expect(result.relays).toContain("wss://r.x.com/"); + expect(result.relays).toContain("wss://djbas.sadkb.com/"); + }); + it("should parse comma-separated hex pubkeys", () => { const hex1 = "a".repeat(64); const hex2 = "b".repeat(64); @@ -53,6 +110,18 @@ describe("parseReqCommand", () => { expect(result.filter.authors).toEqual([hex1, hex2]); }); + it("should parse comma-separated mix of npub and nprofile", () => { + const npub = + "npub180cvv07tjdrrgpa0j7j7tmnyl2yr6yr7l8j4s3evf6u64th6gkwsyjh6w6"; + const nprofile = + "nprofile1qqsrhuxx8l9ex335q7he0f09aej04zpazpl0ne2cgukyawd24mayt8gpp4mhxue69uhhytnc9e3k7mgpz4mhxue69uhkg6nzv9ejuumpv34kytnrdaksjlyr9p"; + const result = parseReqCommand(["-a", `${npub},${nprofile}`]); + // Both should decode to the same pubkey, so should be deduplicated + expect(result.filter.authors).toEqual([ + "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d", + ]); + }); + it("should deduplicate authors", () => { const hex = "a".repeat(64); const result = parseReqCommand(["-a", `${hex},${hex}`]); @@ -71,10 +140,18 @@ describe("parseReqCommand", () => { expect(result.filter.authors).toBeUndefined(); }); - it("should handle mixed hex and NIP-05", () => { + it("should handle mixed hex, npub, nprofile, and NIP-05", () => { const hex = "a".repeat(64); - const result = parseReqCommand(["-a", `${hex},user@domain.com`]); - expect(result.filter.authors).toEqual([hex]); + const npub = + "npub180cvv07tjdrrgpa0j7j7tmnyl2yr6yr7l8j4s3evf6u64th6gkwsyjh6w6"; + const result = parseReqCommand([ + "-a", + `${hex},${npub},user@domain.com`, + ]); + expect(result.filter.authors).toEqual([ + hex, + "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d", + ]); expect(result.nip05Authors).toEqual(["user@domain.com"]); }); @@ -112,6 +189,33 @@ describe("parseReqCommand", () => { expect(result.filter["#p"]).toEqual([hex]); }); + it("should parse npub for #p tag", () => { + const npub = + "npub180cvv07tjdrrgpa0j7j7tmnyl2yr6yr7l8j4s3evf6u64th6gkwsyjh6w6"; + const result = parseReqCommand(["-p", npub]); + expect(result.filter["#p"]).toEqual([ + "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d", + ]); + }); + + it("should parse nprofile for #p tag", () => { + const nprofile = + "nprofile1qqsrhuxx8l9ex335q7he0f09aej04zpazpl0ne2cgukyawd24mayt8gpp4mhxue69uhhytnc9e3k7mgpz4mhxue69uhkg6nzv9ejuumpv34kytnrdaksjlyr9p"; + const result = parseReqCommand(["-p", nprofile]); + expect(result.filter["#p"]).toEqual([ + "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d", + ]); + }); + + it("should extract and normalize relay hints from nprofile in -p flag", () => { + const nprofile = + "nprofile1qqsrhuxx8l9ex335q7he0f09aej04zpazpl0ne2cgukyawd24mayt8gpp4mhxue69uhhytnc9e3k7mgpz4mhxue69uhkg6nzv9ejuumpv34kytnrdaksjlyr9p"; + const result = parseReqCommand(["-p", nprofile]); + // Relay hints should be normalized (lowercase, trailing slash) + expect(result.relays).toContain("wss://r.x.com/"); + expect(result.relays).toContain("wss://djbas.sadkb.com/"); + }); + it("should parse comma-separated pubkeys", () => { const hex1 = "a".repeat(64); const hex2 = "b".repeat(64); @@ -131,6 +235,21 @@ describe("parseReqCommand", () => { expect(result.filter["#p"]).toBeUndefined(); }); + it("should handle mixed hex, npub, nprofile, and NIP-05 for #p tags", () => { + const hex = "a".repeat(64); + const npub = + "npub180cvv07tjdrrgpa0j7j7tmnyl2yr6yr7l8j4s3evf6u64th6gkwsyjh6w6"; + const result = parseReqCommand([ + "-p", + `${hex},${npub},user@domain.com`, + ]); + expect(result.filter["#p"]).toEqual([ + hex, + "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d", + ]); + expect(result.nip05PTags).toEqual(["user@domain.com"]); + }); + it("should deduplicate #p tags", () => { const hex = "a".repeat(64); const result = parseReqCommand(["-p", `${hex},${hex}`]); @@ -221,28 +340,42 @@ describe("parseReqCommand", () => { }); describe("relay parsing", () => { - it("should parse relay with wss:// protocol", () => { + it("should parse relay with wss:// protocol and normalize", () => { const result = parseReqCommand(["wss://relay.example.com"]); - expect(result.relays).toEqual(["wss://relay.example.com"]); + expect(result.relays).toEqual(["wss://relay.example.com/"]); }); - it("should parse relay domain and add wss://", () => { + it("should parse relay domain and add wss:// with trailing slash", () => { const result = parseReqCommand(["relay.example.com"]); - expect(result.relays).toEqual(["wss://relay.example.com"]); + expect(result.relays).toEqual(["wss://relay.example.com/"]); }); - it("should parse multiple relays", () => { + it("should parse multiple relays and normalize all", () => { const result = parseReqCommand([ "wss://relay1.com", "relay2.com", - "wss://relay3.com", + "wss://relay3.com/", ]); expect(result.relays).toEqual([ - "wss://relay1.com", - "wss://relay2.com", - "wss://relay3.com", + "wss://relay1.com/", + "wss://relay2.com/", + "wss://relay3.com/", ]); }); + + it("should normalize relays with and without trailing slash to same value", () => { + const result = parseReqCommand([ + "wss://relay.com", + "wss://relay.com/", + ]); + // Should deduplicate because they normalize to the same URL + expect(result.relays).toEqual(["wss://relay.com/", "wss://relay.com/"]); + }); + + it("should lowercase relay URLs during normalization", () => { + const result = parseReqCommand(["wss://Relay.Example.COM"]); + expect(result.relays).toEqual(["wss://relay.example.com/"]); + }); }); describe("close-on-eose flag", () => { @@ -279,7 +412,7 @@ describe("parseReqCommand", () => { expect(result.filter["#t"]).toEqual(["nostr", "bitcoin"]); expect(result.filter.limit).toBe(100); expect(result.filter.since).toBeDefined(); - expect(result.relays).toEqual(["wss://relay.example.com"]); + expect(result.relays).toEqual(["wss://relay.example.com/"]); }); it("should handle deduplication across multiple flags and commas", () => { diff --git a/src/lib/req-parser.ts b/src/lib/req-parser.ts index 42b82bc..0f69b5e 100644 --- a/src/lib/req-parser.ts +++ b/src/lib/req-parser.ts @@ -6,6 +6,7 @@ import { isValidHexEventId, normalizeHex, } from "./nostr-validation"; +import { normalizeRelayURL } from "./relay-url"; export interface ParsedReqCommand { filter: NostrFilter; @@ -42,10 +43,10 @@ function parseCommaSeparated( /** * Parse REQ command arguments into a Nostr filter * Supports: - * - Filters: -k (kinds), -a (authors), -l (limit), -e (#e), -p (#p), -t (#t), -d (#d), --tag/-T (any #tag) + * - Filters: -k (kinds), -a (authors: hex/npub/nprofile/NIP-05), -l (limit), -e (#e), -p (#p: hex/npub/nprofile/NIP-05), -t (#t), -d (#d), --tag/-T (any #tag) * - Time: --since, --until * - Search: --search - * - Relays: wss://relay.com or relay.com (auto-adds wss://) + * - Relays: wss://relay.com or relay.com (auto-adds wss://), nprofile relay hints are automatically extracted * - Options: --close-on-eose (close stream after EOSE, default: stream stays open) */ export function parseReqCommand(args: string[]): ParsedReqCommand { @@ -74,14 +75,14 @@ export function parseReqCommand(args: string[]): ParsedReqCommand { // Relay URLs (starts with wss://, ws://, or looks like a domain) if (arg.startsWith("wss://") || arg.startsWith("ws://")) { - relays.push(arg); + relays.push(normalizeRelayURL(arg)); i++; continue; } // Shorthand relay (domain-like string without protocol) if (isRelayDomain(arg)) { - relays.push(`wss://${arg}`); + relays.push(normalizeRelayURL(arg)); i++; continue; } @@ -127,10 +128,14 @@ export function parseReqCommand(args: string[]): ParsedReqCommand { nip05Authors.add(authorStr); addedAny = true; } else { - const pubkey = parseNpubOrHex(authorStr); - if (pubkey) { - authors.add(pubkey); + const result = parseNpubOrHex(authorStr); + if (result.pubkey) { + authors.add(result.pubkey); addedAny = true; + // Add relay hints from nprofile (normalized) + if (result.relays) { + relays.push(...result.relays.map(normalizeRelayURL)); + } } } } @@ -180,10 +185,14 @@ export function parseReqCommand(args: string[]): ParsedReqCommand { nip05PTags.add(pubkeyStr); addedAny = true; } else { - const pubkey = parseNpubOrHex(pubkeyStr); - if (pubkey) { - pTags.add(pubkey); + const result = parseNpubOrHex(pubkeyStr); + if (result.pubkey) { + pTags.add(result.pubkey); addedAny = true; + // Add relay hints from nprofile (normalized) + if (result.relays) { + relays.push(...result.relays.map(normalizeRelayURL)); + } } } } @@ -372,29 +381,39 @@ function parseTimestamp(value: string): number | null { } /** - * Parse npub or hex pubkey + * Parse npub, nprofile, or hex pubkey + * Returns pubkey and optional relay hints from nprofile */ -function parseNpubOrHex(value: string): string | null { - if (!value) return null; +function parseNpubOrHex(value: string): { + pubkey: string | null; + relays?: string[]; +} { + if (!value) return { pubkey: null }; - // Try to decode npub - if (value.startsWith("npub")) { + // Try to decode npub or nprofile + if (value.startsWith("npub") || value.startsWith("nprofile")) { try { const decoded = nip19.decode(value); if (decoded.type === "npub") { - return decoded.data; + return { pubkey: decoded.data }; + } + if (decoded.type === "nprofile") { + return { + pubkey: decoded.data.pubkey, + relays: decoded.data.relays, + }; } } catch { - // Not valid npub, continue + // Not valid npub/nprofile, continue } } // Check if it's hex pubkey if (isValidHexPubkey(value)) { - return normalizeHex(value); + return { pubkey: normalizeHex(value) }; } - return null; + return { pubkey: null }; } /** diff --git a/src/services/db.ts b/src/services/db.ts index b22937f..eb3f31b 100644 --- a/src/services/db.ts +++ b/src/services/db.ts @@ -1,6 +1,7 @@ import { ProfileContent } from "applesauce-core/helpers"; import { Dexie, Table } from "dexie"; import { RelayInformation } from "../types/nip11"; +import { normalizeRelayURL } from "../lib/relay-url"; export interface Profile extends ProfileContent { pubkey: string; @@ -39,6 +40,8 @@ class GrimoireDb extends Dexie { constructor(name: string) { super(name); + + // Version 5: Current schema this.version(5).stores({ profiles: "&pubkey", nip05: "&nip05", @@ -46,6 +49,86 @@ class GrimoireDb extends Dexie { relayInfo: "&url", relayAuthPreferences: "&url", }); + + // Version 6: Normalize relay URLs + this.version(6) + .stores({ + profiles: "&pubkey", + nip05: "&nip05", + nips: "&id", + relayInfo: "&url", + relayAuthPreferences: "&url", + }) + .upgrade(async (tx) => { + console.log("[DB Migration v6] Normalizing relay URLs..."); + + // Migrate relayAuthPreferences + const authPrefs = await tx.table("relayAuthPreferences").toArray(); + const normalizedAuthPrefs = new Map(); + let skippedAuthPrefs = 0; + + for (const pref of authPrefs) { + try { + const normalizedUrl = normalizeRelayURL(pref.url); + const existing = normalizedAuthPrefs.get(normalizedUrl); + + // Keep the most recent preference if duplicates exist + if (!existing || pref.updatedAt > existing.updatedAt) { + normalizedAuthPrefs.set(normalizedUrl, { + ...pref, + url: normalizedUrl, + }); + } + } catch (error) { + skippedAuthPrefs++; + console.warn( + `[DB Migration v6] Skipping invalid relay URL in auth preferences: ${pref.url}`, + error + ); + } + } + + await tx.table("relayAuthPreferences").clear(); + await tx.table("relayAuthPreferences").bulkAdd(Array.from(normalizedAuthPrefs.values())); + console.log( + `[DB Migration v6] Normalized ${normalizedAuthPrefs.size} auth preferences` + + (skippedAuthPrefs > 0 ? ` (skipped ${skippedAuthPrefs} invalid)` : "") + ); + + // Migrate relayInfo + const relayInfos = await tx.table("relayInfo").toArray(); + const normalizedRelayInfos = new Map(); + let skippedRelayInfos = 0; + + for (const info of relayInfos) { + try { + const normalizedUrl = normalizeRelayURL(info.url); + const existing = normalizedRelayInfos.get(normalizedUrl); + + // Keep the most recent info if duplicates exist + if (!existing || info.fetchedAt > existing.fetchedAt) { + normalizedRelayInfos.set(normalizedUrl, { + ...info, + url: normalizedUrl, + }); + } + } catch (error) { + skippedRelayInfos++; + console.warn( + `[DB Migration v6] Skipping invalid relay URL in relay info: ${info.url}`, + error + ); + } + } + + await tx.table("relayInfo").clear(); + await tx.table("relayInfo").bulkAdd(Array.from(normalizedRelayInfos.values())); + console.log( + `[DB Migration v6] Normalized ${normalizedRelayInfos.size} relay infos` + + (skippedRelayInfos > 0 ? ` (skipped ${skippedRelayInfos} invalid)` : "") + ); + console.log("[DB Migration v6] Complete!"); + }); } } diff --git a/src/services/relay-state-manager.ts b/src/services/relay-state-manager.ts index 10c0744..8a4fe43 100644 --- a/src/services/relay-state-manager.ts +++ b/src/services/relay-state-manager.ts @@ -8,6 +8,7 @@ import type { } from "@/types/relay-state"; import { transitionAuthState, type AuthEvent } from "@/lib/auth-state-machine"; import { createLogger } from "@/lib/logger"; +import { normalizeRelayURL } from "@/lib/relay-url"; import pool from "./relay-pool"; import accountManager from "./accounts"; import db from "./db"; @@ -79,11 +80,19 @@ class RelayStateManager { /** * Ensure a relay is being monitored (call this when adding relays to pool) + * @returns true if relay is being monitored, false if normalization failed */ - ensureRelayMonitored(relayUrl: string) { - const relay = pool.relay(relayUrl); - if (relay && !this.subscriptions.has(relayUrl)) { - this.monitorRelay(relay); + ensureRelayMonitored(relayUrl: string): boolean { + try { + const normalizedUrl = normalizeRelayURL(relayUrl); + const relay = pool.relay(normalizedUrl); + if (relay && !this.subscriptions.has(relay.url)) { + this.monitorRelay(relay); + } + return true; + } catch (error) { + console.error(`Failed to monitor relay ${relayUrl}:`, error); + return false; } } @@ -270,58 +279,71 @@ class RelayStateManager { async getAuthPreference( relayUrl: string, ): Promise { - // Check memory cache first - if (this.authPreferences.has(relayUrl)) { - return this.authPreferences.get(relayUrl); - } + try { + const normalizedUrl = normalizeRelayURL(relayUrl); - // Load from database - const record = await db.relayAuthPreferences.get(relayUrl); - if (record) { - this.authPreferences.set(relayUrl, record.preference); - return record.preference; - } + // Check memory cache first + if (this.authPreferences.has(normalizedUrl)) { + return this.authPreferences.get(normalizedUrl); + } - return undefined; + // Load from database + const record = await db.relayAuthPreferences.get(normalizedUrl); + if (record) { + this.authPreferences.set(normalizedUrl, record.preference); + return record.preference; + } + + return undefined; + } catch (error) { + console.error(`Failed to get auth preference for ${relayUrl}:`, error); + return undefined; + } } /** * Set auth preference for a relay */ async setAuthPreference(relayUrl: string, preference: AuthPreference) { - console.log( - `[RelayStateManager] Setting auth preference for ${relayUrl} to "${preference}"`, - ); - - // Update memory cache - this.authPreferences.set(relayUrl, preference); - - // Save to database try { - await db.relayAuthPreferences.put({ - url: relayUrl, - preference, - updatedAt: Date.now(), - }); + const normalizedUrl = normalizeRelayURL(relayUrl); console.log( - `[RelayStateManager] Successfully saved preference to database`, + `[RelayStateManager] Setting auth preference for ${normalizedUrl} to "${preference}"`, ); - } catch (error) { - console.error( - `[RelayStateManager] Failed to save preference to database:`, - error, - ); - throw error; - } - // Update relay state - const state = this.relayStates.get(relayUrl); - if (state) { - state.authPreference = preference; - this.notifyListeners(); - console.log( - `[RelayStateManager] Updated relay state and notified listeners`, - ); + // Update memory cache + this.authPreferences.set(normalizedUrl, preference); + + // Save to database + try { + await db.relayAuthPreferences.put({ + url: normalizedUrl, + preference, + updatedAt: Date.now(), + }); + console.log( + `[RelayStateManager] Successfully saved preference to database`, + ); + } catch (error) { + console.error( + `[RelayStateManager] Failed to save preference to database:`, + error, + ); + throw error; + } + + // Update relay state + const state = this.relayStates.get(normalizedUrl); + if (state) { + state.authPreference = preference; + this.notifyListeners(); + console.log( + `[RelayStateManager] Updated relay state and notified listeners`, + ); + } + } catch (error) { + console.error(`Failed to set auth preference for ${relayUrl}:`, error); + throw error; } } @@ -329,8 +351,15 @@ class RelayStateManager { * Authenticate with a relay */ async authenticateRelay(relayUrl: string): Promise { - const relay = pool.relay(relayUrl); - const state = this.relayStates.get(relayUrl); + let normalizedUrl: string; + try { + normalizedUrl = normalizeRelayURL(relayUrl); + } catch (error) { + throw new Error(`Invalid relay URL ${relayUrl}: ${error}`); + } + + const relay = pool.relay(normalizedUrl); + const state = this.relayStates.get(relay.url); if (!relay || !state) { throw new Error(`Relay ${relayUrl} not found`); @@ -420,27 +449,32 @@ class RelayStateManager { * Reject authentication for a relay */ rejectAuth(relayUrl: string, rememberForSession = true) { - const state = this.relayStates.get(relayUrl); - if (state) { - // Use state machine for consistent transitions - const transition = transitionAuthState(state.authStatus, { - type: "USER_REJECTED", - }); + try { + const normalizedUrl = normalizeRelayURL(relayUrl); + const state = this.relayStates.get(normalizedUrl); + if (state) { + // Use state machine for consistent transitions + const transition = transitionAuthState(state.authStatus, { + type: "USER_REJECTED", + }); - console.log( - `[RelayStateManager] ${relayUrl} user rejected auth:`, - `${state.authStatus} → ${transition.newStatus}`, - ); + console.log( + `[RelayStateManager] ${relayUrl} user rejected auth:`, + `${state.authStatus} → ${transition.newStatus}`, + ); - state.authStatus = transition.newStatus; - if (transition.clearChallenge) { - state.currentChallenge = undefined; + state.authStatus = transition.newStatus; + if (transition.clearChallenge) { + state.currentChallenge = undefined; + } + + if (rememberForSession) { + this.sessionRejections.add(normalizedUrl); + } + this.notifyListeners(); } - - if (rememberForSession) { - this.sessionRejections.add(relayUrl); - } - this.notifyListeners(); + } catch (error) { + console.error(`Failed to reject auth for ${relayUrl}:`, error); } } @@ -448,18 +482,25 @@ class RelayStateManager { * Check if a relay should be prompted for auth */ shouldPromptAuth(relayUrl: string): boolean { - // Check permanent preferences - const pref = this.authPreferences.get(relayUrl); - if (pref === "never") return false; + try { + const normalizedUrl = normalizeRelayURL(relayUrl); - // Check session rejections - if (this.sessionRejections.has(relayUrl)) return false; + // Check permanent preferences + const pref = this.authPreferences.get(normalizedUrl); + if (pref === "never") return false; - // Don't prompt if already authenticated (unless challenge changes) - const state = this.relayStates.get(relayUrl); - if (state?.authStatus === "authenticated") return false; + // Check session rejections + if (this.sessionRejections.has(normalizedUrl)) return false; - return true; + // Don't prompt if already authenticated (unless challenge changes) + const state = this.relayStates.get(normalizedUrl); + if (state?.authStatus === "authenticated") return false; + + return true; + } catch (error) { + console.error(`Failed to check auth prompt for ${relayUrl}:`, error); + return false; + } } /**