fix: normalize relay URLs

This commit is contained in:
Alejandro Gómez
2025-12-14 11:51:02 +01:00
parent 33614be77d
commit bef7369de9
17 changed files with 756 additions and 161 deletions

View File

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

View File

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

View File

@@ -473,21 +473,24 @@ export default function ReqViewer({
{/* Results */}
<div className="flex-1 overflow-y-auto">
{loading && events.length === 0 && (
{/* Loading: Before EOSE received */}
{loading && events.length === 0 && !eoseReceived && (
<div className="text-center text-muted-foreground font-mono text-sm p-4">
Loading events...
</div>
)}
{!loading && !stream && events.length === 0 && !error && (
{/* EOSE received, no events, not streaming */}
{eoseReceived && events.length === 0 && !stream && !error && (
<div className="text-center text-muted-foreground font-mono text-sm p-4">
No events found matching filter
</div>
)}
{stream && events.length === 0 && !loading && (
{/* EOSE received, no events, streaming (live mode) */}
{eoseReceived && events.length === 0 && stream && (
<div className="text-center text-muted-foreground font-mono text-sm p-4">
Waiting for events...
Listening for new events...
</div>
)}

View File

@@ -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 (
<div
className={cn(
@@ -63,7 +82,37 @@ export function RelayLink({
className={cn("size-3 flex-shrink-0 rounded-sm", iconClassname)}
/>
)}
<span className={cn("text-xs truncate", urlClassname)}>{url}</span>
{isInsecure && (
<HoverCard openDelay={200}>
<HoverCardTrigger asChild>
<div className="cursor-help">
<ShieldAlert
className={cn(
"size-3 text-amber-600 dark:text-amber-500 flex-shrink-0",
iconClassname,
)}
/>
</div>
</HoverCardTrigger>
<HoverCardContent
side="top"
className="w-64 text-xs"
onClick={(e) => e.stopPropagation()}
>
<div className="space-y-1">
<div className="font-semibold">Insecure Connection</div>
<p className="text-muted-foreground">
This relay uses unencrypted ws:// protocol. This is typically
only safe for localhost/development. Production relays should
use wss:// (secure WebSocket).
</p>
</div>
</HoverCardContent>
</HoverCard>
)}
<span className={cn("text-xs truncate", urlClassname)}>
{displayUrl}
</span>
</div>
<div className="flex items-center gap-1 flex-shrink-0">
{showInboxOutbox && read && (

View File

@@ -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<string>();
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,
};

View File

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

View File

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

View File

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

View File

@@ -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<RelayInformation | null> {
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<RelayInformation | null> {
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<RelayInformation | null> {
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<Map<string, RelayInformation>> {
const results = new Map<string, RelayInformation>();
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<void> {
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<boolean> {
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;
}
}

View File

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

View File

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

119
src/lib/relay-url.test.ts Normal file
View File

@@ -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=<script>alert('xss')</script>");
expect(result).toContain("wss://relay.example.com/");
// Note: URL encoding is handled by browser's URL parsing
});
});
});

50
src/lib/relay-url.ts Normal file
View File

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

View File

@@ -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", () => {

View File

@@ -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<T>(
/**
* 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 };
}
/**

View File

@@ -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<RelayAuthPreference>("relayAuthPreferences").toArray();
const normalizedAuthPrefs = new Map<string, RelayAuthPreference>();
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>("relayInfo").toArray();
const normalizedRelayInfos = new Map<string, RelayInfo>();
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!");
});
}
}

View File

@@ -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<AuthPreference | undefined> {
// 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<void> {
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;
}
}
/**