From 13749d91e95b59af6a6e0d755fe05cafd70ffde8 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 13 Feb 2026 10:06:35 +0000 Subject: [PATCH] refactor: extract relay auth manager into standalone package Decouple NIP-42 relay authentication from Grimoire internals into a generic, framework-agnostic package at packages/relay-auth-manager/. The new package uses dependency injection for pool, signer, and storage (localStorage-like interface), making it reusable in any applesauce-based app. Fixes the bug where auth prompts appeared even when the signer couldn't sign events - now only emits pending challenges when a signer is available. Key changes: - New package with RelayAuthManager class, pure auth state machine, and comprehensive test suite (103 tests) - Grimoire's relay-state-manager now delegates all auth logic to the package, retaining only connection/notice tracking - Auth preferences moved from Dexie to localStorage via pluggable storage - Reactive signer lifecycle: auto-auth re-evaluates when signer appears - Pool relay lifecycle via add$/remove$ observables (no polling) https://claude.ai/code/session_01XqrjeQVtJKw9uC1XAw6rqd --- package-lock.json | 13 + package.json | 3 + packages/relay-auth-manager/package.json | 13 + .../src/__tests__}/auth-state-machine.test.ts | 288 ++++-- .../src/__tests__/relay-auth-manager.test.ts | 961 ++++++++++++++++++ .../src}/auth-state-machine.ts | 32 +- packages/relay-auth-manager/src/index.ts | 14 + .../src/relay-auth-manager.ts | 471 +++++++++ packages/relay-auth-manager/src/types.ts | 105 ++ packages/relay-auth-manager/tsconfig.json | 15 + src/components/ConnViewer.tsx | 13 +- src/components/GlobalAuthPrompt.tsx | 4 +- src/components/layouts/AppShell.tsx | 4 +- src/hooks/useRelayState.ts | 14 +- src/services/relay-auth.ts | 32 + src/services/relay-state-manager.ts | 457 ++------- 16 files changed, 1923 insertions(+), 516 deletions(-) create mode 100644 packages/relay-auth-manager/package.json rename {src/lib => packages/relay-auth-manager/src/__tests__}/auth-state-machine.test.ts (53%) create mode 100644 packages/relay-auth-manager/src/__tests__/relay-auth-manager.test.ts rename {src/lib => packages/relay-auth-manager/src}/auth-state-machine.ts (84%) create mode 100644 packages/relay-auth-manager/src/index.ts create mode 100644 packages/relay-auth-manager/src/relay-auth-manager.ts create mode 100644 packages/relay-auth-manager/src/types.ts create mode 100644 packages/relay-auth-manager/tsconfig.json create mode 100644 src/services/relay-auth.ts diff --git a/package-lock.json b/package-lock.json index b5615be..114e789 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,9 @@ "name": "grimoire", "version": "0.1.0", "license": "MIT", + "workspaces": [ + "packages/*" + ], "dependencies": { "@fiatjaf/git-natural-api": "npm:@jsr/fiatjaf__git-natural-api@^0.2.3", "@radix-ui/react-accordion": "^1.2.12", @@ -11521,6 +11524,10 @@ "integrity": "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==", "license": "MIT" }, + "node_modules/relay-auth-manager": { + "resolved": "packages/relay-auth-manager", + "link": true + }, "node_modules/remark": { "version": "15.0.1", "resolved": "https://registry.npmjs.org/remark/-/remark-15.0.1.tgz", @@ -13308,6 +13315,12 @@ "type": "github", "url": "https://github.com/sponsors/wooorm" } + }, + "packages/relay-auth-manager": { + "version": "0.1.0", + "peerDependencies": { + "rxjs": "^7.0.0" + } } } } diff --git a/package.json b/package.json index 4cf5d98..5228fa3 100644 --- a/package.json +++ b/package.json @@ -4,6 +4,9 @@ "version": "0.1.0", "type": "module", "license": "MIT", + "workspaces": [ + "packages/*" + ], "scripts": { "dev": "vite", "build": "tsc -b && vite build", diff --git a/packages/relay-auth-manager/package.json b/packages/relay-auth-manager/package.json new file mode 100644 index 0000000..7f6ab86 --- /dev/null +++ b/packages/relay-auth-manager/package.json @@ -0,0 +1,13 @@ +{ + "name": "relay-auth-manager", + "version": "0.1.0", + "type": "module", + "main": "src/index.ts", + "types": "src/index.ts", + "exports": { + ".": "./src/index.ts" + }, + "peerDependencies": { + "rxjs": "^7.0.0" + } +} diff --git a/src/lib/auth-state-machine.test.ts b/packages/relay-auth-manager/src/__tests__/auth-state-machine.test.ts similarity index 53% rename from src/lib/auth-state-machine.test.ts rename to packages/relay-auth-manager/src/__tests__/auth-state-machine.test.ts index af604f5..e15330a 100644 --- a/src/lib/auth-state-machine.test.ts +++ b/packages/relay-auth-manager/src/__tests__/auth-state-machine.test.ts @@ -1,16 +1,15 @@ import { describe, it, expect } from "vitest"; -import { transitionAuthState, type AuthEvent } from "./auth-state-machine"; -import type { AuthStatus } from "@/types/relay-state"; +import { transitionAuthState, type AuthEvent } from "../auth-state-machine.js"; +import type { AuthStatus } from "../types.js"; describe("Auth State Machine", () => { - describe("none state transitions", () => { - it("should transition to challenge_received when receiving challenge with ask preference", () => { + describe("none state", () => { + it("should transition to challenge_received on 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); @@ -22,7 +21,6 @@ describe("Auth State Machine", () => { challenge: "test-challenge", preference: "always", }); - expect(result.newStatus).toBe("authenticating"); expect(result.shouldAutoAuth).toBe(true); expect(result.clearChallenge).toBe(false); @@ -34,7 +32,6 @@ describe("Auth State Machine", () => { challenge: "test-challenge", preference: "never", }); - expect(result.newStatus).toBe("rejected"); expect(result.shouldAutoAuth).toBe(false); expect(result.clearChallenge).toBe(true); @@ -45,127 +42,181 @@ describe("Auth State Machine", () => { type: "CHALLENGE_RECEIVED", challenge: "test-challenge", }); - expect(result.newStatus).toBe("challenge_received"); expect(result.shouldAutoAuth).toBe(false); }); - it("should not transition on other events", () => { + it("should ignore AUTH_SUCCESS event", () => { const result = transitionAuthState("none", { type: "AUTH_SUCCESS" }); expect(result.newStatus).toBe("none"); }); + + it("should ignore AUTH_FAILED event", () => { + const result = transitionAuthState("none", { type: "AUTH_FAILED" }); + expect(result.newStatus).toBe("none"); + }); + + it("should ignore USER_ACCEPTED event", () => { + const result = transitionAuthState("none", { type: "USER_ACCEPTED" }); + expect(result.newStatus).toBe("none"); + }); + + it("should ignore USER_REJECTED event", () => { + const result = transitionAuthState("none", { type: "USER_REJECTED" }); + expect(result.newStatus).toBe("none"); + }); + + it("should ignore DISCONNECTED event", () => { + const result = transitionAuthState("none", { type: "DISCONNECTED" }); + expect(result.newStatus).toBe("none"); + }); }); - describe("challenge_received state transitions", () => { - it("should transition to authenticating when user accepts", () => { + describe("challenge_received state", () => { + it("should transition to authenticating on USER_ACCEPTED", () => { 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", () => { + it("should transition to rejected on USER_REJECTED", () => { 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", () => { + it("should transition to none on DISCONNECTED", () => { const result = transitionAuthState("challenge_received", { type: "DISCONNECTED", }); - expect(result.newStatus).toBe("none"); expect(result.clearChallenge).toBe(true); }); + + it("should transition to authenticated on AUTH_SUCCESS (relay confirmed auth while prompt showing)", () => { + const result = transitionAuthState("challenge_received", { + type: "AUTH_SUCCESS", + }); + expect(result.newStatus).toBe("authenticated"); + expect(result.clearChallenge).toBe(true); + }); + + it("should ignore AUTH_FAILED event", () => { + const result = transitionAuthState("challenge_received", { + type: "AUTH_FAILED", + }); + expect(result.newStatus).toBe("challenge_received"); + }); }); - describe("authenticating state transitions", () => { - it("should transition to authenticated on success", () => { + describe("authenticating state", () => { + it("should transition to authenticated on AUTH_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", () => { + it("should transition to failed on AUTH_FAILED", () => { 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", () => { + it("should transition to none on DISCONNECTED", () => { const result = transitionAuthState("authenticating", { type: "DISCONNECTED", }); - expect(result.newStatus).toBe("none"); expect(result.clearChallenge).toBe(true); }); + + it("should ignore USER_ACCEPTED event", () => { + const result = transitionAuthState("authenticating", { + type: "USER_ACCEPTED", + }); + expect(result.newStatus).toBe("authenticating"); + }); + + it("should ignore CHALLENGE_RECEIVED event", () => { + const result = transitionAuthState("authenticating", { + type: "CHALLENGE_RECEIVED", + challenge: "new", + }); + expect(result.newStatus).toBe("authenticating"); + }); }); - describe("authenticated state transitions", () => { - it("should transition to none when disconnected", () => { + describe("authenticated state", () => { + it("should transition to none on 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", () => { + it("should handle new challenge with always preference (re-auth)", () => { 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", () => { + it("should transition to challenge_received for new challenge with ask preference", () => { 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", () => { + it("should transition to challenge_received for new challenge with no preference", () => { + const result = transitionAuthState("authenticated", { + type: "CHALLENGE_RECEIVED", + challenge: "new-challenge", + }); + expect(result.newStatus).toBe("challenge_received"); + }); + + it("should stay authenticated on AUTH_SUCCESS", () => { const result = transitionAuthState("authenticated", { type: "AUTH_SUCCESS", }); expect(result.newStatus).toBe("authenticated"); }); + + it("should stay authenticated on USER_ACCEPTED", () => { + const result = transitionAuthState("authenticated", { + type: "USER_ACCEPTED", + }); + expect(result.newStatus).toBe("authenticated"); + }); }); - describe("failed state transitions", () => { + describe("failed state", () => { 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"); }); @@ -175,7 +226,6 @@ describe("Auth State Machine", () => { challenge: "retry-challenge", preference: "always", }); - expect(result.newStatus).toBe("authenticating"); expect(result.shouldAutoAuth).toBe(true); }); @@ -186,113 +236,149 @@ describe("Auth State Machine", () => { challenge: "retry-challenge", preference: "never", }); - expect(result.newStatus).toBe("rejected"); + expect(result.clearChallenge).toBe(true); }); - it("should transition to none when disconnected", () => { + it("should transition to none on DISCONNECTED", () => { const result = transitionAuthState("failed", { type: "DISCONNECTED", }); - expect(result.newStatus).toBe("none"); }); + + it("should ignore USER_ACCEPTED event", () => { + const result = transitionAuthState("failed", { + type: "USER_ACCEPTED", + }); + expect(result.newStatus).toBe("failed"); + }); }); - describe("rejected state transitions", () => { + describe("rejected state", () => { 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", () => { + it("should auto-auth on new challenge with always preference", () => { + const result = transitionAuthState("rejected", { + type: "CHALLENGE_RECEIVED", + challenge: "new-challenge", + preference: "always", + }); + expect(result.newStatus).toBe("authenticating"); + expect(result.shouldAutoAuth).toBe(true); + }); + + it("should stay rejected with never preference", () => { + const result = transitionAuthState("rejected", { + type: "CHALLENGE_RECEIVED", + challenge: "new-challenge", + preference: "never", + }); + expect(result.newStatus).toBe("rejected"); + expect(result.clearChallenge).toBe(true); + }); + + it("should transition to none on 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", () => { + it("should clear on auth success", () => { const result = transitionAuthState("authenticating", { type: "AUTH_SUCCESS", }); expect(result.clearChallenge).toBe(true); }); - it("should clear challenge on auth failure", () => { + it("should clear on auth failure", () => { const result = transitionAuthState("authenticating", { type: "AUTH_FAILED", }); expect(result.clearChallenge).toBe(true); }); - it("should clear challenge on rejection", () => { + it("should clear on user 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 clear on disconnect from any auth state", () => { + const statesWithChallenge: AuthStatus[] = [ + "challenge_received", + "authenticating", + "authenticated", + "failed", + "rejected", + ]; + for (const status of statesWithChallenge) { + const result = transitionAuthState(status, { type: "DISCONNECTED" }); + expect(result.clearChallenge).toBe(true); + } }); - it("should not clear challenge when receiving new one", () => { + it("should not clear when receiving new challenge", () => { 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", () => { + it("should clear when never preference auto-rejects", () => { const result = transitionAuthState("none", { type: "CHALLENGE_RECEIVED", challenge: "test", + preference: "never", + }); + expect(result.clearChallenge).toBe(true); + }); + }); + + describe("shouldAutoAuth flag", () => { + it("should be true only with always preference from none", () => { + const result = transitionAuthState("none", { + type: "CHALLENGE_RECEIVED", + challenge: "test", + preference: "always", + }); + expect(result.shouldAutoAuth).toBe(true); + }); + + it("should be true with always preference from failed", () => { + const result = transitionAuthState("failed", { + type: "CHALLENGE_RECEIVED", + challenge: "test", + preference: "always", + }); + expect(result.shouldAutoAuth).toBe(true); + }); + + it("should be true with always preference from rejected", () => { + const result = transitionAuthState("rejected", { + type: "CHALLENGE_RECEIVED", + challenge: "test", + preference: "always", + }); + expect(result.shouldAutoAuth).toBe(true); + }); + + it("should be true with always preference from authenticated", () => { + const result = transitionAuthState("authenticated", { + type: "CHALLENGE_RECEIVED", + challenge: "new", preference: "always", }); expect(result.shouldAutoAuth).toBe(true); @@ -316,11 +402,43 @@ describe("Auth State Machine", () => { expect(result.shouldAutoAuth).toBe(false); }); - it("should be false on user acceptance (manual auth)", () => { + it("should be false on manual user acceptance", () => { const result = transitionAuthState("challenge_received", { type: "USER_ACCEPTED", }); expect(result.shouldAutoAuth).toBe(false); }); }); + + describe("exhaustive state coverage", () => { + const allStates: AuthStatus[] = [ + "none", + "challenge_received", + "authenticating", + "authenticated", + "failed", + "rejected", + ]; + const allEvents: AuthEvent[] = [ + { type: "CHALLENGE_RECEIVED", challenge: "test" }, + { type: "CHALLENGE_RECEIVED", challenge: "test", preference: "always" }, + { type: "CHALLENGE_RECEIVED", challenge: "test", preference: "never" }, + { type: "USER_ACCEPTED" }, + { type: "USER_REJECTED" }, + { type: "AUTH_SUCCESS" }, + { type: "AUTH_FAILED" }, + { type: "DISCONNECTED" }, + ]; + + it("should return a valid status for every state/event combination", () => { + for (const state of allStates) { + for (const event of allEvents) { + const result = transitionAuthState(state, event); + expect(allStates).toContain(result.newStatus); + expect(typeof result.shouldAutoAuth).toBe("boolean"); + expect(typeof result.clearChallenge).toBe("boolean"); + } + } + }); + }); }); diff --git a/packages/relay-auth-manager/src/__tests__/relay-auth-manager.test.ts b/packages/relay-auth-manager/src/__tests__/relay-auth-manager.test.ts new file mode 100644 index 0000000..cd93a72 --- /dev/null +++ b/packages/relay-auth-manager/src/__tests__/relay-auth-manager.test.ts @@ -0,0 +1,961 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { BehaviorSubject, Subject } from "rxjs"; +import { RelayAuthManager } from "../relay-auth-manager.js"; +import type { + AuthRelay, + AuthRelayPool, + AuthSigner, + AuthPreferenceStorage, + RelayAuthManagerOptions, +} from "../types.js"; + +// --- Test helpers --- + +function createMockRelay(url: string): AuthRelay & { + connected$: BehaviorSubject; + challenge$: BehaviorSubject; + authenticated$: BehaviorSubject; +} { + return { + url, + connected: false, + authenticated: false, + challenge: null, + connected$: new BehaviorSubject(false), + challenge$: new BehaviorSubject(null), + authenticated$: new BehaviorSubject(false), + authenticate: vi.fn().mockResolvedValue({ ok: true }), + }; +} + +function createMockPool( + relays: Map> = new Map(), +): AuthRelayPool & { + add$: Subject; + remove$: Subject; +} { + const add$ = new Subject(); + const remove$ = new Subject(); + + return { + relay: (url: string) => { + let r = relays.get(url); + if (!r) { + r = createMockRelay(url); + relays.set(url, r); + } + return r; + }, + add$, + remove$, + }; +} + +function createMockStorage(): AuthPreferenceStorage & { + store: Record; +} { + const store: Record = {}; + return { + store, + getItem: (key: string) => store[key] ?? null, + setItem: (key: string, value: string) => { + store[key] = value; + }, + }; +} + +function createMockSigner(): AuthSigner { + return { + signEvent: vi.fn().mockImplementation((event) => ({ + ...event, + sig: "mock-sig", + })), + }; +} + +function createManager(overrides: Partial = {}): { + manager: RelayAuthManager; + pool: ReturnType; + signer$: BehaviorSubject; + storage: ReturnType; +} { + const pool = createMockPool(); + const signer$ = new BehaviorSubject(null); + const storage = createMockStorage(); + + const manager = new RelayAuthManager({ + pool, + signer$, + storage, + ...overrides, + }); + + return { manager, pool, signer$, storage }; +} + +// --- Tests --- + +describe("RelayAuthManager", () => { + let manager: RelayAuthManager; + let pool: ReturnType; + let signer$: BehaviorSubject; + let storage: ReturnType; + + beforeEach(() => { + const ctx = createManager(); + manager = ctx.manager; + pool = ctx.pool; + signer$ = ctx.signer$; + storage = ctx.storage; + }); + + afterEach(() => { + manager.destroy(); + }); + + describe("relay monitoring", () => { + it("should monitor relays added to pool via add$", () => { + const relay = createMockRelay("wss://relay.example.com"); + pool.add$.next(relay); + + const state = manager.getRelayState("wss://relay.example.com"); + expect(state).toBeDefined(); + expect(state!.url).toBe("wss://relay.example.com"); + expect(state!.status).toBe("none"); + }); + + it("should monitor initial relays passed in options", () => { + manager.destroy(); + + const relay = createMockRelay("wss://initial.relay.com"); + const ctx = createManager({ initialRelays: [relay] }); + manager = ctx.manager; + + const state = manager.getRelayState("wss://initial.relay.com"); + expect(state).toBeDefined(); + expect(state!.status).toBe("none"); + }); + + it("should stop monitoring relays removed from pool", () => { + const relay = createMockRelay("wss://relay.example.com"); + pool.add$.next(relay); + expect(manager.getRelayState("wss://relay.example.com")).toBeDefined(); + + pool.remove$.next(relay); + expect(manager.getRelayState("wss://relay.example.com")).toBeUndefined(); + }); + + it("should be idempotent for monitorRelay", () => { + const relay = createMockRelay("wss://relay.example.com"); + manager.monitorRelay(relay); + manager.monitorRelay(relay); // second call should be a no-op + + const states = manager.getAllStates(); + expect(states.size).toBe(1); + }); + + it("should unmonitor relay by URL", () => { + const relay = createMockRelay("wss://relay.example.com"); + manager.monitorRelay(relay); + manager.unmonitorRelay("wss://relay.example.com"); + + expect(manager.getRelayState("wss://relay.example.com")).toBeUndefined(); + }); + }); + + describe("challenge detection", () => { + it("should detect new challenge from relay", () => { + const relay = createMockRelay("wss://relay.example.com"); + relay.connected$.next(true); + manager.monitorRelay(relay); + + relay.challenge$.next("test-challenge"); + + const state = manager.getRelayState("wss://relay.example.com"); + expect(state!.status).toBe("challenge_received"); + expect(state!.challenge).toBe("test-challenge"); + expect(state!.challengeReceivedAt).toBeGreaterThan(0); + }); + + it("should detect challenge change", () => { + const relay = createMockRelay("wss://relay.example.com"); + relay.connected$.next(true); + manager.monitorRelay(relay); + + relay.challenge$.next("challenge-1"); + expect(manager.getRelayState("wss://relay.example.com")!.challenge).toBe( + "challenge-1", + ); + + // Simulate user rejected, then new challenge arrives + manager.reject("wss://relay.example.com", false); + relay.challenge$.next("challenge-2"); + + const state = manager.getRelayState("wss://relay.example.com"); + expect(state!.challenge).toBe("challenge-2"); + expect(state!.status).toBe("challenge_received"); + }); + + it("should reset auth state on disconnect", () => { + const relay = createMockRelay("wss://relay.example.com"); + relay.connected$.next(true); + manager.monitorRelay(relay); + + relay.challenge$.next("test-challenge"); + expect(manager.getRelayState("wss://relay.example.com")!.status).toBe( + "challenge_received", + ); + + relay.connected$.next(false); + const state = manager.getRelayState("wss://relay.example.com"); + expect(state!.status).toBe("none"); + expect(state!.challenge).toBeNull(); + }); + }); + + describe("pending challenges observable", () => { + it("should emit pending challenges when signer is available", () => { + const challenges: Array<{ relayUrl: string }[]> = []; + manager.pendingChallenges$.subscribe((c) => challenges.push(c)); + + const relay = createMockRelay("wss://relay.example.com"); + relay.connected$.next(true); + manager.monitorRelay(relay); + + // No signer - challenge should not be pending + relay.challenge$.next("test-challenge"); + const withoutSigner = challenges[challenges.length - 1]; + expect(withoutSigner).toHaveLength(0); + + // Signer available - challenge should be pending + signer$.next(createMockSigner()); + const withSigner = challenges[challenges.length - 1]; + expect(withSigner).toHaveLength(1); + expect(withSigner[0].relayUrl).toBe("wss://relay.example.com"); + }); + + it("should not include challenges for relays with 'never' preference", () => { + signer$.next(createMockSigner()); + manager.setPreference("wss://relay.example.com", "never"); + + const relay = createMockRelay("wss://relay.example.com"); + relay.connected$.next(true); + manager.monitorRelay(relay); + + relay.challenge$.next("test-challenge"); + + const state = manager.getRelayState("wss://relay.example.com"); + // Status is "rejected" because "never" preference auto-rejects + expect(state!.status).toBe("rejected"); + + let pending: { relayUrl: string }[] = []; + manager.pendingChallenges$.subscribe((c) => (pending = c)); + expect(pending).toHaveLength(0); + }); + + it("should not include challenges for session-rejected relays", () => { + signer$.next(createMockSigner()); + + const relay = createMockRelay("wss://relay.example.com"); + relay.connected$.next(true); + manager.monitorRelay(relay); + + relay.challenge$.next("test-challenge"); + manager.reject("wss://relay.example.com", true); // remember for session + + // New challenge arrives + relay.challenge$.next("new-challenge"); + + let pending: { relayUrl: string }[] = []; + manager.pendingChallenges$.subscribe((c) => (pending = c)); + expect(pending).toHaveLength(0); + }); + + it("should not include expired challenges", () => { + manager.destroy(); + + // Create manager with very short TTL + const ctx = createManager({ challengeTTL: 1 }); // 1ms TTL + manager = ctx.manager; + pool = ctx.pool; + signer$ = ctx.signer$; + + signer$.next(createMockSigner()); + + const relay = createMockRelay("wss://relay.example.com"); + relay.connected$.next(true); + manager.monitorRelay(relay); + relay.challenge$.next("test-challenge"); + + // Wait for TTL to expire + return new Promise((resolve) => { + setTimeout(() => { + // Force re-emission by triggering signer update + signer$.next(createMockSigner()); + + let pending: { relayUrl: string }[] = []; + manager.pendingChallenges$.subscribe((c) => (pending = c)); + expect(pending).toHaveLength(0); + resolve(); + }, 10); + }); + }); + }); + + describe("authentication", () => { + it("should authenticate with relay when signer is available", async () => { + const signer = createMockSigner(); + signer$.next(signer); + + const relay = createMockRelay("wss://relay.example.com"); + relay.connected$.next(true); + manager.monitorRelay(relay); + relay.challenge$.next("test-challenge"); + + await manager.authenticate("wss://relay.example.com"); + + expect(relay.authenticate).toHaveBeenCalledWith(signer); + }); + + it("should transition to authenticating status during auth", async () => { + signer$.next(createMockSigner()); + + const relay = createMockRelay("wss://relay.example.com"); + relay.connected$.next(true); + manager.monitorRelay(relay); + relay.challenge$.next("test-challenge"); + + // Start auth (don't await yet) + const authPromise = manager.authenticate("wss://relay.example.com"); + + const state = manager.getRelayState("wss://relay.example.com"); + expect(state!.status).toBe("authenticating"); + + await authPromise; + }); + + it("should throw if no relay is being monitored", async () => { + signer$.next(createMockSigner()); + + await expect( + manager.authenticate("wss://unknown.relay.com"), + ).rejects.toThrow("not being monitored"); + }); + + it("should throw if no challenge exists", async () => { + signer$.next(createMockSigner()); + + const relay = createMockRelay("wss://relay.example.com"); + relay.connected$.next(true); + manager.monitorRelay(relay); + + await expect( + manager.authenticate("wss://relay.example.com"), + ).rejects.toThrow("No auth challenge"); + }); + + it("should throw if no signer is available", async () => { + const relay = createMockRelay("wss://relay.example.com"); + relay.connected$.next(true); + manager.monitorRelay(relay); + relay.challenge$.next("test-challenge"); + + await expect( + manager.authenticate("wss://relay.example.com"), + ).rejects.toThrow("No signer available"); + }); + + it("should set status to failed on auth error", async () => { + signer$.next(createMockSigner()); + + const relay = createMockRelay("wss://relay.example.com"); + relay.connected$.next(true); + (relay.authenticate as ReturnType).mockRejectedValue( + new Error("auth failed"), + ); + manager.monitorRelay(relay); + relay.challenge$.next("test-challenge"); + + await expect( + manager.authenticate("wss://relay.example.com"), + ).rejects.toThrow("auth failed"); + + const state = manager.getRelayState("wss://relay.example.com"); + expect(state!.status).toBe("failed"); + }); + + it("should update to authenticated when authenticated$ emits true", () => { + signer$.next(createMockSigner()); + + const relay = createMockRelay("wss://relay.example.com"); + relay.connected$.next(true); + manager.monitorRelay(relay); + relay.challenge$.next("test-challenge"); + + // Simulate authentication success via observable + relay.authenticated$.next(true); + + const state = manager.getRelayState("wss://relay.example.com"); + expect(state!.status).toBe("authenticated"); + }); + }); + + describe("rejection", () => { + it("should reject auth and update status", () => { + const relay = createMockRelay("wss://relay.example.com"); + relay.connected$.next(true); + manager.monitorRelay(relay); + relay.challenge$.next("test-challenge"); + + manager.reject("wss://relay.example.com"); + + const state = manager.getRelayState("wss://relay.example.com"); + expect(state!.status).toBe("rejected"); + expect(state!.challenge).toBeNull(); + }); + + it("should remember session rejection", () => { + signer$.next(createMockSigner()); + + const relay = createMockRelay("wss://relay.example.com"); + relay.connected$.next(true); + manager.monitorRelay(relay); + relay.challenge$.next("test-challenge"); + + manager.reject("wss://relay.example.com", true); + + // New challenge arrives + relay.challenge$.next("new-challenge"); + + let pending: { relayUrl: string }[] = []; + manager.pendingChallenges$.subscribe((c) => (pending = c)); + expect(pending).toHaveLength(0); + }); + + it("should not remember session rejection when flag is false", () => { + signer$.next(createMockSigner()); + + const relay = createMockRelay("wss://relay.example.com"); + relay.connected$.next(true); + manager.monitorRelay(relay); + relay.challenge$.next("challenge-1"); + + manager.reject("wss://relay.example.com", false); + + // New challenge arrives + relay.challenge$.next("challenge-2"); + + let pending: { relayUrl: string }[] = []; + manager.pendingChallenges$.subscribe((c) => (pending = c)); + expect(pending).toHaveLength(1); + }); + + it("should be a no-op for unknown relay", () => { + // Should not throw + manager.reject("wss://unknown.relay.com"); + }); + }); + + describe("auto-auth (always preference)", () => { + it("should auto-authenticate when preference is always and signer is available", () => { + const signer = createMockSigner(); + signer$.next(signer); + manager.setPreference("wss://relay.example.com", "always"); + + const relay = createMockRelay("wss://relay.example.com"); + relay.connected$.next(true); + manager.monitorRelay(relay); + + relay.challenge$.next("test-challenge"); + + // Should have called authenticate + expect(relay.authenticate).toHaveBeenCalledWith(signer); + + const state = manager.getRelayState("wss://relay.example.com"); + expect(state!.status).toBe("authenticating"); + }); + + it("should NOT auto-authenticate when preference is always but no signer", () => { + // No signer + manager.setPreference("wss://relay.example.com", "always"); + + const relay = createMockRelay("wss://relay.example.com"); + relay.connected$.next(true); + manager.monitorRelay(relay); + + relay.challenge$.next("test-challenge"); + + // Should NOT have called authenticate + expect(relay.authenticate).not.toHaveBeenCalled(); + + // Should fall back to challenge_received + const state = manager.getRelayState("wss://relay.example.com"); + expect(state!.status).toBe("challenge_received"); + }); + + it("should auto-authenticate when signer becomes available after challenge", () => { + manager.setPreference("wss://relay.example.com", "always"); + + const relay = createMockRelay("wss://relay.example.com"); + relay.connected$.next(true); + manager.monitorRelay(relay); + relay.challenge$.next("test-challenge"); + + // No signer yet, should be in challenge_received + expect(manager.getRelayState("wss://relay.example.com")!.status).toBe( + "challenge_received", + ); + + // Now provide signer + const signer = createMockSigner(); + signer$.next(signer); + + // Should have auto-authenticated + expect(relay.authenticate).toHaveBeenCalledWith(signer); + }); + }); + + describe("auto-reject (never preference)", () => { + it("should auto-reject when preference is never", () => { + signer$.next(createMockSigner()); + manager.setPreference("wss://relay.example.com", "never"); + + const relay = createMockRelay("wss://relay.example.com"); + relay.connected$.next(true); + manager.monitorRelay(relay); + + relay.challenge$.next("test-challenge"); + + const state = manager.getRelayState("wss://relay.example.com"); + expect(state!.status).toBe("rejected"); + expect(relay.authenticate).not.toHaveBeenCalled(); + }); + }); + + describe("preferences", () => { + it("should set and get preference", () => { + manager.setPreference("wss://relay.example.com", "always"); + expect(manager.getPreference("wss://relay.example.com")).toBe("always"); + }); + + it("should persist preferences to storage", () => { + manager.setPreference("wss://relay.example.com", "always"); + manager.setPreference("wss://other.relay.com", "never"); + + const saved = JSON.parse(storage.store["relay-auth-preferences"] || "{}"); + expect(saved["wss://relay.example.com"]).toBe("always"); + expect(saved["wss://other.relay.com"]).toBe("never"); + }); + + it("should load preferences from storage on initialization", () => { + manager.destroy(); + + const newStorage = createMockStorage(); + newStorage.store["relay-auth-preferences"] = JSON.stringify({ + "wss://relay.example.com": "always", + "wss://other.relay.com": "never", + }); + + const ctx = createManager({ storage: newStorage }); + manager = ctx.manager; + + expect(manager.getPreference("wss://relay.example.com")).toBe("always"); + expect(manager.getPreference("wss://other.relay.com")).toBe("never"); + }); + + it("should use custom storage key", () => { + manager.destroy(); + + const ctx = createManager({ storageKey: "my-auth-prefs" }); + manager = ctx.manager; + storage = ctx.storage; + + manager.setPreference("wss://relay.example.com", "always"); + + expect(storage.store["my-auth-prefs"]).toBeDefined(); + const saved = JSON.parse(storage.store["my-auth-prefs"]); + expect(saved["wss://relay.example.com"]).toBe("always"); + }); + + it("should return all preferences", () => { + manager.setPreference("wss://a.relay.com", "always"); + manager.setPreference("wss://b.relay.com", "never"); + + const all = manager.getAllPreferences(); + expect(all.size).toBe(2); + expect(all.get("wss://a.relay.com")).toBe("always"); + expect(all.get("wss://b.relay.com")).toBe("never"); + }); + + it("should handle corrupted storage gracefully", () => { + manager.destroy(); + + const newStorage = createMockStorage(); + newStorage.store["relay-auth-preferences"] = "not valid json"; + + // Should not throw + const ctx = createManager({ storage: newStorage }); + manager = ctx.manager; + + expect(manager.getPreference("wss://relay.example.com")).toBeUndefined(); + }); + + it("should ignore invalid preference values from storage", () => { + manager.destroy(); + + const newStorage = createMockStorage(); + newStorage.store["relay-auth-preferences"] = JSON.stringify({ + "wss://relay.example.com": "invalid_value", + "wss://other.relay.com": "always", + }); + + const ctx = createManager({ storage: newStorage }); + manager = ctx.manager; + + expect(manager.getPreference("wss://relay.example.com")).toBeUndefined(); + expect(manager.getPreference("wss://other.relay.com")).toBe("always"); + }); + + it("should work without storage", () => { + manager.destroy(); + + const ctx = createManager({ storage: undefined }); + manager = ctx.manager; + + // Should not throw + manager.setPreference("wss://relay.example.com", "always"); + expect(manager.getPreference("wss://relay.example.com")).toBe("always"); + }); + }); + + describe("signer lifecycle", () => { + it("should not show pending challenges when no signer", () => { + const relay = createMockRelay("wss://relay.example.com"); + relay.connected$.next(true); + manager.monitorRelay(relay); + relay.challenge$.next("test-challenge"); + + let pending: { relayUrl: string }[] = []; + manager.pendingChallenges$.subscribe((c) => (pending = c)); + expect(pending).toHaveLength(0); + }); + + it("should show pending challenges when signer becomes available", () => { + const relay = createMockRelay("wss://relay.example.com"); + relay.connected$.next(true); + manager.monitorRelay(relay); + relay.challenge$.next("test-challenge"); + + let pending: { relayUrl: string }[] = []; + manager.pendingChallenges$.subscribe((c) => (pending = c)); + expect(pending).toHaveLength(0); + + signer$.next(createMockSigner()); + expect(pending).toHaveLength(1); + }); + + it("should hide pending challenges when signer is removed", () => { + signer$.next(createMockSigner()); + + const relay = createMockRelay("wss://relay.example.com"); + relay.connected$.next(true); + manager.monitorRelay(relay); + relay.challenge$.next("test-challenge"); + + let pending: { relayUrl: string }[] = []; + manager.pendingChallenges$.subscribe((c) => (pending = c)); + expect(pending).toHaveLength(1); + + signer$.next(null); + expect(pending).toHaveLength(0); + }); + + it("should report signer availability", () => { + expect(manager.hasSignerAvailable()).toBe(false); + signer$.next(createMockSigner()); + expect(manager.hasSignerAvailable()).toBe(true); + signer$.next(null); + expect(manager.hasSignerAvailable()).toBe(false); + }); + }); + + describe("states$ observable", () => { + it("should emit initial empty state", () => { + let latest: ReadonlyMap | undefined; + manager.states$.subscribe((s) => (latest = s)); + + expect(latest).toBeDefined(); + expect(latest!.size).toBe(0); + }); + + it("should emit updated state when relay is added", () => { + let latest: ReadonlyMap | undefined; + manager.states$.subscribe((s) => (latest = s)); + + const relay = createMockRelay("wss://relay.example.com"); + manager.monitorRelay(relay); + + expect(latest!.size).toBe(1); + }); + + it("should emit updated state on status change", () => { + const states: Array> = []; + manager.states$.subscribe((s) => states.push(s)); + + const relay = createMockRelay("wss://relay.example.com"); + relay.connected$.next(true); + manager.monitorRelay(relay); + + relay.challenge$.next("test-challenge"); + + const last = states[states.length - 1]; + expect(last.get("wss://relay.example.com")?.status).toBe( + "challenge_received", + ); + }); + + it("should emit immutable snapshots (not the same reference)", () => { + const snapshots: Array> = []; + manager.states$.subscribe((s) => snapshots.push(s)); + + const relay = createMockRelay("wss://relay.example.com"); + manager.monitorRelay(relay); + + relay.challenge$.next("test-challenge"); + + // Each emission should be a different Map instance + if (snapshots.length >= 2) { + expect(snapshots[snapshots.length - 1]).not.toBe( + snapshots[snapshots.length - 2], + ); + } + }); + }); + + describe("connection state tracking", () => { + it("should track relay connection state", () => { + const relay = createMockRelay("wss://relay.example.com"); + manager.monitorRelay(relay); + + expect(manager.getRelayState("wss://relay.example.com")!.connected).toBe( + false, + ); + + relay.connected$.next(true); + expect(manager.getRelayState("wss://relay.example.com")!.connected).toBe( + true, + ); + + relay.connected$.next(false); + expect(manager.getRelayState("wss://relay.example.com")!.connected).toBe( + false, + ); + }); + }); + + describe("multiple relays", () => { + it("should handle multiple relays independently", () => { + signer$.next(createMockSigner()); + + const relay1 = createMockRelay("wss://relay1.example.com"); + const relay2 = createMockRelay("wss://relay2.example.com"); + + relay1.connected$.next(true); + relay2.connected$.next(true); + + manager.monitorRelay(relay1); + manager.monitorRelay(relay2); + + relay1.challenge$.next("challenge-1"); + relay2.challenge$.next("challenge-2"); + + expect(manager.getRelayState("wss://relay1.example.com")!.challenge).toBe( + "challenge-1", + ); + expect(manager.getRelayState("wss://relay2.example.com")!.challenge).toBe( + "challenge-2", + ); + + // Authenticate only relay1 + manager.authenticate("wss://relay1.example.com"); + + expect(manager.getRelayState("wss://relay1.example.com")!.status).toBe( + "authenticating", + ); + expect(manager.getRelayState("wss://relay2.example.com")!.status).toBe( + "challenge_received", + ); + }); + + it("should track different preferences per relay", () => { + manager.setPreference("wss://relay1.example.com", "always"); + manager.setPreference("wss://relay2.example.com", "never"); + + expect(manager.getPreference("wss://relay1.example.com")).toBe("always"); + expect(manager.getPreference("wss://relay2.example.com")).toBe("never"); + }); + }); + + describe("URL resolution", () => { + it("should resolve relay URL when state exists", () => { + const relay = createMockRelay("wss://relay.example.com"); + manager.monitorRelay(relay); + + const state = manager.getRelayState("wss://relay.example.com"); + expect(state).toBeDefined(); + }); + + it("should try normalized URL", () => { + const relay = createMockRelay("wss://relay.example.com"); + manager.monitorRelay(relay); + + // Try with trailing slash + const state = manager.getRelayState("wss://relay.example.com/"); + // normalizeUrl strips trailing slash, should find it + expect(state).toBeDefined(); + }); + }); + + describe("destroy", () => { + it("should clean up all subscriptions", () => { + const relay = createMockRelay("wss://relay.example.com"); + manager.monitorRelay(relay); + + manager.destroy(); + + expect(manager.getAllStates().size).toBe(0); + }); + + it("should complete observables", () => { + let statesCompleted = false; + let challengesCompleted = false; + + manager.states$.subscribe({ + complete: () => (statesCompleted = true), + }); + manager.pendingChallenges$.subscribe({ + complete: () => (challengesCompleted = true), + }); + + manager.destroy(); + + expect(statesCompleted).toBe(true); + expect(challengesCompleted).toBe(true); + }); + }); + + describe("integration: full auth flow", () => { + it("should handle complete auth lifecycle", async () => { + const signer = createMockSigner(); + signer$.next(signer); + + const relay = createMockRelay("wss://relay.example.com"); + relay.connected$.next(true); + manager.monitorRelay(relay); + + // 1. Challenge received + relay.challenge$.next("auth-challenge"); + expect(manager.getRelayState("wss://relay.example.com")!.status).toBe( + "challenge_received", + ); + + // 2. User authenticates + await manager.authenticate("wss://relay.example.com"); + expect(relay.authenticate).toHaveBeenCalledWith(signer); + + // 3. Relay confirms authentication + relay.authenticated$.next(true); + expect(manager.getRelayState("wss://relay.example.com")!.status).toBe( + "authenticated", + ); + + // 4. Relay disconnects + relay.connected$.next(false); + expect(manager.getRelayState("wss://relay.example.com")!.status).toBe( + "none", + ); + }); + + it("should handle auto-auth flow", () => { + const signer = createMockSigner(); + signer$.next(signer); + manager.setPreference("wss://relay.example.com", "always"); + + const relay = createMockRelay("wss://relay.example.com"); + relay.connected$.next(true); + manager.monitorRelay(relay); + + // Challenge received → auto-auth + relay.challenge$.next("auth-challenge"); + + expect(relay.authenticate).toHaveBeenCalledWith(signer); + expect(manager.getRelayState("wss://relay.example.com")!.status).toBe( + "authenticating", + ); + }); + + it("should handle auto-reject flow", () => { + signer$.next(createMockSigner()); + manager.setPreference("wss://relay.example.com", "never"); + + const relay = createMockRelay("wss://relay.example.com"); + relay.connected$.next(true); + manager.monitorRelay(relay); + + relay.challenge$.next("auth-challenge"); + + expect(relay.authenticate).not.toHaveBeenCalled(); + expect(manager.getRelayState("wss://relay.example.com")!.status).toBe( + "rejected", + ); + }); + + it("should handle signer swap during pending challenge", () => { + const relay = createMockRelay("wss://relay.example.com"); + relay.connected$.next(true); + manager.monitorRelay(relay); + relay.challenge$.next("test-challenge"); + + // No signer - challenge pending but not shown + let pending: { relayUrl: string }[] = []; + manager.pendingChallenges$.subscribe((c) => (pending = c)); + expect(pending).toHaveLength(0); + + // First signer + const signer1 = createMockSigner(); + signer$.next(signer1); + expect(pending).toHaveLength(1); + + // Switch to null (logged out) + signer$.next(null); + expect(pending).toHaveLength(0); + + // Second signer (re-login) + const signer2 = createMockSigner(); + signer$.next(signer2); + expect(pending).toHaveLength(1); + }); + + it("should handle auto-auth failure gracefully", async () => { + const signer = createMockSigner(); + signer$.next(signer); + manager.setPreference("wss://relay.example.com", "always"); + + const relay = createMockRelay("wss://relay.example.com"); + relay.connected$.next(true); + (relay.authenticate as ReturnType).mockRejectedValue( + new Error("signing failed"), + ); + manager.monitorRelay(relay); + + relay.challenge$.next("test-challenge"); + + // Wait for the async failure to propagate + await vi.waitFor(() => { + expect(manager.getRelayState("wss://relay.example.com")!.status).toBe( + "failed", + ); + }); + }); + }); +}); diff --git a/src/lib/auth-state-machine.ts b/packages/relay-auth-manager/src/auth-state-machine.ts similarity index 84% rename from src/lib/auth-state-machine.ts rename to packages/relay-auth-manager/src/auth-state-machine.ts index 6c71eeb..c01b805 100644 --- a/src/lib/auth-state-machine.ts +++ b/packages/relay-auth-manager/src/auth-state-machine.ts @@ -1,7 +1,7 @@ -import type { AuthStatus, AuthPreference } from "@/types/relay-state"; +import type { AuthPreference, AuthStatus } from "./types.js"; /** - * Events that trigger auth state transitions + * Events that trigger auth state transitions. */ export type AuthEvent = | { @@ -16,25 +16,28 @@ export type AuthEvent = | { type: "DISCONNECTED" }; /** - * Result of an auth state transition + * Result of an auth state transition. */ export interface AuthTransitionResult { + /** The new auth status after the transition */ newStatus: AuthStatus; - shouldAutoAuth: boolean; // True if preference is "always" and should auto-authenticate - clearChallenge: boolean; // True if challenge should be cleared + /** True if the manager should automatically authenticate (preference is "always") */ + shouldAutoAuth: boolean; + /** True if the current challenge should be cleared */ + clearChallenge: boolean; } /** - * Pure function implementing the auth state machine + * Pure function implementing the NIP-42 auth state machine. + * * @param currentStatus - Current auth status * @param event - Event triggering the transition - * @returns New state and any side effects to perform + * @returns Transition result with new status and side-effect flags */ export function transitionAuthState( currentStatus: AuthStatus, event: AuthEvent, ): AuthTransitionResult { - // Default result - no change const noChange: AuthTransitionResult = { newStatus: currentStatus, shouldAutoAuth: false, @@ -44,7 +47,6 @@ export function transitionAuthState( 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", @@ -52,14 +54,12 @@ export function transitionAuthState( 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, @@ -83,6 +83,13 @@ export function transitionAuthState( shouldAutoAuth: false, clearChallenge: true, }; + case "AUTH_SUCCESS": + // Relay confirmed auth while prompt was still showing + return { + newStatus: "authenticated", + shouldAutoAuth: false, + clearChallenge: true, + }; case "DISCONNECTED": return { newStatus: "none", @@ -125,7 +132,6 @@ export function transitionAuthState( clearChallenge: true, }; } - // If we get a new challenge while authenticated, transition to challenge_received if (event.type === "CHALLENGE_RECEIVED") { if (event.preference === "always") { return { @@ -144,7 +150,6 @@ export function transitionAuthState( case "failed": case "rejected": - // Can receive new challenge after failure/rejection if (event.type === "CHALLENGE_RECEIVED") { if (event.preference === "always") { return { @@ -175,7 +180,6 @@ export function transitionAuthState( return noChange; default: { - // Exhaustive check const _exhaustive: never = currentStatus; return _exhaustive; } diff --git a/packages/relay-auth-manager/src/index.ts b/packages/relay-auth-manager/src/index.ts new file mode 100644 index 0000000..fc98247 --- /dev/null +++ b/packages/relay-auth-manager/src/index.ts @@ -0,0 +1,14 @@ +export { RelayAuthManager } from "./relay-auth-manager.js"; +export { transitionAuthState } from "./auth-state-machine.js"; +export type { AuthEvent, AuthTransitionResult } from "./auth-state-machine.js"; +export type { + AuthStatus, + AuthPreference, + AuthSigner, + AuthRelay, + AuthRelayPool, + AuthPreferenceStorage, + RelayAuthState, + RelayAuthManagerOptions, + PendingAuthChallenge, +} from "./types.js"; diff --git a/packages/relay-auth-manager/src/relay-auth-manager.ts b/packages/relay-auth-manager/src/relay-auth-manager.ts new file mode 100644 index 0000000..0ae8e53 --- /dev/null +++ b/packages/relay-auth-manager/src/relay-auth-manager.ts @@ -0,0 +1,471 @@ +import { BehaviorSubject, type Subscription, combineLatest } from "rxjs"; +import { startWith } from "rxjs/operators"; +import { transitionAuthState, type AuthEvent } from "./auth-state-machine.js"; +import type { + AuthPreference, + AuthRelay, + AuthSigner, + PendingAuthChallenge, + RelayAuthManagerOptions, + RelayAuthState, +} from "./types.js"; + +const DEFAULT_STORAGE_KEY = "relay-auth-preferences"; +const DEFAULT_CHALLENGE_TTL = 5 * 60 * 1000; // 5 minutes + +/** + * Generic NIP-42 relay authentication manager. + * + * Monitors relays for AUTH challenges and manages the authentication lifecycle + * including auto-auth, user prompts, preferences persistence, and signer lifecycle. + * + * Framework-agnostic: uses RxJS observables for reactivity, accepts pluggable + * storage and signer via constructor injection. + */ +export class RelayAuthManager { + private readonly storageKey: string; + private readonly challengeTTL: number; + private readonly storage?: RelayAuthManagerOptions["storage"]; + + private signer: AuthSigner | null = null; + private readonly relaySubscriptions = new Map(); + private readonly monitoredRelays = new Map(); + private readonly preferences = new Map(); + private readonly sessionRejections = new Set(); + private readonly _relayStates = new Map(); + + private poolAddSub?: Subscription; + private poolRemoveSub?: Subscription; + private signerSub?: Subscription; + + /** Observable of all relay auth states. Emits a new Map on every change. */ + readonly states$: BehaviorSubject>; + + /** Observable of pending challenges that need user input. */ + readonly pendingChallenges$: BehaviorSubject; + + constructor(options: RelayAuthManagerOptions) { + this.storage = options.storage; + this.storageKey = options.storageKey ?? DEFAULT_STORAGE_KEY; + this.challengeTTL = options.challengeTTL ?? DEFAULT_CHALLENGE_TTL; + + this.states$ = new BehaviorSubject>( + new Map(), + ); + this.pendingChallenges$ = new BehaviorSubject([]); + + // Load persisted preferences before monitoring relays + this.loadPreferences(); + + // Subscribe to signer changes + this.signerSub = options.signer$.subscribe((signer) => { + const hadSigner = this.signer !== null; + this.signer = signer; + + // When signer becomes available, re-evaluate auto-auth opportunities + if (signer && !hadSigner) { + this.reevaluateAutoAuth(); + } + + this.emitState(); + }); + + // Subscribe to pool relay lifecycle + this.poolAddSub = options.pool.add$.subscribe((relay) => { + this.monitorRelay(relay); + }); + + this.poolRemoveSub = options.pool.remove$.subscribe((relay) => { + this.unmonitorRelay(relay.url); + }); + + // Monitor initial relays + if (options.initialRelays) { + for (const relay of options.initialRelays) { + this.monitorRelay(relay); + } + } + } + + /** + * Start monitoring a relay for auth challenges. + * Safe to call multiple times for the same relay (idempotent). + */ + monitorRelay(relay: AuthRelay): void { + const url = relay.url; + + if (this.relaySubscriptions.has(url)) return; + + // Store relay reference for later use (authenticate, auto-auth) + this.monitoredRelays.set(url, relay); + + // Initialize state + if (!this._relayStates.has(url)) { + this._relayStates.set(url, { + url, + connected: relay.connected, + status: "none", + challenge: relay.challenge, + challengeReceivedAt: null, + }); + } + + // Subscribe to relay observables using startWith for initial values + const sub = combineLatest({ + connected: relay.connected$.pipe(startWith(relay.connected)), + challenge: relay.challenge$.pipe(startWith(relay.challenge)), + authenticated: relay.authenticated$.pipe(startWith(relay.authenticated)), + }).subscribe((values) => { + this.handleRelayUpdate(url, relay, values); + }); + + this.relaySubscriptions.set(url, sub); + } + + /** + * Stop monitoring a relay. + */ + unmonitorRelay(url: string): void { + const sub = this.relaySubscriptions.get(url); + if (sub) { + sub.unsubscribe(); + this.relaySubscriptions.delete(url); + } + this.monitoredRelays.delete(url); + this._relayStates.delete(url); + this.emitState(); + } + + /** + * Authenticate with a relay. Requires a pending challenge and available signer. + */ + async authenticate(relayUrl: string): Promise { + const url = this.resolveRelayUrl(relayUrl); + const state = this._relayStates.get(url); + + const relay = this.monitoredRelays.get(url); + if (!state || !relay) { + throw new Error(`Relay ${relayUrl} is not being monitored`); + } + if (!state.challenge) { + throw new Error(`No auth challenge for ${relayUrl}`); + } + if (!this.signer) { + throw new Error("No signer available for authentication"); + } + + // Transition via state machine + const transition = transitionAuthState(state.status, { + type: "USER_ACCEPTED", + }); + state.status = transition.newStatus; + this.emitState(); + + try { + await relay.authenticate(this.signer); + // authenticated$ subscription will handle the success state update + } catch (error) { + state.status = "failed"; + state.challenge = null; + state.challengeReceivedAt = null; + this.emitState(); + throw error; + } + } + + /** + * Reject authentication for a relay. + * @param relayUrl - Relay URL + * @param rememberForSession - If true, won't prompt again this session (default: true) + */ + reject(relayUrl: string, rememberForSession = true): void { + const url = this.resolveRelayUrl(relayUrl); + const state = this._relayStates.get(url); + + if (!state) return; + + const transition = transitionAuthState(state.status, { + type: "USER_REJECTED", + }); + state.status = transition.newStatus; + + if (transition.clearChallenge) { + state.challenge = null; + state.challengeReceivedAt = null; + } + + if (rememberForSession) { + this.sessionRejections.add(url); + } + + this.emitState(); + } + + /** + * Set auth preference for a relay. Persists to storage if available. + */ + setPreference(relayUrl: string, preference: AuthPreference): void { + const url = this.resolveRelayUrl(relayUrl); + this.preferences.set(url, preference); + this.savePreferences(); + this.emitState(); + } + + /** + * Get auth preference for a relay. + */ + getPreference(relayUrl: string): AuthPreference | undefined { + const url = this.resolveRelayUrl(relayUrl); + return this.preferences.get(url); + } + + /** + * Get all auth preferences. + */ + getAllPreferences(): ReadonlyMap { + return this.preferences; + } + + /** + * Get auth state for a specific relay. + */ + getRelayState(relayUrl: string): RelayAuthState | undefined { + const url = this.resolveRelayUrl(relayUrl); + return this._relayStates.get(url); + } + + /** + * Get all relay auth states. + */ + getAllStates(): ReadonlyMap { + return this._relayStates; + } + + /** + * Check if a signer is currently available. + */ + hasSignerAvailable(): boolean { + return this.signer !== null; + } + + /** + * Clean up all subscriptions and complete observables. + */ + destroy(): void { + this.signerSub?.unsubscribe(); + this.poolAddSub?.unsubscribe(); + this.poolRemoveSub?.unsubscribe(); + + for (const sub of this.relaySubscriptions.values()) { + sub.unsubscribe(); + } + this.relaySubscriptions.clear(); + this.monitoredRelays.clear(); + this._relayStates.clear(); + + this.states$.complete(); + this.pendingChallenges$.complete(); + } + + // --- Private --- + + private handleRelayUpdate( + url: string, + relay: AuthRelay, + values: { + connected: boolean; + challenge: string | null; + authenticated: boolean; + }, + ): void { + const state = this._relayStates.get(url); + if (!state) return; + + const wasConnected = state.connected; + const now = Date.now(); + + // Update connection state + state.connected = values.connected; + + // Determine auth event from observable values + let authEvent: AuthEvent | null = null; + + // Priority 1: Disconnection + if (!values.connected && wasConnected) { + authEvent = { type: "DISCONNECTED" }; + } + // Priority 2: Authentication success + else if (values.authenticated && state.status !== "authenticated") { + authEvent = { type: "AUTH_SUCCESS" }; + } + // Priority 3: New challenge (or challenge changed) + else if (values.challenge && values.challenge !== state.challenge) { + const preference = this.preferences.get(url); + authEvent = { + type: "CHALLENGE_RECEIVED", + challenge: values.challenge, + preference, + }; + } + // Priority 4: Challenge cleared without auth success + else if ( + !values.challenge && + !values.authenticated && + (state.status === "authenticating" || + state.status === "challenge_received") + ) { + authEvent = { type: "AUTH_FAILED" }; + } + + if (authEvent) { + const transition = transitionAuthState(state.status, authEvent); + state.status = transition.newStatus; + + // Update challenge + if (transition.clearChallenge) { + state.challenge = null; + state.challengeReceivedAt = null; + } else if (authEvent.type === "CHALLENGE_RECEIVED") { + state.challenge = authEvent.challenge; + state.challengeReceivedAt = now; + } + + // Handle auto-auth + if (transition.shouldAutoAuth) { + if (this.signer) { + relay.authenticate(this.signer).catch(() => { + const s = this._relayStates.get(url); + if (s) { + s.status = "failed"; + this.emitState(); + } + }); + } else { + // No signer available - fall back to challenge_received so it shows as pending + state.status = "challenge_received"; + } + } + } + + this.emitState(); + } + + /** + * Re-evaluate auto-auth opportunities when signer becomes available. + * Checks all relays in challenge_received state with "always" preference. + */ + private reevaluateAutoAuth(): void { + if (!this.signer) return; + + for (const [url, state] of this._relayStates) { + if (state.status === "challenge_received" && state.challenge) { + const pref = this.preferences.get(url); + if (pref === "always") { + const relay = this.monitoredRelays.get(url); + if (!relay) continue; + state.status = "authenticating"; + relay.authenticate(this.signer!).catch(() => { + const s = this._relayStates.get(url); + if (s) { + s.status = "failed"; + this.emitState(); + } + }); + } + } + } + } + + private shouldPrompt(url: string): boolean { + const pref = this.preferences.get(url); + if (pref === "never") return false; + if (this.sessionRejections.has(url)) return false; + return true; + } + + private isChallengeExpired(receivedAt: number, now = Date.now()): boolean { + return now - receivedAt > this.challengeTTL; + } + + private resolveRelayUrl(url: string): string { + // Fast path: exact match + if (this._relayStates.has(url)) return url; + + // Try normalized form + const normalized = normalizeUrl(url); + if (this._relayStates.has(normalized)) return normalized; + + return url; + } + + private loadPreferences(): void { + if (!this.storage) return; + + try { + const json = this.storage.getItem(this.storageKey); + if (json) { + const prefs = JSON.parse(json) as Record; + for (const [url, pref] of Object.entries(prefs)) { + if (pref === "always" || pref === "never" || pref === "ask") { + this.preferences.set(url, pref); + } + } + } + } catch { + // Ignore storage errors silently + } + } + + private savePreferences(): void { + if (!this.storage) return; + + try { + const prefs: Record = {}; + for (const [url, pref] of this.preferences) { + prefs[url] = pref; + } + this.storage.setItem(this.storageKey, JSON.stringify(prefs)); + } catch { + // Ignore storage errors silently + } + } + + private emitState(): void { + // Emit a snapshot of current states + this.states$.next(new Map(this._relayStates)); + + // Derive and emit pending challenges + const now = Date.now(); + const challenges: PendingAuthChallenge[] = []; + + for (const state of this._relayStates.values()) { + if ( + state.status === "challenge_received" && + state.challenge && + state.challengeReceivedAt && + !this.isChallengeExpired(state.challengeReceivedAt, now) && + this.shouldPrompt(state.url) && + this.signer !== null + ) { + challenges.push({ + relayUrl: state.url, + challenge: state.challenge, + receivedAt: state.challengeReceivedAt, + }); + } + } + + this.pendingChallenges$.next(challenges); + } +} + +/** + * Basic URL normalization for relay URLs. + */ +function normalizeUrl(url: string): string { + let u = url.trim(); + if (!u.startsWith("ws://") && !u.startsWith("wss://")) { + u = `wss://${u}`; + } + return u.replace(/\/+$/, ""); +} diff --git a/packages/relay-auth-manager/src/types.ts b/packages/relay-auth-manager/src/types.ts new file mode 100644 index 0000000..6d134ba --- /dev/null +++ b/packages/relay-auth-manager/src/types.ts @@ -0,0 +1,105 @@ +import type { Observable } from "rxjs"; + +/** + * Auth status for a relay's NIP-42 authentication state. + */ +export type AuthStatus = + | "none" // No auth interaction yet + | "challenge_received" // Challenge received, waiting for user decision + | "authenticating" // Signing and sending AUTH event + | "authenticated" // Successfully authenticated + | "rejected" // User rejected auth + | "failed"; // Authentication failed + +/** + * User's persistent auth preference for a relay. + */ +export type AuthPreference = "always" | "never" | "ask"; + +/** + * Auth state for a single relay. + */ +export interface RelayAuthState { + url: string; + connected: boolean; + status: AuthStatus; + challenge: string | null; + challengeReceivedAt: number | null; +} + +/** + * A pending auth challenge that needs user input. + */ +export interface PendingAuthChallenge { + relayUrl: string; + challenge: string; + receivedAt: number; +} + +/** + * Minimal relay interface needed by RelayAuthManager. + * Compatible with IRelay from applesauce-relay. + */ +export interface AuthRelay { + url: string; + connected$: Observable; + challenge$: Observable; + authenticated$: Observable; + readonly connected: boolean; + readonly authenticated: boolean; + readonly challenge: string | null; + authenticate(signer: AuthSigner): Promise; +} + +/** + * Signer interface for NIP-42 authentication. + * Compatible with AuthSigner from applesauce-relay. + */ +export interface AuthSigner { + signEvent(event: unknown): unknown | Promise; +} + +/** + * Minimal pool interface needed by RelayAuthManager. + * Compatible with RelayPool from applesauce-relay. + */ +export interface AuthRelayPool { + /** Get or create a relay by URL */ + relay(url: string): AuthRelay; + /** Emits when a relay is added to the pool */ + add$: Observable; + /** Emits when a relay is removed from the pool */ + remove$: Observable; +} + +/** + * localStorage-like storage interface for persisting auth preferences. + * Both localStorage and sessionStorage satisfy this interface. + */ +export interface AuthPreferenceStorage { + getItem(key: string): string | null; + setItem(key: string, value: string): void; +} + +/** + * Options for RelayAuthManager constructor. + */ +export interface RelayAuthManagerOptions { + /** Relay pool to monitor for auth challenges */ + pool: AuthRelayPool; + + /** Observable that emits the current signer. Emit null when signing is unavailable (e.g., read-only account or logged out). */ + signer$: Observable; + + /** Optional storage for persisting auth preferences across sessions */ + storage?: AuthPreferenceStorage; + + /** Key to use in storage (default: "relay-auth-preferences") */ + storageKey?: string; + + /** Challenge TTL in milliseconds (default: 300000 = 5 minutes) */ + challengeTTL?: number; + + /** Initial relays to monitor (for relays already in the pool at creation time) */ + initialRelays?: Iterable; +} diff --git a/packages/relay-auth-manager/tsconfig.json b/packages/relay-auth-manager/tsconfig.json new file mode 100644 index 0000000..a798eb0 --- /dev/null +++ b/packages/relay-auth-manager/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "isolatedModules": true, + "skipLibCheck": true, + "noEmit": true + }, + "include": ["src"] +} diff --git a/src/components/ConnViewer.tsx b/src/components/ConnViewer.tsx index 15795d6..da9deac 100644 --- a/src/components/ConnViewer.tsx +++ b/src/components/ConnViewer.tsx @@ -244,16 +244,9 @@ function RelayCard({ relay }: RelayCardProps) { } 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); - } + setAuthPreference(relay.url, value); + toast.success("Preference saved"); + setIsSavingPreference(false); }} > Ask diff --git a/src/components/GlobalAuthPrompt.tsx b/src/components/GlobalAuthPrompt.tsx index 4ff2dd4..8dc3c9e 100644 --- a/src/components/GlobalAuthPrompt.tsx +++ b/src/components/GlobalAuthPrompt.tsx @@ -155,7 +155,7 @@ export function GlobalAuthPrompt() { challenge={challenge.challenge} onAuthenticate={async (remember) => { if (remember) { - await setAuthPreference(challenge.relayUrl, "always"); + setAuthPreference(challenge.relayUrl, "always"); } activeToasts.current.delete(key); @@ -184,7 +184,7 @@ export function GlobalAuthPrompt() { }} onReject={async (remember) => { if (remember) { - await setAuthPreference(challenge.relayUrl, "never"); + setAuthPreference(challenge.relayUrl, "never"); } rejectAuth(challenge.relayUrl, !remember); diff --git a/src/components/layouts/AppShell.tsx b/src/components/layouts/AppShell.tsx index 6e30102..7efdd1d 100644 --- a/src/components/layouts/AppShell.tsx +++ b/src/components/layouts/AppShell.tsx @@ -31,9 +31,7 @@ export function AppShell({ children, hideBottomBar = false }: AppShellProps) { // Initialize global relay state manager useEffect(() => { - relayStateManager.initialize().catch((err) => { - console.error("Failed to initialize relay state manager:", err); - }); + relayStateManager.initialize(); }, []); // Sync relay state with Jotai diff --git a/src/hooks/useRelayState.ts b/src/hooks/useRelayState.ts index 1286889..328a62a 100644 --- a/src/hooks/useRelayState.ts +++ b/src/hooks/useRelayState.ts @@ -50,16 +50,14 @@ export function useRelayState() { return relayState?.relays[normalizedUrl]; }, - // Get auth preference - getAuthPreference: async ( - url: string, - ): Promise => { - return await relayStateManager.getAuthPreference(url); + // Get auth preference (now synchronous) + getAuthPreference: (url: string): AuthPreference | undefined => { + return relayStateManager.getAuthPreference(url); }, - // Set auth preference - setAuthPreference: async (url: string, preference: AuthPreference) => { - await relayStateManager.setAuthPreference(url, preference); + // Set auth preference (now synchronous) + setAuthPreference: (url: string, preference: AuthPreference) => { + relayStateManager.setAuthPreference(url, preference); }, // Authenticate with relay diff --git a/src/services/relay-auth.ts b/src/services/relay-auth.ts new file mode 100644 index 0000000..d832c63 --- /dev/null +++ b/src/services/relay-auth.ts @@ -0,0 +1,32 @@ +import { map } from "rxjs/operators"; +import { RelayAuthManager } from "relay-auth-manager"; +import type { AuthSigner } from "relay-auth-manager"; +import { canAccountSign } from "@/hooks/useAccount"; +import pool from "./relay-pool"; +import accountManager from "./accounts"; + +/** + * Singleton RelayAuthManager instance for Grimoire. + * + * Wired to Grimoire's relay pool, account system, and localStorage. + * Manages NIP-42 auth challenges, preferences, and auto-auth. + */ +const relayAuthManager = new RelayAuthManager({ + pool, + + // Map active account to signer (null when read-only or logged out) + signer$: accountManager.active$.pipe( + map((account) => { + if (!account || !canAccountSign(account)) return null; + // IAccount satisfies AuthSigner (has signEvent method) + return account as unknown as AuthSigner; + }), + ), + + // Use localStorage for preference persistence + storage: localStorage, + + // Use "relay-auth-preferences" key (default) +}); + +export default relayAuthManager; diff --git a/src/services/relay-state-manager.ts b/src/services/relay-state-manager.ts index 9cb247d..3269859 100644 --- a/src/services/relay-state-manager.ts +++ b/src/services/relay-state-manager.ts @@ -1,50 +1,40 @@ import type { IRelay } from "applesauce-relay"; -import { combineLatest, firstValueFrom, race, timer } from "rxjs"; -import { filter, map, startWith } from "rxjs/operators"; +import { combineLatest } from "rxjs"; +import { 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 { normalizeRelayURL } from "@/lib/relay-url"; -import { canAccountSign } from "@/hooks/useAccount"; import pool from "./relay-pool"; -import accountManager from "./accounts"; -import db from "./db"; - -const logger = createLogger("RelayStateManager"); +import relayAuthManager from "./relay-auth"; 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 + * Observable values emitted by relay observables (connection + notices only) */ interface RelayObservableValues { connected: boolean; - notices: string[]; // notices is an array of strings - challenge: string | null | undefined; // challenge can be null or undefined - authenticated: boolean; + notices: string[]; } /** - * Singleton service for managing global relay state - * Subscribes to all relay observables and maintains state for all relays + * Singleton service for managing global relay state. + * + * Tracks relay connection state, notices, and errors. + * Delegates all NIP-42 authentication logic to the generic RelayAuthManager. */ class RelayStateManager { private relayStates: Map = new Map(); private subscriptions: Map void> = new Map(); private listeners: Set<(state: GlobalRelayState) => void> = new Set(); - private authPreferences: Map = new Map(); - private sessionRejections: Set = new Set(); private initialized = false; private pollingIntervalId?: NodeJS.Timeout; private lastNotifiedState?: GlobalRelayState; private stateVersion = 0; + private authUnsubscribe?: () => void; constructor() { // Don't perform async operations in constructor @@ -55,15 +45,17 @@ class RelayStateManager { * Initialize relay monitoring for all relays in the pool * Must be called before using the manager */ - async initialize() { + initialize() { if (this.initialized) return; - // Load preferences from database BEFORE starting monitoring - // This ensures preferences are available when relays connect - await this.loadAuthPreferences(); - this.initialized = true; + // Subscribe to auth manager state changes to re-notify listeners + const authSub = relayAuthManager.states$.subscribe(() => { + this.notifyListeners(); + }); + this.authUnsubscribe = () => authSub.unsubscribe(); + // Subscribe to existing relays pool.relays.forEach((relay) => { this.monitorRelay(relay); @@ -108,9 +100,10 @@ class RelayStateManager { this.relayStates.set(url, this.createInitialState(url)); } - // 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 + // Also monitor in the auth manager + relayAuthManager.monitorRelay(relay); + + // Subscribe to connection and notice observables only const subscription = combineLatest({ connected: relay.connected$.pipe(startWith(relay.connected)), notices: relay.notice$.pipe( @@ -119,13 +112,7 @@ class RelayStateManager { 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); }); @@ -141,7 +128,7 @@ class RelayStateManager { url, connectionState: "disconnected", authStatus: "none", - authPreference: this.authPreferences.get(url), + authPreference: relayAuthManager.getPreference(url), notices: [], errors: [], stats: { @@ -153,9 +140,7 @@ class RelayStateManager { } /** - * Update relay state based on observable values - * @param url - Relay URL - * @param values - Current values emitted by relay observables + * Update relay state based on observable values (connection + notices only) */ private updateRelayState(url: string, values: RelayObservableValues) { const state = this.relayStates.get(url); @@ -174,96 +159,12 @@ class RelayStateManager { } else if (!isConnected && wasConnected) { state.connectionState = "disconnected"; state.lastDisconnected = now; - // Reset auth status when disconnecting - console.log( - `[RelayStateManager] ${url} disconnected, resetting auth status`, - ); - state.authStatus = "none"; - state.currentChallenge = undefined; } else if (isConnected) { state.connectionState = "connected"; } else { state.connectionState = "disconnected"; } - // Update auth status using state machine - const challenge = values.challenge; - const isAuthenticated = values.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++; - } - - 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, - ); - }); - } - } - // Add notices (bounded array) if (values.notices && values.notices.length > 0) { const notice = values.notices[0]; @@ -281,297 +182,77 @@ class RelayStateManager { } /** - * Get auth preference for a relay + * Get auth preference for a relay (delegates to auth manager) */ - async getAuthPreference( - relayUrl: string, - ): Promise { - try { - const normalizedUrl = normalizeRelayURL(relayUrl); - - // Check memory cache first - if (this.authPreferences.has(normalizedUrl)) { - return this.authPreferences.get(normalizedUrl); - } - - // Load from database - const record = await db.relayAuthPreferences.get(normalizedUrl); - if (record) { - this.authPreferences.set(normalizedUrl, record.preference); - return record.preference; - } - - return undefined; - } catch (error) { - console.error(`Failed to get auth preference for ${relayUrl}:`, error); - return undefined; - } + getAuthPreference(relayUrl: string): AuthPreference | undefined { + return relayAuthManager.getPreference(relayUrl); } /** - * Set auth preference for a relay + * Set auth preference for a relay (delegates to auth manager) */ - async setAuthPreference(relayUrl: string, preference: AuthPreference) { + setAuthPreference(relayUrl: string, preference: AuthPreference) { + relayAuthManager.setPreference(relayUrl, preference); + + // Update local relay state for UI try { const normalizedUrl = normalizeRelayURL(relayUrl); - console.log( - `[RelayStateManager] Setting auth preference for ${normalizedUrl} to "${preference}"`, - ); - - // Update memory cache - this.authPreferences.set(normalizedUrl, preference); - - // Save to database - try { - await db.relayAuthPreferences.put({ - url: normalizedUrl, - preference, - updatedAt: Date.now(), - }); - console.log( - `[RelayStateManager] Successfully saved preference to database`, - ); - } catch (error) { - console.error( - `[RelayStateManager] Failed to save preference to database:`, - error, - ); - throw error; - } - - // Update relay state const state = this.relayStates.get(normalizedUrl); if (state) { state.authPreference = preference; - this.notifyListeners(); - console.log( - `[RelayStateManager] Updated relay state and notified listeners`, - ); } - } catch (error) { - console.error(`Failed to set auth preference for ${relayUrl}:`, error); - throw error; + } catch { + // Ignore normalization errors } + + this.notifyListeners(); } /** - * Authenticate with a relay + * Authenticate with a relay (delegates to auth manager) */ async authenticateRelay(relayUrl: string): Promise { - let normalizedUrl: string; - try { - normalizedUrl = normalizeRelayURL(relayUrl); - } catch (error) { - throw new Error(`Invalid relay URL ${relayUrl}: ${error}`); - } - - const relay = pool.relay(normalizedUrl); - const state = this.relayStates.get(relay.url); - - if (!relay || !state) { - throw new Error(`Relay ${relayUrl} not found`); - } - - if (!state.currentChallenge) { - throw new Error(`No auth challenge for ${relayUrl}`); - } - - // Get active account - const account = accountManager.active; - if (!account) { - throw new Error("No active account to authenticate with"); - } - - // Check if account can sign (read-only accounts cannot authenticate) - if (!canAccountSign(account)) { - throw new Error("Active account cannot sign events (read-only account)"); - } - - // Update status to authenticating - state.authStatus = "authenticating"; - state.stats.authAttemptsCount++; - this.notifyListeners(); - - try { - logger.info(`Authenticating with ${relayUrl}`); - - // Start authentication - await relay.authenticate(account); - - // 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)), - ]), - ); - - if (!authResult) { - throw new Error("Authentication timeout - relay did not respond"); - } - - 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: `Authentication failed: ${errorMessage}`, - timestamp: Date.now(), - type: errorType, - }); - - if (state.errors.length > MAX_ERRORS) { - state.errors = state.errors.slice(0, MAX_ERRORS); - } - this.notifyListeners(); - throw error; - } + await relayAuthManager.authenticate(relayUrl); } /** - * Reject authentication for a relay + * Reject authentication for a relay (delegates to auth manager) */ rejectAuth(relayUrl: string, rememberForSession = true) { - try { - const normalizedUrl = normalizeRelayURL(relayUrl); - const state = this.relayStates.get(normalizedUrl); - if (state) { - // Use state machine for consistent transitions - const transition = transitionAuthState(state.authStatus, { - type: "USER_REJECTED", - }); - - console.log( - `[RelayStateManager] ${relayUrl} user rejected auth:`, - `${state.authStatus} → ${transition.newStatus}`, - ); - - state.authStatus = transition.newStatus; - if (transition.clearChallenge) { - state.currentChallenge = undefined; - } - - if (rememberForSession) { - this.sessionRejections.add(normalizedUrl); - } - this.notifyListeners(); - } - } catch (error) { - console.error(`Failed to reject auth for ${relayUrl}:`, error); - } + relayAuthManager.reject(relayUrl, rememberForSession); } /** - * Check if a relay should be prompted for auth - */ - shouldPromptAuth(relayUrl: string): boolean { - try { - const normalizedUrl = normalizeRelayURL(relayUrl); - - // Don't prompt if there's no active account or account can't sign - const account = accountManager.active; - if (!account || !canAccountSign(account)) return false; - - // Check permanent preferences - const pref = this.authPreferences.get(normalizedUrl); - if (pref === "never") return false; - - // Check session rejections - if (this.sessionRejections.has(normalizedUrl)) return false; - - // Don't prompt if already authenticated (unless challenge changes) - const state = this.relayStates.get(normalizedUrl); - if (state?.authStatus === "authenticated") return false; - - return true; - } catch (error) { - console.error(`Failed to check auth prompt for ${relayUrl}:`, error); - return false; - } - } - - /** - * Check if a challenge has expired - */ - private isChallengeExpired(receivedAt: number): boolean { - return Date.now() - receivedAt > CHALLENGE_TTL; - } - - /** - * Get current global state + * Get current global state (merges connection state with auth state) */ getState(): GlobalRelayState { const relays: Record = {}; + const authStates = relayAuthManager.getAllStates(); + this.relayStates.forEach((state, url) => { - // Create shallow copy to avoid mutation issues in hasStateChanged - relays[url] = { ...state }; + const authState = authStates.get(url); + relays[url] = { + ...state, + // Merge auth state from the auth manager + authStatus: authState?.status ?? "none", + authPreference: relayAuthManager.getPreference(url), + currentChallenge: + authState?.challenge && authState.challengeReceivedAt + ? { + challenge: authState.challenge, + receivedAt: authState.challengeReceivedAt, + } + : undefined, + }; }); - const pendingChallenges = Array.from(this.relayStates.values()) - .filter((state) => { - // Only include non-expired challenges - if ( - state.authStatus === "challenge_received" && - 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, - receivedAt: state.currentChallenge!.receivedAt, - })); + // Get pending challenges from auth manager + const pendingChallenges = relayAuthManager.pendingChallenges$.value; const authPreferences: Record = {}; - this.authPreferences.forEach((pref, url) => { + for (const [url, pref] of relayAuthManager.getAllPreferences()) { authPreferences[url] = pref; - }); + } return { relays, @@ -653,21 +334,6 @@ class RelayStateManager { } } - /** - * Load auth preferences from database into memory cache - */ - private async loadAuthPreferences() { - try { - const allPrefs = await db.relayAuthPreferences.toArray(); - allPrefs.forEach((record) => { - this.authPreferences.set(record.url, record.preference); - }); - logger.info(`Loaded ${allPrefs.length} auth preferences from database`); - } catch (error) { - logger.warn("Failed to load auth preferences", error); - } - } - /** * Cleanup all subscriptions and intervals */ @@ -678,6 +344,9 @@ class RelayStateManager { this.pollingIntervalId = undefined; } + // Unsubscribe from auth manager + this.authUnsubscribe?.(); + // Unsubscribe from all relay observables this.subscriptions.forEach((unsubscribe) => unsubscribe()); this.subscriptions.clear();