mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-06-04 09:41:13 +02:00
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:
@@ -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
|
||||
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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}`;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user