feat: make relay-auth-manager production-ready

- authenticate() now waits for authenticated$ confirmation via
  firstValueFrom(race(...)) — resolves only when relay confirms,
  rejects on disconnect. Uses Promise.all to avoid unhandled rejections.
- Add retry() for relays stuck in "failed" state.
- Add removePreference() to prevent unbounded preference growth.
- Add normalizeUrl option for custom URL normalization — preferences and
  session rejections now consistently normalized via normalizeForKey().
- Wire Grimoire's normalizeRelayURL as the normalizer in relay-auth.ts.
- Update README with new methods, normalizeUrl option, retry lifecycle.
- 123 package tests passing.

https://claude.ai/code/session_01XqrjeQVtJKw9uC1XAw6rqd
This commit is contained in:
Claude
2026-02-19 22:04:48 +00:00
parent 7bb0179480
commit a8b90d9204
5 changed files with 440 additions and 44 deletions

View File

@@ -48,6 +48,7 @@ new RelayAuthManager(options: RelayAuthManagerOptions)
| `storageKey` | `string` | `"relay-auth-preferences"` | Key used in storage. |
| `challengeTTL` | `number` | `300000` (5 min) | How long a challenge stays pending before being filtered out. |
| `initialRelays` | `Iterable<AuthRelay>` | `[]` | Relays already in the pool at creation time. |
| `normalizeUrl` | `(url: string) => string` | adds `wss://`, strips trailing `/` | Custom URL normalizer. Applied to all URLs used as map keys (preferences, state lookups). Provide this if your app uses a different normalization (e.g., lowercase hostname, trailing slash). |
## Observables
@@ -78,15 +79,17 @@ Challenges that need user interaction. Already filtered — only includes relays
| Method | Description |
|--------|-------------|
| `authenticate(relayUrl)` | Accept a pending challenge. Signs and sends AUTH. Returns a Promise that resolves on success. Throws if no challenge, no signer, or relay not monitored. |
| `reject(relayUrl, rememberForSession?)` | Reject a challenge. If `rememberForSession` is `true`, suppresses future prompts for this relay until page reload. |
| `authenticate(relayUrl)` | Accept a pending challenge. Signs and sends AUTH. Returns a Promise that resolves when `authenticated$` confirms. Rejects if relay disconnects, auth fails, or preconditions aren't met (no challenge, no signer, relay not monitored). |
| `retry(relayUrl)` | Retry authentication for a relay in `"failed"` state. Re-reads the challenge from the relay. Same promise semantics as `authenticate()`. |
| `reject(relayUrl, rememberForSession?)` | Reject a challenge. If `rememberForSession` is `true` (default), suppresses future prompts for this relay until page reload. |
### Preferences
| Method | Description |
|--------|-------------|
| `setPreference(url, pref)` | Set `"always"`, `"never"`, or `"ask"` for a relay. Persisted to storage. |
| `setPreference(url, pref)` | Set `"always"`, `"never"`, or `"ask"` for a relay. Persisted to storage. URL is normalized for consistent matching. |
| `getPreference(url)` | Get preference for a relay, or `undefined`. |
| `removePreference(url)` | Remove a preference. Returns `true` if one existed. Persisted to storage. |
| `getAllPreferences()` | `ReadonlyMap<string, AuthPreference>` of all preferences. |
### Relay Monitoring
@@ -100,8 +103,8 @@ Challenges that need user interaction. Already filtered — only includes relays
| Method | Description |
|--------|-------------|
| `getRelayState(url)` | Get `RelayAuthState` for a single relay. |
| `getAllStates()` | Same as `states$.value`. |
| `getRelayState(url)` | Get `RelayAuthState` snapshot for a single relay. Returns a copy, not a live reference. |
| `getAllStates()` | Snapshot of all states. Same as `states$.value`. |
| `hasSignerAvailable()` | Whether a signer is currently available. |
### Lifecycle
@@ -117,12 +120,12 @@ Each relay progresses through these states:
```
none ──challenge──▶ challenge_received ──accept──▶ authenticating ──success──▶ authenticated
▲ │ │
│ reject failed
│ ▼
└──── disconnect ──── rejected failed
│ reject failed ◀──retry──┐
│ ▼ │ │
└──── disconnect ──── rejected failed ──────────┘
```
Disconnect from any state resets to `none`.
Disconnect from any state resets to `none`. Failed relays can be retried via `retry()`.
## Preferences

View File

@@ -16,6 +16,7 @@ function createMockRelay(url: string): AuthRelay & {
challenge$: BehaviorSubject<string | null>;
authenticated$: BehaviorSubject<boolean>;
} {
const authenticated$ = new BehaviorSubject<boolean>(false);
return {
url,
connected: false,
@@ -23,8 +24,12 @@ function createMockRelay(url: string): AuthRelay & {
challenge: null,
connected$: new BehaviorSubject<boolean>(false),
challenge$: new BehaviorSubject<string | null>(null),
authenticated$: new BehaviorSubject<boolean>(false),
authenticate: vi.fn().mockResolvedValue({ ok: true }),
authenticated$,
// Default mock: authenticate succeeds and relay confirms
authenticate: vi.fn().mockImplementation(() => {
authenticated$.next(true);
return Promise.resolve({ ok: true });
}),
};
}
@@ -317,6 +322,60 @@ describe("RelayAuthManager", () => {
expect(relay.authenticate).toHaveBeenCalledWith(signer);
});
it("should resolve only after authenticated$ confirms", async () => {
signer$.next(createMockSigner());
const relay = createMockRelay("wss://relay.example.com");
relay.connected$.next(true);
// Override: authenticate succeeds but doesn't confirm via observable
let resolveAuth!: () => void;
(relay.authenticate as ReturnType<typeof vi.fn>).mockImplementation(
() => new Promise<void>((r) => (resolveAuth = r)),
);
manager.monitorRelay(relay);
relay.challenge$.next("test-challenge");
let resolved = false;
const authPromise = manager
.authenticate("wss://relay.example.com")
.then(() => {
resolved = true;
});
// relay.authenticate() hasn't resolved yet
expect(resolved).toBe(false);
// relay.authenticate() resolves, but authenticated$ hasn't confirmed
resolveAuth();
await Promise.resolve(); // flush microtask
expect(resolved).toBe(false);
// Now relay confirms authentication
relay.authenticated$.next(true);
await authPromise;
expect(resolved).toBe(true);
});
it("should reject if relay disconnects during authentication", async () => {
signer$.next(createMockSigner());
const relay = createMockRelay("wss://relay.example.com");
relay.connected$.next(true);
// Override: authenticate never resolves on its own
(relay.authenticate as ReturnType<typeof vi.fn>).mockImplementation(
() => new Promise(() => {}),
);
manager.monitorRelay(relay);
relay.challenge$.next("test-challenge");
const authPromise = manager.authenticate("wss://relay.example.com");
// Disconnect during auth
relay.connected$.next(false);
await expect(authPromise).rejects.toThrow("disconnected during");
});
it("should transition to authenticating status during auth", async () => {
signer$.next(createMockSigner());
@@ -325,13 +384,17 @@ describe("RelayAuthManager", () => {
manager.monitorRelay(relay);
relay.challenge$.next("test-challenge");
// Start auth (don't await yet)
const authPromise = manager.authenticate("wss://relay.example.com");
// Subscribe BEFORE starting auth to capture intermediate states
const states: string[] = [];
manager.states$.subscribe((s) => {
const st = s.get("wss://relay.example.com");
if (st) states.push(st.status);
});
const state = manager.getRelayState("wss://relay.example.com");
expect(state!.status).toBe("authenticating");
await manager.authenticate("wss://relay.example.com");
await authPromise;
// authenticating must have appeared in the emission history
expect(states).toContain("authenticating");
});
it("should throw if no relay is being monitored", async () => {
@@ -400,6 +463,96 @@ describe("RelayAuthManager", () => {
});
});
describe("retry", () => {
it("should retry authentication for a relay in failed state", async () => {
const signer = createMockSigner();
signer$.next(signer);
const relay = createMockRelay("wss://relay.example.com");
relay.connected$.next(true);
// First attempt fails
(relay.authenticate as ReturnType<typeof vi.fn>).mockRejectedValueOnce(
new Error("auth failed"),
);
manager.monitorRelay(relay);
relay.challenge$.next("test-challenge");
await expect(
manager.authenticate("wss://relay.example.com"),
).rejects.toThrow();
expect(manager.getRelayState("wss://relay.example.com")!.status).toBe(
"failed",
);
// Set challenge directly on relay without triggering observable state transition
// (In production, relay.challenge is a getter synced with challenge$)
(relay as Record<string, unknown>).challenge = "retry-challenge";
// Retry succeeds (default mock behavior restores after mockRejectedValueOnce)
await manager.retry("wss://relay.example.com");
expect(relay.authenticate).toHaveBeenCalledTimes(2);
});
it("should throw if relay is not in failed state", async () => {
signer$.next(createMockSigner());
const relay = createMockRelay("wss://relay.example.com");
relay.connected$.next(true);
manager.monitorRelay(relay);
relay.challenge$.next("test-challenge");
await expect(manager.retry("wss://relay.example.com")).rejects.toThrow(
'expected "failed"',
);
});
it("should throw if no challenge is available on the relay", async () => {
signer$.next(createMockSigner());
const relay = createMockRelay("wss://relay.example.com");
relay.connected$.next(true);
(relay.authenticate as ReturnType<typeof vi.fn>).mockRejectedValueOnce(
new Error("fail"),
);
manager.monitorRelay(relay);
relay.challenge$.next("test-challenge");
await expect(
manager.authenticate("wss://relay.example.com"),
).rejects.toThrow();
// Relay has no challenge anymore
// relay.challenge is still null by default on the mock object
await expect(manager.retry("wss://relay.example.com")).rejects.toThrow(
"No auth challenge available",
);
});
it("should throw if no signer is available", async () => {
signer$.next(createMockSigner());
const relay = createMockRelay("wss://relay.example.com");
relay.connected$.next(true);
(relay.authenticate as ReturnType<typeof vi.fn>).mockRejectedValueOnce(
new Error("fail"),
);
manager.monitorRelay(relay);
relay.challenge$.next("test-challenge");
await expect(
manager.authenticate("wss://relay.example.com"),
).rejects.toThrow();
// Remove signer and set challenge directly (without triggering state transition)
signer$.next(null);
(relay as Record<string, unknown>).challenge = "retry-challenge";
await expect(manager.retry("wss://relay.example.com")).rejects.toThrow(
"No signer available",
);
});
});
describe("rejection", () => {
it("should reject auth and update status", () => {
const relay = createMockRelay("wss://relay.example.com");
@@ -472,7 +625,8 @@ describe("RelayAuthManager", () => {
expect(relay.authenticate).toHaveBeenCalledWith(signer);
const state = manager.getRelayState("wss://relay.example.com");
expect(state!.status).toBe("authenticating");
// With auto-confirm mock, state may already be authenticated
expect(["authenticating", "authenticated"]).toContain(state!.status);
});
it("should NOT auto-authenticate when preference is always but no signer", () => {
@@ -628,6 +782,104 @@ describe("RelayAuthManager", () => {
});
});
describe("removePreference", () => {
it("should remove an existing preference", () => {
manager.setPreference("wss://relay.example.com", "always");
expect(manager.getPreference("wss://relay.example.com")).toBe("always");
const removed = manager.removePreference("wss://relay.example.com");
expect(removed).toBe(true);
expect(manager.getPreference("wss://relay.example.com")).toBeUndefined();
});
it("should return false when removing a non-existent preference", () => {
const removed = manager.removePreference("wss://unknown.relay.com");
expect(removed).toBe(false);
});
it("should persist removal to storage", () => {
manager.setPreference("wss://a.relay.com", "always");
manager.setPreference("wss://b.relay.com", "never");
manager.removePreference("wss://a.relay.com");
const saved = JSON.parse(storage.store["relay-auth-preferences"] || "{}");
expect(saved["wss://a.relay.com"]).toBeUndefined();
expect(saved["wss://b.relay.com"]).toBe("never");
});
it("should not appear in getAllPreferences after removal", () => {
manager.setPreference("wss://a.relay.com", "always");
manager.setPreference("wss://b.relay.com", "never");
manager.removePreference("wss://a.relay.com");
const all = manager.getAllPreferences();
expect(all.size).toBe(1);
expect(all.has("wss://a.relay.com")).toBe(false);
});
});
describe("custom normalizeUrl", () => {
it("should use custom normalizer for preference lookups", () => {
manager.destroy();
// Normalizer that lowercases and adds trailing slash
const normalizeUrl = (url: string) => {
let u = url.trim().toLowerCase();
if (!u.startsWith("wss://")) u = `wss://${u}`;
if (!u.endsWith("/")) u += "/";
return u;
};
const ctx = createManager({ normalizeUrl });
manager = ctx.manager;
// Set preference with one form
manager.setPreference("wss://Relay.Example.Com", "always");
// Look up with a different form — should match via normalizer
expect(manager.getPreference("wss://relay.example.com/")).toBe("always");
});
it("should use custom normalizer for relay state lookups", () => {
manager.destroy();
const normalizeUrl = (url: string) =>
url.toLowerCase().replace(/\/+$/, "");
const ctx = createManager({ normalizeUrl });
manager = ctx.manager;
const relay = createMockRelay("wss://relay.example.com");
relay.connected$.next(true);
manager.monitorRelay(relay);
// Look up with different case
const state = manager.getRelayState("wss://Relay.Example.Com");
expect(state).toBeDefined();
expect(state!.url).toBe("wss://relay.example.com");
});
it("should handle normalizer errors gracefully", () => {
manager.destroy();
const normalizeUrl = () => {
throw new Error("normalization failed");
};
const ctx = createManager({ normalizeUrl });
manager = ctx.manager;
const relay = createMockRelay("wss://relay.example.com");
manager.monitorRelay(relay);
// Direct lookup still works (fast path)
expect(manager.getRelayState("wss://relay.example.com")).toBeDefined();
// Mismatched lookup returns undefined (normalizer throws, falls through)
expect(manager.getRelayState("wss://relay.example.com/")).toBeUndefined();
});
});
describe("signer lifecycle", () => {
it("should not show pending challenges when no signer", () => {
const relay = createMockRelay("wss://relay.example.com");
@@ -912,9 +1164,6 @@ describe("RelayAuthManager", () => {
// 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",
);
@@ -992,12 +1241,11 @@ describe("RelayAuthManager", () => {
"challenge_received",
);
// 2. User authenticates
// 2. User authenticates (mock auto-confirms via authenticated$)
await manager.authenticate("wss://relay.example.com");
expect(relay.authenticate).toHaveBeenCalledWith(signer);
// 3. Relay confirms authentication
relay.authenticated$.next(true);
// 3. State should now be authenticated
expect(manager.getRelayState("wss://relay.example.com")!.status).toBe(
"authenticated",
);
@@ -1022,9 +1270,6 @@ describe("RelayAuthManager", () => {
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", () => {

View File

@@ -1,5 +1,11 @@
import { BehaviorSubject, type Subscription, combineLatest } from "rxjs";
import { startWith } from "rxjs/operators";
import {
BehaviorSubject,
type Subscription,
combineLatest,
firstValueFrom,
race,
} from "rxjs";
import { filter, startWith, take, map as rxMap } from "rxjs/operators";
import { transitionAuthState, type AuthEvent } from "./auth-state-machine.js";
import type {
AuthPreference,
@@ -26,6 +32,7 @@ export class RelayAuthManager {
private readonly storageKey: string;
private readonly challengeTTL: number;
private readonly storage?: RelayAuthManagerOptions["storage"];
private readonly normalizeUrl: (url: string) => string;
private signer: AuthSigner | null = null;
private readonly relaySubscriptions = new Map<string, Subscription>();
@@ -48,6 +55,7 @@ export class RelayAuthManager {
this.storage = options.storage;
this.storageKey = options.storageKey ?? DEFAULT_STORAGE_KEY;
this.challengeTTL = options.challengeTTL ?? DEFAULT_CHALLENGE_TTL;
this.normalizeUrl = options.normalizeUrl ?? defaultNormalizeUrl;
this.states$ = new BehaviorSubject<ReadonlyMap<string, RelayAuthState>>(
new Map(),
@@ -138,6 +146,9 @@ export class RelayAuthManager {
/**
* Authenticate with a relay. Requires a pending challenge and available signer.
*
* Resolves when the relay confirms authentication (authenticated$ emits true).
* Rejects if the relay disconnects, the auth call throws, or the relay never confirms.
*/
async authenticate(relayUrl: string): Promise<void> {
const url = this.resolveRelayUrl(relayUrl);
@@ -162,13 +173,103 @@ export class RelayAuthManager {
this.emitState();
try {
await relay.authenticate(this.signer);
// Set up a race: authenticated$ confirms vs connected$ drops
const confirmed = firstValueFrom(
race(
relay.authenticated$.pipe(
filter((auth) => auth === true),
take(1),
rxMap(() => true as const),
),
relay.connected$.pipe(
filter((connected) => connected === false),
take(1),
rxMap(() => {
throw new Error(
`Relay ${relayUrl} disconnected during authentication`,
);
}),
),
),
);
// Use Promise.all so both promises are awaited together.
// If relay.authenticate() hangs but disconnect fires, confirmed rejects
// and Promise.all propagates it immediately (no unhandled rejection).
await Promise.all([relay.authenticate(this.signer), confirmed]);
// authenticated$ subscription will handle the success state update
} catch (error) {
state.status = "failed";
state.challenge = null;
state.challengeReceivedAt = null;
this.emitState();
// Only mark as failed if still in authenticating state
if (state.status === "authenticating") {
state.status = "failed";
state.challenge = null;
state.challengeReceivedAt = null;
this.emitState();
}
throw error;
}
}
/**
* Retry authentication for a relay in "failed" state.
* Re-uses the original challenge if still available from the relay.
*/
async retry(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.status !== "failed") {
throw new Error(
`Cannot retry relay ${relayUrl}: status is "${state.status}", expected "failed"`,
);
}
if (!this.signer) {
throw new Error("No signer available for authentication");
}
// Use the relay's current challenge (it may have sent a fresh one)
const challenge = relay.challenge;
if (!challenge) {
throw new Error(`No auth challenge available for ${relayUrl}`);
}
state.status = "authenticating";
state.challenge = challenge;
state.challengeReceivedAt = Date.now();
this.emitState();
try {
const confirmed = firstValueFrom(
race(
relay.authenticated$.pipe(
filter((auth) => auth === true),
take(1),
rxMap(() => true as const),
),
relay.connected$.pipe(
filter((connected) => connected === false),
take(1),
rxMap(() => {
throw new Error(
`Relay ${relayUrl} disconnected during authentication`,
);
}),
),
),
);
await Promise.all([relay.authenticate(this.signer), confirmed]);
} catch (error) {
if (state.status === "authenticating") {
state.status = "failed";
state.challenge = null;
state.challengeReceivedAt = null;
this.emitState();
}
throw error;
}
}
@@ -195,7 +296,7 @@ export class RelayAuthManager {
}
if (rememberForSession) {
this.sessionRejections.add(url);
this.sessionRejections.add(this.normalizeForKey(url));
}
this.emitState();
@@ -205,7 +306,7 @@ export class RelayAuthManager {
* Set auth preference for a relay. Persists to storage if available.
*/
setPreference(relayUrl: string, preference: AuthPreference): void {
const url = this.resolveRelayUrl(relayUrl);
const url = this.normalizeForKey(relayUrl);
this.preferences.set(url, preference);
this.savePreferences();
this.emitState();
@@ -215,10 +316,24 @@ export class RelayAuthManager {
* Get auth preference for a relay.
*/
getPreference(relayUrl: string): AuthPreference | undefined {
const url = this.resolveRelayUrl(relayUrl);
const url = this.normalizeForKey(relayUrl);
return this.preferences.get(url);
}
/**
* Remove auth preference for a relay. Persists to storage if available.
* Returns true if a preference was removed, false if none existed.
*/
removePreference(relayUrl: string): boolean {
const url = this.normalizeForKey(relayUrl);
const deleted = this.preferences.delete(url);
if (deleted) {
this.savePreferences();
this.emitState();
}
return deleted;
}
/**
* Get all auth preferences.
*/
@@ -301,7 +416,7 @@ export class RelayAuthManager {
}
// Priority 3: New challenge (or challenge changed)
else if (values.challenge && values.challenge !== state.challenge) {
const preference = this.preferences.get(url);
const preference = this.preferences.get(this.normalizeForKey(url));
authEvent = {
type: "CHALLENGE_RECEIVED",
challenge: values.challenge,
@@ -362,7 +477,7 @@ export class RelayAuthManager {
for (const [url, state] of this._relayStates) {
if (state.status === "challenge_received" && state.challenge) {
const pref = this.preferences.get(url);
const pref = this.preferences.get(this.normalizeForKey(url));
if (pref === "always") {
const relay = this.monitoredRelays.get(url);
if (!relay) continue;
@@ -380,9 +495,10 @@ export class RelayAuthManager {
}
private shouldPrompt(url: string): boolean {
const pref = this.preferences.get(url);
const normalized = this.normalizeForKey(url);
const pref = this.preferences.get(normalized);
if (pref === "never") return false;
if (this.sessionRejections.has(url)) return false;
if (this.sessionRejections.has(normalized)) return false;
return true;
}
@@ -390,13 +506,29 @@ export class RelayAuthManager {
return now - receivedAt > this.challengeTTL;
}
/**
* Normalize a URL for use as a map key (preferences, session rejections).
* Falls back to the raw URL if the normalizer throws.
*/
private normalizeForKey(url: string): string {
try {
return this.normalizeUrl(url);
} catch {
return url;
}
}
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;
try {
const normalized = this.normalizeUrl(url);
if (this._relayStates.has(normalized)) return normalized;
} catch {
// Normalization failed, fall through
}
return url;
}
@@ -479,9 +611,10 @@ function snapshotStates(
}
/**
* Basic URL normalization for relay URLs.
* Default URL normalization for relay URLs.
* Adds wss:// prefix if missing and strips trailing slashes.
*/
function normalizeUrl(url: string): string {
function defaultNormalizeUrl(url: string): string {
let u = url.trim();
if (!u.startsWith("ws://") && !u.startsWith("wss://")) {
u = `wss://${u}`;

View File

@@ -102,4 +102,14 @@ export interface RelayAuthManagerOptions {
/** Initial relays to monitor (for relays already in the pool at creation time) */
initialRelays?: Iterable<AuthRelay>;
/**
* Custom URL normalizer for consistent relay URL matching.
* Called on all URLs before they're used as Map keys (preferences, state, etc.).
* Default: adds wss:// prefix and strips trailing slashes.
*
* Provide this if your app uses a different normalization (e.g., lowercase hostname,
* trailing slash convention) to ensure preferences match relay state.
*/
normalizeUrl?: (url: string) => string;
}

View File

@@ -2,6 +2,7 @@ import { map } from "rxjs/operators";
import { RelayAuthManager } from "relay-auth-manager";
import type { AuthSigner, AuthPreference } from "relay-auth-manager";
import { canAccountSign } from "@/hooks/useAccount";
import { normalizeRelayURL } from "@/lib/relay-url";
import pool from "./relay-pool";
import accountManager from "./accounts";
import db from "./db";
@@ -68,6 +69,10 @@ const relayAuthManager = new RelayAuthManager({
storage: localStorage,
storageKey: STORAGE_KEY,
// Use Grimoire's normalizer (lowercase + trailing slash via applesauce)
// to ensure preference keys match relay URLs consistently.
normalizeUrl: normalizeRelayURL,
});
// Run one-time migration from Dexie → localStorage