From 8e92a8ebfb77f394d3fdf91823e12832fbca4970 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20G=C3=B3mez?= Date: Sat, 13 Dec 2025 15:49:04 +0100 Subject: [PATCH] feat: filter by arbitrary tag, relay state improvements --- src/components/ConnViewer.tsx | 39 ++- src/hooks/useRelayState.ts | 22 +- src/hooks/useReqTimeline.ts | 10 +- src/lib/auth-state-machine.test.ts | 326 +++++++++++++++++++++++ src/lib/auth-state-machine.ts | 182 +++++++++++++ src/lib/logger.ts | 62 +++++ src/lib/req-parser.test.ts | 161 +++++++++++ src/lib/req-parser.ts | 49 +++- src/lib/type-guards.ts | 30 +++ src/services/relay-state-manager.ts | 399 ++++++++++++++++++++-------- src/types/man.ts | 9 + src/types/relay-state.ts | 4 + 12 files changed, 1158 insertions(+), 135 deletions(-) create mode 100644 src/lib/auth-state-machine.test.ts create mode 100644 src/lib/auth-state-machine.ts create mode 100644 src/lib/logger.ts create mode 100644 src/lib/type-guards.ts diff --git a/src/components/ConnViewer.tsx b/src/components/ConnViewer.tsx index 4423ad5..e27cd29 100644 --- a/src/components/ConnViewer.tsx +++ b/src/components/ConnViewer.tsx @@ -1,3 +1,5 @@ +import { useState } from "react"; +import { toast } from "sonner"; import { Wifi, WifiOff, @@ -23,11 +25,12 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from "./ui/dropdown-menu"; +import { isAuthPreference } from "@/lib/type-guards"; /** * CONN viewer - displays connection and auth status for all relays in the pool */ -export default function ConnViewer() { +function ConnViewer() { const { relays } = useRelayState(); const relayList = Object.values(relays); @@ -85,6 +88,7 @@ interface RelayCardProps { function RelayCard({ relay }: RelayCardProps) { const { setAuthPreference } = useRelayState(); + const [isSavingPreference, setIsSavingPreference] = useState(false); const connectionIcon = () => { const iconMap = { @@ -178,8 +182,15 @@ function RelayCard({ relay }: RelayCardProps) { {/* Auth Settings Dropdown */} - @@ -207,10 +218,22 @@ function RelayCard({ relay }: RelayCardProps) { { - await setAuthPreference( - relay.url, - value as "always" | "never" | "ask", - ); + if (!isAuthPreference(value)) { + console.error("Invalid auth preference:", value); + return; + } + + setIsSavingPreference(true); + try { + await setAuthPreference(relay.url, value); + toast.success("Preference saved"); + } catch (error) { + toast.error( + `Failed to save preference: ${error instanceof Error ? error.message : "Unknown error"}`, + ); + } finally { + setIsSavingPreference(false); + } }} > Ask @@ -229,3 +252,5 @@ function RelayCard({ relay }: RelayCardProps) { ); } + +export default ConnViewer; diff --git a/src/hooks/useRelayState.ts b/src/hooks/useRelayState.ts index 4ace3af..d014da7 100644 --- a/src/hooks/useRelayState.ts +++ b/src/hooks/useRelayState.ts @@ -12,6 +12,16 @@ export function useRelayState() { // Subscribe to relay state manager updates useEffect(() => { + // Initialize state immediately if not set (before subscription) + setState((prev) => { + if (prev.relayState) return prev; + return { + ...prev, + relayState: relayStateManager.getState(), + }; + }); + + // Subscribe to updates const unsubscribe = relayStateManager.subscribe((relayState) => { setState((prev) => ({ ...prev, @@ -19,16 +29,10 @@ export function useRelayState() { })); }); - // Initialize state if not set - if (!state.relayState) { - setState((prev) => ({ - ...prev, - relayState: relayStateManager.getState(), - })); - } - return unsubscribe; - }, [setState, state.relayState]); + // Only depend on setState - it's stable from Jotai + // Don't include state.relayState to avoid re-subscription loops + }, [setState]); const relayState = state.relayState; diff --git a/src/hooks/useReqTimeline.ts b/src/hooks/useReqTimeline.ts index f91300e..3afbf08 100644 --- a/src/hooks/useReqTimeline.ts +++ b/src/hooks/useReqTimeline.ts @@ -2,6 +2,7 @@ import { useState, useEffect, useMemo } from "react"; import pool from "@/services/relay-pool"; import type { NostrEvent, Filter } from "nostr-tools"; import { useEventStore } from "applesauce-react/hooks"; +import { isNostrEvent } from "@/lib/type-guards"; interface UseReqTimelineOptions { limit?: number; @@ -84,15 +85,16 @@ export function useReqTimeline( if (!stream) { setLoading(false); } - } else { + } else if (isNostrEvent(response)) { // It's an event - store in memory, deduplicate by ID - const event = response as NostrEvent; - eventStore.add(event); + eventStore.add(response); setEventsMap((prev) => { const next = new Map(prev); - next.set(event.id, event); + next.set(response.id, response); return next; }); + } else { + console.warn("REQ: Unexpected response type:", response); } }, (err: Error) => { diff --git a/src/lib/auth-state-machine.test.ts b/src/lib/auth-state-machine.test.ts new file mode 100644 index 0000000..af604f5 --- /dev/null +++ b/src/lib/auth-state-machine.test.ts @@ -0,0 +1,326 @@ +import { describe, it, expect } from "vitest"; +import { transitionAuthState, type AuthEvent } from "./auth-state-machine"; +import type { AuthStatus } from "@/types/relay-state"; + +describe("Auth State Machine", () => { + describe("none state transitions", () => { + it("should transition to challenge_received when receiving challenge with ask preference", () => { + const result = transitionAuthState("none", { + type: "CHALLENGE_RECEIVED", + challenge: "test-challenge", + preference: "ask", + }); + + expect(result.newStatus).toBe("challenge_received"); + expect(result.shouldAutoAuth).toBe(false); + expect(result.clearChallenge).toBe(false); + }); + + it("should transition to authenticating with always preference", () => { + const result = transitionAuthState("none", { + type: "CHALLENGE_RECEIVED", + challenge: "test-challenge", + preference: "always", + }); + + expect(result.newStatus).toBe("authenticating"); + expect(result.shouldAutoAuth).toBe(true); + expect(result.clearChallenge).toBe(false); + }); + + it("should transition to rejected with never preference", () => { + const result = transitionAuthState("none", { + type: "CHALLENGE_RECEIVED", + challenge: "test-challenge", + preference: "never", + }); + + expect(result.newStatus).toBe("rejected"); + expect(result.shouldAutoAuth).toBe(false); + expect(result.clearChallenge).toBe(true); + }); + + it("should default to ask when no preference provided", () => { + const result = transitionAuthState("none", { + type: "CHALLENGE_RECEIVED", + challenge: "test-challenge", + }); + + expect(result.newStatus).toBe("challenge_received"); + expect(result.shouldAutoAuth).toBe(false); + }); + + it("should not transition on other events", () => { + const result = transitionAuthState("none", { type: "AUTH_SUCCESS" }); + expect(result.newStatus).toBe("none"); + }); + }); + + describe("challenge_received state transitions", () => { + it("should transition to authenticating when user accepts", () => { + const result = transitionAuthState("challenge_received", { + type: "USER_ACCEPTED", + }); + + expect(result.newStatus).toBe("authenticating"); + expect(result.shouldAutoAuth).toBe(false); + expect(result.clearChallenge).toBe(false); + }); + + it("should transition to rejected when user rejects", () => { + const result = transitionAuthState("challenge_received", { + type: "USER_REJECTED", + }); + + expect(result.newStatus).toBe("rejected"); + expect(result.shouldAutoAuth).toBe(false); + expect(result.clearChallenge).toBe(true); + }); + + it("should transition to none when disconnected", () => { + const result = transitionAuthState("challenge_received", { + type: "DISCONNECTED", + }); + + expect(result.newStatus).toBe("none"); + expect(result.clearChallenge).toBe(true); + }); + }); + + describe("authenticating state transitions", () => { + it("should transition to authenticated on success", () => { + const result = transitionAuthState("authenticating", { + type: "AUTH_SUCCESS", + }); + + expect(result.newStatus).toBe("authenticated"); + expect(result.shouldAutoAuth).toBe(false); + expect(result.clearChallenge).toBe(true); + }); + + it("should transition to failed on auth failure", () => { + const result = transitionAuthState("authenticating", { + type: "AUTH_FAILED", + }); + + expect(result.newStatus).toBe("failed"); + expect(result.shouldAutoAuth).toBe(false); + expect(result.clearChallenge).toBe(true); + }); + + it("should transition to none when disconnected", () => { + const result = transitionAuthState("authenticating", { + type: "DISCONNECTED", + }); + + expect(result.newStatus).toBe("none"); + expect(result.clearChallenge).toBe(true); + }); + }); + + describe("authenticated state transitions", () => { + it("should transition to none when disconnected", () => { + const result = transitionAuthState("authenticated", { + type: "DISCONNECTED", + }); + + expect(result.newStatus).toBe("none"); + expect(result.clearChallenge).toBe(true); + }); + + it("should handle new challenge with always preference", () => { + const result = transitionAuthState("authenticated", { + type: "CHALLENGE_RECEIVED", + challenge: "new-challenge", + preference: "always", + }); + + expect(result.newStatus).toBe("authenticating"); + expect(result.shouldAutoAuth).toBe(true); + }); + + it("should transition to challenge_received for new challenge", () => { + const result = transitionAuthState("authenticated", { + type: "CHALLENGE_RECEIVED", + challenge: "new-challenge", + preference: "ask", + }); + + expect(result.newStatus).toBe("challenge_received"); + expect(result.shouldAutoAuth).toBe(false); + }); + + it("should stay authenticated on other events", () => { + const result = transitionAuthState("authenticated", { + type: "AUTH_SUCCESS", + }); + expect(result.newStatus).toBe("authenticated"); + }); + }); + + describe("failed state transitions", () => { + it("should transition to challenge_received on new challenge", () => { + const result = transitionAuthState("failed", { + type: "CHALLENGE_RECEIVED", + challenge: "retry-challenge", + preference: "ask", + }); + + expect(result.newStatus).toBe("challenge_received"); + }); + + it("should auto-auth on new challenge with always preference", () => { + const result = transitionAuthState("failed", { + type: "CHALLENGE_RECEIVED", + challenge: "retry-challenge", + preference: "always", + }); + + expect(result.newStatus).toBe("authenticating"); + expect(result.shouldAutoAuth).toBe(true); + }); + + it("should transition to rejected with never preference", () => { + const result = transitionAuthState("failed", { + type: "CHALLENGE_RECEIVED", + challenge: "retry-challenge", + preference: "never", + }); + + expect(result.newStatus).toBe("rejected"); + }); + + it("should transition to none when disconnected", () => { + const result = transitionAuthState("failed", { + type: "DISCONNECTED", + }); + + expect(result.newStatus).toBe("none"); + }); + }); + + describe("rejected state transitions", () => { + it("should handle new challenge after rejection", () => { + const result = transitionAuthState("rejected", { + type: "CHALLENGE_RECEIVED", + challenge: "new-challenge", + preference: "ask", + }); + + expect(result.newStatus).toBe("challenge_received"); + }); + + it("should transition to none when disconnected", () => { + const result = transitionAuthState("rejected", { + type: "DISCONNECTED", + }); + + expect(result.newStatus).toBe("none"); + }); + }); + + describe("edge cases", () => { + it("should handle missing preference as ask", () => { + const result = transitionAuthState("none", { + type: "CHALLENGE_RECEIVED", + challenge: "test", + }); + + expect(result.newStatus).toBe("challenge_received"); + expect(result.shouldAutoAuth).toBe(false); + }); + + it("should not transition on invalid events for each state", () => { + const states: AuthStatus[] = [ + "none", + "challenge_received", + "authenticating", + "authenticated", + "failed", + "rejected", + ]; + + states.forEach((state) => { + const result = transitionAuthState(state, { + type: "USER_ACCEPTED", + } as AuthEvent); + // Should either stay in same state or have a valid transition + expect(result.newStatus).toBeTruthy(); + }); + }); + }); + + describe("clearChallenge flag", () => { + it("should clear challenge on auth success", () => { + const result = transitionAuthState("authenticating", { + type: "AUTH_SUCCESS", + }); + expect(result.clearChallenge).toBe(true); + }); + + it("should clear challenge on auth failure", () => { + const result = transitionAuthState("authenticating", { + type: "AUTH_FAILED", + }); + expect(result.clearChallenge).toBe(true); + }); + + it("should clear challenge on rejection", () => { + const result = transitionAuthState("challenge_received", { + type: "USER_REJECTED", + }); + expect(result.clearChallenge).toBe(true); + }); + + it("should clear challenge on disconnect", () => { + const result = transitionAuthState("authenticated", { + type: "DISCONNECTED", + }); + expect(result.clearChallenge).toBe(true); + }); + + it("should not clear challenge when receiving new one", () => { + const result = transitionAuthState("none", { + type: "CHALLENGE_RECEIVED", + challenge: "test", + }); + expect(result.clearChallenge).toBe(false); + }); + }); + + describe("shouldAutoAuth flag", () => { + it("should be true only with always preference", () => { + const result = transitionAuthState("none", { + type: "CHALLENGE_RECEIVED", + challenge: "test", + preference: "always", + }); + expect(result.shouldAutoAuth).toBe(true); + }); + + it("should be false with ask preference", () => { + const result = transitionAuthState("none", { + type: "CHALLENGE_RECEIVED", + challenge: "test", + preference: "ask", + }); + expect(result.shouldAutoAuth).toBe(false); + }); + + it("should be false with never preference", () => { + const result = transitionAuthState("none", { + type: "CHALLENGE_RECEIVED", + challenge: "test", + preference: "never", + }); + expect(result.shouldAutoAuth).toBe(false); + }); + + it("should be false on user acceptance (manual auth)", () => { + const result = transitionAuthState("challenge_received", { + type: "USER_ACCEPTED", + }); + expect(result.shouldAutoAuth).toBe(false); + }); + }); +}); diff --git a/src/lib/auth-state-machine.ts b/src/lib/auth-state-machine.ts new file mode 100644 index 0000000..4987f7f --- /dev/null +++ b/src/lib/auth-state-machine.ts @@ -0,0 +1,182 @@ +import type { AuthStatus, AuthPreference } from "@/types/relay-state"; + +/** + * Events that trigger auth state transitions + */ +export type AuthEvent = + | { + type: "CHALLENGE_RECEIVED"; + challenge: string; + preference?: AuthPreference; + } + | { type: "USER_ACCEPTED" } + | { type: "USER_REJECTED" } + | { type: "AUTH_SUCCESS" } + | { type: "AUTH_FAILED" } + | { type: "DISCONNECTED" }; + +/** + * Result of an auth state transition + */ +export interface AuthTransitionResult { + newStatus: AuthStatus; + shouldAutoAuth: boolean; // True if preference is "always" and should auto-authenticate + clearChallenge: boolean; // True if challenge should be cleared +} + +/** + * Pure function implementing the auth state machine + * @param currentStatus - Current auth status + * @param event - Event triggering the transition + * @returns New state and any side effects to perform + */ +export function transitionAuthState( + currentStatus: AuthStatus, + event: AuthEvent, +): AuthTransitionResult { + // Default result - no change + const noChange: AuthTransitionResult = { + newStatus: currentStatus, + shouldAutoAuth: false, + clearChallenge: false, + }; + + switch (currentStatus) { + case "none": + if (event.type === "CHALLENGE_RECEIVED") { + // Check if we should auto-authenticate based on preference + if (event.preference === "always") { + return { + newStatus: "authenticating", + shouldAutoAuth: true, + clearChallenge: false, + }; + } else if (event.preference === "never") { + // Immediately reject if preference is never + return { + newStatus: "rejected", + shouldAutoAuth: false, + clearChallenge: true, + }; + } else { + // Default: ask user + return { + newStatus: "challenge_received", + shouldAutoAuth: false, + clearChallenge: false, + }; + } + } + return noChange; + + case "challenge_received": + switch (event.type) { + case "USER_ACCEPTED": + return { + newStatus: "authenticating", + shouldAutoAuth: false, + clearChallenge: false, + }; + case "USER_REJECTED": + return { + newStatus: "rejected", + shouldAutoAuth: false, + clearChallenge: true, + }; + case "DISCONNECTED": + return { + newStatus: "none", + shouldAutoAuth: false, + clearChallenge: true, + }; + default: + return noChange; + } + + case "authenticating": + switch (event.type) { + case "AUTH_SUCCESS": + return { + newStatus: "authenticated", + shouldAutoAuth: false, + clearChallenge: true, + }; + case "AUTH_FAILED": + return { + newStatus: "failed", + shouldAutoAuth: false, + clearChallenge: true, + }; + case "DISCONNECTED": + return { + newStatus: "none", + shouldAutoAuth: false, + clearChallenge: true, + }; + default: + return noChange; + } + + case "authenticated": + if (event.type === "DISCONNECTED") { + return { + newStatus: "none", + shouldAutoAuth: false, + clearChallenge: true, + }; + } + // If we get a new challenge while authenticated, transition to challenge_received + if (event.type === "CHALLENGE_RECEIVED") { + if (event.preference === "always") { + return { + newStatus: "authenticating", + shouldAutoAuth: true, + clearChallenge: false, + }; + } + return { + newStatus: "challenge_received", + shouldAutoAuth: false, + clearChallenge: false, + }; + } + return noChange; + + case "failed": + case "rejected": + // Can receive new challenge after failure/rejection + if (event.type === "CHALLENGE_RECEIVED") { + if (event.preference === "always") { + return { + newStatus: "authenticating", + shouldAutoAuth: true, + clearChallenge: false, + }; + } else if (event.preference === "never") { + return { + newStatus: "rejected", + shouldAutoAuth: false, + clearChallenge: true, + }; + } + return { + newStatus: "challenge_received", + shouldAutoAuth: false, + clearChallenge: false, + }; + } + if (event.type === "DISCONNECTED") { + return { + newStatus: "none", + shouldAutoAuth: false, + clearChallenge: true, + }; + } + return noChange; + + default: + // Exhaustive check + const _exhaustive: never = currentStatus; + return _exhaustive; + } +} diff --git a/src/lib/logger.ts b/src/lib/logger.ts new file mode 100644 index 0000000..9b98808 --- /dev/null +++ b/src/lib/logger.ts @@ -0,0 +1,62 @@ +/** + * Structured logging utility with log levels + * Only logs debug messages in development mode + */ + +type LogLevel = "debug" | "info" | "warn" | "error"; + +class Logger { + private context: string; + + constructor(context: string) { + this.context = context; + } + + private log(level: LogLevel, message: string, data?: unknown) { + // Format message + const prefix = `[${this.context}]`; + const logMessage = data !== undefined ? [prefix, message, data] : [prefix, message]; + + switch (level) { + case "debug": + // Only log debug in development + if (import.meta.env.DEV) { + console.log(...logMessage); + } + break; + case "info": + console.log(...logMessage); + break; + case "warn": + console.warn(...logMessage); + break; + case "error": + console.error(...logMessage); + // Could send to error tracking service here + break; + } + } + + debug(message: string, data?: unknown) { + this.log("debug", message, data); + } + + info(message: string, data?: unknown) { + this.log("info", message, data); + } + + warn(message: string, data?: unknown) { + this.log("warn", message, data); + } + + error(message: string, error?: unknown) { + this.log("error", message, error); + } +} + +/** + * Create a logger for a specific context + */ +export function createLogger(context: string): Logger { + return new Logger(context); +} diff --git a/src/lib/req-parser.test.ts b/src/lib/req-parser.test.ts index 86787d2..639bfb5 100644 --- a/src/lib/req-parser.test.ts +++ b/src/lib/req-parser.test.ts @@ -311,6 +311,167 @@ describe("parseReqCommand", () => { }); }); + describe("generic tag flag (--tag, -T)", () => { + it("should parse single generic tag", () => { + const result = parseReqCommand(["--tag", "a", "30023:abc:article"]); + expect(result.filter["#a"]).toEqual(["30023:abc:article"]); + }); + + it("should parse short form -T", () => { + const result = parseReqCommand(["-T", "a", "30023:abc:article"]); + expect(result.filter["#a"]).toEqual(["30023:abc:article"]); + }); + + it("should parse comma-separated values", () => { + const result = parseReqCommand([ + "--tag", + "a", + "30023:abc:article1,30023:abc:article2,30023:abc:article3", + ]); + expect(result.filter["#a"]).toEqual([ + "30023:abc:article1", + "30023:abc:article2", + "30023:abc:article3", + ]); + }); + + it("should parse comma-separated values with spaces", () => { + const result = parseReqCommand([ + "--tag", + "a", + "value1, value2, value3", + ]); + expect(result.filter["#a"]).toEqual(["value1", "value2", "value3"]); + }); + + it("should deduplicate values within single tag", () => { + const result = parseReqCommand([ + "--tag", + "a", + "value1,value2,value1,value2", + ]); + expect(result.filter["#a"]).toEqual(["value1", "value2"]); + }); + + it("should accumulate values across multiple --tag flags for same letter", () => { + const result = parseReqCommand([ + "--tag", + "a", + "value1", + "--tag", + "a", + "value2", + "--tag", + "a", + "value3", + ]); + expect(result.filter["#a"]).toEqual(["value1", "value2", "value3"]); + }); + + it("should deduplicate across multiple --tag flags", () => { + const result = parseReqCommand([ + "--tag", + "a", + "value1,value2", + "--tag", + "a", + "value2,value3", + ]); + expect(result.filter["#a"]).toEqual(["value1", "value2", "value3"]); + }); + + it("should handle multiple different generic tags", () => { + const result = parseReqCommand([ + "--tag", + "a", + "address1", + "--tag", + "r", + "https://example.com", + "--tag", + "g", + "geohash123", + ]); + expect(result.filter["#a"]).toEqual(["address1"]); + expect(result.filter["#r"]).toEqual(["https://example.com"]); + expect(result.filter["#g"]).toEqual(["geohash123"]); + }); + + it("should work alongside specific tag flags", () => { + const result = parseReqCommand([ + "-t", + "nostr", + "--tag", + "a", + "30023:abc:article", + "-d", + "article1", + ]); + expect(result.filter["#t"]).toEqual(["nostr"]); + expect(result.filter["#a"]).toEqual(["30023:abc:article"]); + expect(result.filter["#d"]).toEqual(["article1"]); + }); + + it("should not conflict with -a author flag", () => { + const hex = "a".repeat(64); + const result = parseReqCommand([ + "-a", + hex, + "--tag", + "a", + "30023:abc:article", + ]); + expect(result.filter.authors).toEqual([hex]); + expect(result.filter["#a"]).toEqual(["30023:abc:article"]); + }); + + it("should ignore --tag without letter argument", () => { + const result = parseReqCommand(["--tag"]); + expect(result.filter["#a"]).toBeUndefined(); + }); + + it("should ignore --tag without value argument", () => { + const result = parseReqCommand(["--tag", "a"]); + expect(result.filter["#a"]).toBeUndefined(); + }); + + it("should ignore --tag with multi-character letter", () => { + const result = parseReqCommand(["--tag", "abc", "value"]); + expect(result.filter["#abc"]).toBeUndefined(); + }); + + it("should handle empty values in comma-separated list", () => { + const result = parseReqCommand(["--tag", "a", "value1,,value2,,"]); + expect(result.filter["#a"]).toEqual(["value1", "value2"]); + }); + + it("should handle whitespace in comma-separated values", () => { + const result = parseReqCommand([ + "--tag", + "a", + " value1 , value2 , value3 ", + ]); + expect(result.filter["#a"]).toEqual(["value1", "value2", "value3"]); + }); + + it("should support any single-letter tag", () => { + const result = parseReqCommand([ + "--tag", + "x", + "xval", + "--tag", + "y", + "yval", + "--tag", + "z", + "zval", + ]); + expect(result.filter["#x"]).toEqual(["xval"]); + expect(result.filter["#y"]).toEqual(["yval"]); + expect(result.filter["#z"]).toEqual(["zval"]); + }); + }); + describe("edge cases", () => { it("should handle empty args", () => { const result = parseReqCommand([]); diff --git a/src/lib/req-parser.ts b/src/lib/req-parser.ts index 2478de4..42b82bc 100644 --- a/src/lib/req-parser.ts +++ b/src/lib/req-parser.ts @@ -42,7 +42,7 @@ 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) + * - Filters: -k (kinds), -a (authors), -l (limit), -e (#e), -p (#p), -t (#t), -d (#d), --tag/-T (any #tag) * - Time: --since, --until * - Search: --search * - Relays: wss://relay.com or relay.com (auto-adds wss://) @@ -62,6 +62,9 @@ export function parseReqCommand(args: string[]): ParsedReqCommand { const tTags = new Set(); const dTags = new Set(); + // Map for arbitrary single-letter tags: letter -> Set + const genericTags = new Map>(); + let closeOnEose = false; let i = 0; @@ -256,6 +259,43 @@ export function parseReqCommand(args: string[]): ParsedReqCommand { break; } + case "-T": + case "--tag": { + // Generic tag filter: --tag + // Supports comma-separated values: --tag a val1,val2 + if (!nextArg) { + i++; + break; + } + + // Next arg should be the single letter + const letter = nextArg; + const valueArg = args[i + 2]; + + // Validate: must be single letter + if (letter.length !== 1 || !valueArg) { + i++; + break; + } + + // Get or create Set for this tag letter + let tagSet = genericTags.get(letter); + if (!tagSet) { + tagSet = new Set(); + genericTags.set(letter, tagSet); + } + + // Parse comma-separated values + const addedAny = parseCommaSeparated( + valueArg, + (v) => v, // tag values are already strings + tagSet, + ); + + i += addedAny ? 3 : 1; + break; + } + default: i++; break; @@ -273,6 +313,13 @@ export function parseReqCommand(args: string[]): ParsedReqCommand { if (tTags.size > 0) filter["#t"] = Array.from(tTags); if (dTags.size > 0) filter["#d"] = Array.from(dTags); + // Convert generic tags to filter + for (const [letter, tagSet] of genericTags.entries()) { + if (tagSet.size > 0) { + (filter as any)[`#${letter}`] = Array.from(tagSet); + } + } + return { filter, relays: relays.length > 0 ? relays : undefined, diff --git a/src/lib/type-guards.ts b/src/lib/type-guards.ts new file mode 100644 index 0000000..88e0903 --- /dev/null +++ b/src/lib/type-guards.ts @@ -0,0 +1,30 @@ +import type { NostrEvent } from "nostr-tools"; +import type { AuthPreference } from "@/types/relay-state"; + +/** + * Type guard to check if a value is a valid Nostr event + */ +export function isNostrEvent(value: unknown): value is NostrEvent { + if (typeof value !== "object" || value === null) { + return false; + } + + const event = value as Record; + + return ( + typeof event.id === "string" && + typeof event.pubkey === "string" && + typeof event.created_at === "number" && + typeof event.kind === "number" && + Array.isArray(event.tags) && + typeof event.content === "string" && + typeof event.sig === "string" + ); +} + +/** + * Type guard to check if a string is a valid AuthPreference + */ +export function isAuthPreference(value: string): value is AuthPreference { + return value === "always" || value === "never" || value === "ask"; +} diff --git a/src/services/relay-state-manager.ts b/src/services/relay-state-manager.ts index f21e136..10c0744 100644 --- a/src/services/relay-state-manager.ts +++ b/src/services/relay-state-manager.ts @@ -1,16 +1,33 @@ import type { IRelay } from "applesauce-relay"; -import { merge } from "rxjs"; +import { combineLatest, firstValueFrom, race, timer } from "rxjs"; +import { filter, map, startWith } from "rxjs/operators"; import type { RelayState, GlobalRelayState, AuthPreference, } from "@/types/relay-state"; +import { transitionAuthState, type AuthEvent } from "@/lib/auth-state-machine"; +import { createLogger } from "@/lib/logger"; import pool from "./relay-pool"; import accountManager from "./accounts"; import db from "./db"; +const logger = createLogger("RelayStateManager"); + const MAX_NOTICES = 20; const MAX_ERRORS = 20; +const CHALLENGE_TTL = 5 * 60 * 1000; // 5 minutes in milliseconds + +/** + * Observable values emitted by relay observables + * Note: Using startWith() to ensure immediate emission with current values + */ +interface RelayObservableValues { + connected: boolean; + notices: string[]; // notices is an array of strings + challenge: string | null | undefined; // challenge can be null or undefined + authenticated: boolean; +} /** * Singleton service for managing global relay state @@ -23,28 +40,35 @@ class RelayStateManager { private authPreferences: Map = new Map(); private sessionRejections: Set = new Set(); private initialized = false; + private pollingIntervalId?: NodeJS.Timeout; + private lastNotifiedState?: GlobalRelayState; + private stateVersion = 0; constructor() { - this.loadAuthPreferences(); + // Don't perform async operations in constructor + // They will be handled in initialize() } /** * Initialize relay monitoring for all relays in the pool + * Must be called before using the manager */ async initialize() { if (this.initialized) return; - this.initialized = true; - // Load preferences from database + // Load preferences from database BEFORE starting monitoring + // This ensures preferences are available when relays connect await this.loadAuthPreferences(); + this.initialized = true; + // Subscribe to existing relays pool.relays.forEach((relay) => { this.monitorRelay(relay); }); - // Poll for new relays every second - setInterval(() => { + // Poll for new relays every second and store interval ID for cleanup + this.pollingIntervalId = setInterval(() => { pool.relays.forEach((relay) => { if (!this.subscriptions.has(relay.url)) { this.monitorRelay(relay); @@ -74,24 +98,27 @@ class RelayStateManager { this.relayStates.set(url, this.createInitialState(url)); } - // Subscribe to all relay observables - const subscription = merge( - relay.connected$, - relay.notice$, - relay.challenge$, - relay.authenticated$, - ).subscribe(() => { - console.log( - `[RelayStateManager] Observable triggered for ${url}, authenticated = ${relay.authenticated}, challenge = ${relay.challenge}`, - ); - this.updateRelayState(url, relay); + // Subscribe to all relay observables using combineLatest + // startWith ensures immediate emission with current values (critical for BehaviorSubjects) + // This prevents waiting for all observables to naturally emit + const subscription = combineLatest({ + connected: relay.connected$.pipe(startWith(relay.connected)), + notices: relay.notice$.pipe( + startWith(Array.isArray(relay.notices) ? relay.notices : []), + map(notice => Array.isArray(notice) ? notice : (notice ? [notice] : [])) + ), + challenge: relay.challenge$.pipe(startWith(relay.challenge)), + authenticated: relay.authenticated$.pipe(startWith(relay.authenticated)), + }).subscribe((values) => { + logger.debug(`Observable triggered for ${url}`, { + authenticated: values.authenticated, + challenge: values.challenge ? "present" : "none", + }); + this.updateRelayState(url, values); }); // Store cleanup function this.subscriptions.set(url, () => subscription.unsubscribe()); - - // Initial state update - this.updateRelayState(url, relay); } /** @@ -114,9 +141,11 @@ class RelayStateManager { } /** - * Update relay state based on current observable values + * Update relay state based on observable values + * @param url - Relay URL + * @param values - Current values emitted by relay observables */ - private updateRelayState(url: string, relay: IRelay) { + private updateRelayState(url: string, values: RelayObservableValues) { const state = this.relayStates.get(url); if (!state) return; @@ -124,7 +153,7 @@ class RelayStateManager { // Update connection state const wasConnected = state.connectionState === "connected"; - const isConnected = relay.connected; + const isConnected = values.connected; if (isConnected && !wasConnected) { state.connectionState = "connected"; @@ -145,79 +174,83 @@ class RelayStateManager { state.connectionState = "disconnected"; } - // Update auth status - const challenge = relay.challenge; - // Use the getter property instead of observable value - const isAuthenticated = relay.authenticated; + // Update auth status using state machine + const challenge = values.challenge; + const isAuthenticated = values.authenticated; - if (isAuthenticated === true) { - // Successfully authenticated - this takes priority over everything - if (state.authStatus !== "authenticated") { - console.log( - `[RelayStateManager] ${url} authenticated (was: ${state.authStatus})`, - ); - state.authStatus = "authenticated"; + // Determine auth events based on observable values + let authEvent: AuthEvent | null = null; + + // Priority 1: Disconnection (handled above, but check here too) + if (!isConnected && wasConnected) { + authEvent = { type: "DISCONNECTED" }; + } + // Priority 2: Authentication success + else if (isAuthenticated === true && state.authStatus !== "authenticated") { + authEvent = { type: "AUTH_SUCCESS" }; + } + // Priority 3: New challenge (or challenge change) + else if ( + challenge && + (!state.currentChallenge || state.currentChallenge.challenge !== challenge) + ) { + const preference = this.authPreferences.get(url); + authEvent = { type: "CHALLENGE_RECEIVED", challenge, preference }; + } + // Priority 4: Challenge cleared (authentication may have failed) + else if ( + !challenge && + !isAuthenticated && + (state.authStatus === "authenticating" || + state.authStatus === "challenge_received") + ) { + authEvent = { type: "AUTH_FAILED" }; + } + + // Apply state machine transition if we have an event + if (authEvent) { + const transition = transitionAuthState(state.authStatus, authEvent); + + logger.info(`${url} auth transition: ${state.authStatus} → ${transition.newStatus}`, { + event: authEvent.type, + }); + + // Update state + state.authStatus = transition.newStatus; + + // Update challenge + if (transition.clearChallenge) { + state.currentChallenge = undefined; + } else if (authEvent.type === "CHALLENGE_RECEIVED") { + state.currentChallenge = { + challenge: authEvent.challenge, + receivedAt: now, + }; + } + + // Handle side effects + if (transition.newStatus === "authenticated") { state.lastAuthenticated = now; state.stats.authSuccessCount++; } - state.currentChallenge = undefined; - } else if (challenge) { - // Challenge received - if (state.authStatus !== "authenticating") { - // Only update to challenge_received if not already authenticating - if ( - !state.currentChallenge || - state.currentChallenge.challenge !== challenge - ) { - console.log(`[RelayStateManager] ${url} challenge received`); - state.currentChallenge = { - challenge, - receivedAt: now, - }; - // Check if we should auto-authenticate - const preference = this.authPreferences.get(url); - if (preference === "always") { - console.log( - `[RelayStateManager] ${url} has "always" preference, auto-authenticating`, - ); - state.authStatus = "authenticating"; - // Trigger authentication asynchronously - this.authenticateRelay(url).catch((error) => { - console.error( - `[RelayStateManager] Auto-auth failed for ${url}:`, - error, - ); - }); - } else { - state.authStatus = "challenge_received"; - } - } - } - // If we're authenticating and there's still a challenge, keep authenticating status - } else { - // No challenge and not authenticated - if (state.currentChallenge || state.authStatus === "authenticating") { - // Challenge was cleared or authentication didn't result in authenticated status - if (state.authStatus === "authenticating") { - // Authentication failed - console.log( - `[RelayStateManager] ${url} auth failed - no challenge and not authenticated`, + if (transition.shouldAutoAuth) { + console.log( + `[RelayStateManager] ${url} auto-authenticating (preference="always")`, + ); + // Trigger authentication asynchronously + this.authenticateRelay(url).catch((error) => { + console.error( + `[RelayStateManager] Auto-auth failed for ${url}:`, + error, ); - state.authStatus = "failed"; - } else if (state.authStatus === "challenge_received") { - // Challenge was dismissed/rejected - console.log(`[RelayStateManager] ${url} challenge rejected`); - state.authStatus = "rejected"; - } - state.currentChallenge = undefined; + }); } } // Add notices (bounded array) - const notices = relay.notices; - if (notices.length > 0) { - const notice = notices[0]; + if (values.notices && values.notices.length > 0) { + const notice = values.notices[0]; const lastNotice = state.notices[0]; if (!lastNotice || lastNotice.message !== notice) { state.notices.unshift({ message: notice, timestamp: now }); @@ -319,29 +352,62 @@ class RelayStateManager { this.notifyListeners(); try { - console.log(`[RelayStateManager] Authenticating with ${relayUrl}...`); - console.log( - `[RelayStateManager] Before auth - authenticated = ${relay.authenticated}, challenge = ${relay.challenge}`, - ); + logger.info(`Authenticating with ${relayUrl}`); + + // Start authentication await relay.authenticate(account); - console.log( - `[RelayStateManager] After auth - authenticated = ${relay.authenticated}, challenge = ${relay.challenge}`, + + // Wait for authenticated$ observable to emit true or timeout after 5 seconds + // This ensures we get the actual result from the relay, not a race condition + const authResult = await firstValueFrom( + race([ + relay.authenticated$.pipe( + filter((authenticated) => authenticated === true), + map(() => true), + ), + timer(5000).pipe(map(() => false)), + ]), ); - // Wait a bit for the observable to update - await new Promise((resolve) => setTimeout(resolve, 200)); - console.log( - `[RelayStateManager] After delay - authenticated = ${relay.authenticated}, challenge = ${relay.challenge}`, - ); + if (!authResult) { + throw new Error("Authentication timeout - relay did not respond"); + } - // Force immediate state update after authentication - this.updateRelayState(relayUrl, relay); + logger.info(`Successfully authenticated with ${relayUrl}`); + // State will be updated automatically by the combineLatest subscription } catch (error) { state.authStatus = "failed"; + + // Extract error message properly + const errorMessage = + error instanceof Error ? error.message : String(error); + + // Categorize error type + let errorType: "network" | "authentication" | "protocol" | "unknown" = + "unknown"; + if ( + errorMessage.includes("timeout") || + errorMessage.includes("network") + ) { + errorType = "network"; + } else if ( + errorMessage.includes("auth") || + errorMessage.includes("sign") + ) { + errorType = "authentication"; + } else if ( + errorMessage.includes("protocol") || + errorMessage.includes("invalid") + ) { + errorType = "protocol"; + } + state.errors.unshift({ - message: `Auth failed: ${error}`, + message: `Authentication failed: ${errorMessage}`, timestamp: Date.now(), + type: errorType, }); + if (state.errors.length > MAX_ERRORS) { state.errors = state.errors.slice(0, MAX_ERRORS); } @@ -356,8 +422,21 @@ class RelayStateManager { rejectAuth(relayUrl: string, rememberForSession = true) { const state = this.relayStates.get(relayUrl); if (state) { - state.authStatus = "rejected"; - state.currentChallenge = undefined; + // 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}`, + ); + + state.authStatus = transition.newStatus; + if (transition.clearChallenge) { + state.currentChallenge = undefined; + } + if (rememberForSession) { this.sessionRejections.add(relayUrl); } @@ -383,21 +462,51 @@ class RelayStateManager { return true; } + /** + * Check if a challenge has expired + */ + private isChallengeExpired(receivedAt: number): boolean { + return Date.now() - receivedAt > CHALLENGE_TTL; + } + /** * Get current global state */ getState(): GlobalRelayState { const relays: Record = {}; this.relayStates.forEach((state, url) => { - relays[url] = state; + // Create shallow copy to avoid mutation issues in hasStateChanged + relays[url] = { ...state }; }); const pendingChallenges = Array.from(this.relayStates.values()) - .filter( - (state) => + .filter((state) => { + // Only include non-expired challenges + if ( state.authStatus === "challenge_received" && - this.shouldPromptAuth(state.url), - ) + state.currentChallenge && + !this.isChallengeExpired(state.currentChallenge.receivedAt) && + this.shouldPromptAuth(state.url) + ) { + return true; + } + + // Clear expired challenges + if ( + state.currentChallenge && + this.isChallengeExpired(state.currentChallenge.receivedAt) + ) { + console.log( + `[RelayStateManager] Challenge expired for ${state.url}`, + ); + state.currentChallenge = undefined; + if (state.authStatus === "challenge_received") { + state.authStatus = "none"; + } + } + + return false; + }) .map((state) => ({ relayUrl: state.url, challenge: state.currentChallenge!.challenge, @@ -427,11 +536,66 @@ class RelayStateManager { } /** - * Notify all listeners of state change + * Check if state has actually changed (to avoid unnecessary re-renders) + */ + private hasStateChanged(newState: GlobalRelayState): boolean { + if (!this.lastNotifiedState) return true; + + const prev = this.lastNotifiedState; + + // Check if relay count changed + const prevRelayUrls = Object.keys(prev.relays); + const newRelayUrls = Object.keys(newState.relays); + if (prevRelayUrls.length !== newRelayUrls.length) return true; + + // Check if any relay state changed (shallow comparison) + for (const url of newRelayUrls) { + const prevRelay = prev.relays[url]; + const newRelay = newState.relays[url]; + + // Relay added or removed + if (!prevRelay || !newRelay) return true; + + // Check important fields for changes + if ( + prevRelay.connectionState !== newRelay.connectionState || + prevRelay.authStatus !== newRelay.authStatus || + prevRelay.authPreference !== newRelay.authPreference || + prevRelay.currentChallenge?.challenge !== + newRelay.currentChallenge?.challenge || + prevRelay.notices.length !== newRelay.notices.length || + prevRelay.errors.length !== newRelay.errors.length + ) { + return true; + } + } + + // Check pending challenges (length and URLs) + if ( + prev.pendingChallenges.length !== newState.pendingChallenges.length || + prev.pendingChallenges.some( + (c, i) => c.relayUrl !== newState.pendingChallenges[i]?.relayUrl, + ) + ) { + return true; + } + + // No significant changes detected + return false; + } + + /** + * Notify all listeners of state change (only if state actually changed) */ private notifyListeners() { const state = this.getState(); - this.listeners.forEach((listener) => listener(state)); + + // Only notify if state has actually changed + if (this.hasStateChanged(state)) { + this.stateVersion++; + this.lastNotifiedState = state; + this.listeners.forEach((listener) => listener(state)); + } } /** @@ -443,20 +607,27 @@ class RelayStateManager { allPrefs.forEach((record) => { this.authPreferences.set(record.url, record.preference); }); - console.log( - `[RelayStateManager] Loaded ${allPrefs.length} auth preferences from database`, - ); + logger.info(`Loaded ${allPrefs.length} auth preferences from database`); } catch (error) { - console.warn("Failed to load auth preferences:", error); + logger.warn("Failed to load auth preferences", error); } } /** - * Cleanup all subscriptions + * Cleanup all subscriptions and intervals */ destroy() { + // Clear polling interval + if (this.pollingIntervalId) { + clearInterval(this.pollingIntervalId); + this.pollingIntervalId = undefined; + } + + // Unsubscribe from all relay observables this.subscriptions.forEach((unsubscribe) => unsubscribe()); this.subscriptions.clear(); + + // Clear all listeners this.listeners.clear(); } } diff --git a/src/types/man.ts b/src/types/man.ts index ecf8d09..4b8da44 100644 --- a/src/types/man.ts +++ b/src/types/man.ts @@ -175,6 +175,11 @@ export const manPages: Record = { description: "Filter by d-tag identifier (replaceable events). Supports comma-separated values: -d article1,article2", }, + { + flag: "-T, --tag ", + description: + "Filter by any single-letter tag (#). Supports comma-separated values: --tag a val1,val2. Works with any tag (a, r, g, L, etc.)", + }, { flag: "--since