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
This commit is contained in:
Claude
2026-02-13 10:06:35 +00:00
parent 62ce435043
commit 13749d91e9
16 changed files with 1923 additions and 516 deletions

13
package-lock.json generated
View File

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

View File

@@ -4,6 +4,9 @@
"version": "0.1.0",
"type": "module",
"license": "MIT",
"workspaces": [
"packages/*"
],
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",

View File

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

View File

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

View File

@@ -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<boolean>;
challenge$: BehaviorSubject<string | null>;
authenticated$: BehaviorSubject<boolean>;
} {
return {
url,
connected: false,
authenticated: false,
challenge: null,
connected$: new BehaviorSubject<boolean>(false),
challenge$: new BehaviorSubject<string | null>(null),
authenticated$: new BehaviorSubject<boolean>(false),
authenticate: vi.fn().mockResolvedValue({ ok: true }),
};
}
function createMockPool(
relays: Map<string, ReturnType<typeof createMockRelay>> = new Map(),
): AuthRelayPool & {
add$: Subject<AuthRelay>;
remove$: Subject<AuthRelay>;
} {
const add$ = new Subject<AuthRelay>();
const remove$ = new Subject<AuthRelay>();
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<string, string>;
} {
const store: Record<string, string> = {};
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<RelayAuthManagerOptions> = {}): {
manager: RelayAuthManager;
pool: ReturnType<typeof createMockPool>;
signer$: BehaviorSubject<AuthSigner | null>;
storage: ReturnType<typeof createMockStorage>;
} {
const pool = createMockPool();
const signer$ = new BehaviorSubject<AuthSigner | null>(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<typeof createMockPool>;
let signer$: BehaviorSubject<AuthSigner | null>;
let storage: ReturnType<typeof createMockStorage>;
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<void>((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<typeof vi.fn>).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<string, unknown> | 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<string, unknown> | 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<ReadonlyMap<string, { status: string }>> = [];
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<ReadonlyMap<string, unknown>> = [];
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<typeof vi.fn>).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",
);
});
});
});
});

View File

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

View File

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

View File

@@ -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<string, Subscription>();
private readonly monitoredRelays = new Map<string, AuthRelay>();
private readonly preferences = new Map<string, AuthPreference>();
private readonly sessionRejections = new Set<string>();
private readonly _relayStates = new Map<string, RelayAuthState>();
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<ReadonlyMap<string, RelayAuthState>>;
/** Observable of pending challenges that need user input. */
readonly pendingChallenges$: BehaviorSubject<PendingAuthChallenge[]>;
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<ReadonlyMap<string, RelayAuthState>>(
new Map(),
);
this.pendingChallenges$ = new BehaviorSubject<PendingAuthChallenge[]>([]);
// 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<void> {
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<string, AuthPreference> {
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<string, RelayAuthState> {
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<string, AuthPreference>;
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<string, AuthPreference> = {};
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(/\/+$/, "");
}

View File

@@ -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<boolean>;
challenge$: Observable<string | null>;
authenticated$: Observable<boolean>;
readonly connected: boolean;
readonly authenticated: boolean;
readonly challenge: string | null;
authenticate(signer: AuthSigner): Promise<unknown>;
}
/**
* Signer interface for NIP-42 authentication.
* Compatible with AuthSigner from applesauce-relay.
*/
export interface AuthSigner {
signEvent(event: unknown): unknown | Promise<unknown>;
}
/**
* 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<AuthRelay>;
/** Emits when a relay is removed from the pool */
remove$: Observable<AuthRelay>;
}
/**
* 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<AuthSigner | null>;
/** 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<AuthRelay>;
}

View File

@@ -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"]
}

View File

@@ -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);
}}
>
<DropdownMenuRadioItem value="ask">Ask</DropdownMenuRadioItem>

View File

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

View File

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

View File

@@ -50,16 +50,14 @@ export function useRelayState() {
return relayState?.relays[normalizedUrl];
},
// Get auth preference
getAuthPreference: async (
url: string,
): Promise<AuthPreference | undefined> => {
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

View File

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

View File

@@ -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<string, RelayState> = new Map();
private subscriptions: Map<string, () => void> = new Map();
private listeners: Set<(state: GlobalRelayState) => void> = new Set();
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;
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<AuthPreference | undefined> {
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<void> {
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<string, RelayState> = {};
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<string, AuthPreference> = {};
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();