mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-06-16 17:48:34 +02:00
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:
13
package-lock.json
generated
13
package-lock.json
generated
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,9 @@
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
"packages/*"
|
||||
],
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
|
||||
13
packages/relay-auth-manager/package.json
Normal file
13
packages/relay-auth-manager/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
14
packages/relay-auth-manager/src/index.ts
Normal file
14
packages/relay-auth-manager/src/index.ts
Normal 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";
|
||||
471
packages/relay-auth-manager/src/relay-auth-manager.ts
Normal file
471
packages/relay-auth-manager/src/relay-auth-manager.ts
Normal 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(/\/+$/, "");
|
||||
}
|
||||
105
packages/relay-auth-manager/src/types.ts
Normal file
105
packages/relay-auth-manager/src/types.ts
Normal 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>;
|
||||
}
|
||||
15
packages/relay-auth-manager/tsconfig.json
Normal file
15
packages/relay-auth-manager/tsconfig.json
Normal 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"]
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
32
src/services/relay-auth.ts
Normal file
32
src/services/relay-auth.ts
Normal 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;
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user