mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-04-12 16:37:06 +02:00
feat: filter by arbitrary tag, relay state improvements
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
326
src/lib/auth-state-machine.test.ts
Normal file
326
src/lib/auth-state-machine.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
182
src/lib/auth-state-machine.ts
Normal file
182
src/lib/auth-state-machine.ts
Normal 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
62
src/lib/logger.ts
Normal 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);
|
||||
}
|
||||
@@ -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([]);
|
||||
|
||||
@@ -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
30
src/lib/type-guards.ts
Normal 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";
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
],
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user