feat: filter by arbitrary tag, relay state improvements

This commit is contained in:
Alejandro Gómez
2025-12-13 15:49:04 +01:00
parent a4d9720358
commit 8e92a8ebfb
12 changed files with 1158 additions and 135 deletions

View File

@@ -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 */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button className="text-muted-foreground hover:text-foreground transition-colors">
<Settings className="size-4" />
<button
disabled={isSavingPreference}
className="text-muted-foreground hover:text-foreground transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{isSavingPreference ? (
<Loader2 className="size-4 animate-spin" />
) : (
<Settings className="size-4" />
)}
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
@@ -207,10 +218,22 @@ function RelayCard({ relay }: RelayCardProps) {
<DropdownMenuRadioGroup
value={currentPreference}
onValueChange={async (value) => {
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);
}
}}
>
<DropdownMenuRadioItem value="ask">Ask</DropdownMenuRadioItem>
@@ -229,3 +252,5 @@ function RelayCard({ relay }: RelayCardProps) {
</div>
);
}
export default ConnViewer;

View File

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

View File

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

View File

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

View File

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

62
src/lib/logger.ts Normal file
View File

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

View File

@@ -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([]);

View File

@@ -42,7 +42,7 @@ 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)
* - 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<string>();
const dTags = new Set<string>();
// Map for arbitrary single-letter tags: letter -> Set<value>
const genericTags = new Map<string, Set<string>>();
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 <letter> <value>
// 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<string>();
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,

30
src/lib/type-guards.ts Normal file
View File

@@ -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<string, unknown>;
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";
}

View File

@@ -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<string, AuthPreference> = new Map();
private sessionRejections: Set<string> = 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<string, RelayState> = {};
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();
}
}

View File

@@ -175,6 +175,11 @@ export const manPages: Record<string, ManPageEntry> = {
description:
"Filter by d-tag identifier (replaceable events). Supports comma-separated values: -d article1,article2",
},
{
flag: "-T, --tag <letter> <value>",
description:
"Filter by any single-letter tag (#<letter>). Supports comma-separated values: --tag a val1,val2. Works with any tag (a, r, g, L, etc.)",
},
{
flag: "--since <time>",
description:
@@ -211,6 +216,10 @@ export const manPages: Record<string, ManPageEntry> = {
"req -k 1 --since 1h relay.damus.io Get notes from last hour",
"req -k 1 --close-on-eose Get recent notes and close after EOSE",
"req -t nostr,bitcoin -l 50 Get 50 events tagged #nostr or #bitcoin",
"req --tag a 30023:abc...:article Get events referencing addressable event (#a tag)",
"req -T r https://example.com Get events referencing URL (#r tag)",
"req -k 30023 --tag d article1,article2 Get specific replaceable events by d-tag",
"req --tag g geohash123 -l 20 Get 20 events with geolocation tag",
"req --search bitcoin -k 1 Search notes for 'bitcoin'",
"req -k 1 relay1.com relay2.com Query multiple relays",
],

View File

@@ -26,9 +26,13 @@ export interface RelayNotice {
timestamp: number;
}
export type ErrorType = "network" | "authentication" | "protocol" | "unknown";
export interface RelayError {
message: string;
timestamp: number;
type: ErrorType;
dismissed?: boolean;
}
export interface RelayStats {