mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-04-12 00:17:02 +02:00
fix: normalize relay URLs
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
119
src/lib/relay-url.test.ts
Normal 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
50
src/lib/relay-url.ts
Normal 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)
|
||||
}`
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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!");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user