refactor: extract relay auth manager into standalone package (#249)

* 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

* fix: prevent auth prompts when signer is unavailable

Three bugs fixed:

1. Race condition in emitState(): states$ was emitted before
   pendingChallenges$, so relay-state-manager's states$ subscriber
   would read stale pendingChallenges$.value. Now pendingChallenges$
   is emitted first for consistent reads.

2. relay-state-manager only subscribed to states$, missing
   pendingChallenges$ changes. Now subscribes to both.

3. canAccountSign used constructor.name which is fragile under
   minification. Now uses account.type !== "readonly" (stable
   property from applesauce-accounts).

Added 3 regression tests verifying pendingChallenges$.value
consistency when observed from states$ subscribers.

https://claude.ai/code/session_01XqrjeQVtJKw9uC1XAw6rqd

* fix: migrate auth preferences from Dexie to localStorage

One-time migration preserves existing user preferences: reads from
Dexie relayAuthPreferences table, writes to localStorage, injects
into the running manager, then clears the Dexie table. Skips if
localStorage already has data (idempotent).

Removes the DexieAuthStorage adapter — sync/async impedance mismatch
made it fragile. localStorage is the right fit for the sync
getItem/setItem interface.

https://claude.ai/code/session_01XqrjeQVtJKw9uC1XAw6rqd

* docs: add README for relay-auth-manager package

Covers constructor options, observables, methods, auth lifecycle,
preferences, storage interface, state machine, and dependency
interfaces.

https://claude.ai/code/session_01XqrjeQVtJKw9uC1XAw6rqd

* fix: address 3 bugs in relay-auth-manager

1. State machine: authenticated + CHALLENGE_RECEIVED with "never"
   preference now correctly auto-rejects instead of entering a dead
   challenge_received state that nobody acts on.

2. Auto-auth error handlers now guard with `status === "authenticating"`
   before overwriting state, preventing a late-resolving promise from
   clobbering a valid state transition (e.g. disconnect → "none").

3. API no longer leaks mutable internal references. getRelayState(),
   getAllStates(), getAllPreferences(), and states$ emissions all return
   shallow copies. Previously, consumers holding a reference to a
   previous emission would see it silently mutate.

All three fixes include regression tests (110 tests, up from 103).

https://claude.ai/code/session_01XqrjeQVtJKw9uC1XAw6rqd

* 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

* chore: prepare relay-auth-manager for npm publishing

- Point exports/main/types to dist/ for correct npm resolution
- Add tsconfig.build.json for emitting ESM JS + declarations
- Add build/clean/prepublishOnly scripts
- Add LICENSE, description, keywords, author, files field
- Add Vite resolve alias + tsconfig paths for workspace dev
  (resolves source directly, no pre-build needed for dev)
- Fix TypeScript strict errors in test file
- Clean npm pack output: dist/, README.md, LICENSE only

https://claude.ai/code/session_01XqrjeQVtJKw9uC1XAw6rqd

---------

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Alejandro
2026-02-20 08:52:50 +01:00
committed by GitHub
parent c8fb1b005b
commit 6c2adc01e0
22 changed files with 2827 additions and 519 deletions

13
package-lock.json generated
View File

@@ -8,6 +8,9 @@
"name": "grimoire",
"version": "0.1.0",
"license": "MIT",
"workspaces": [
"packages/*"
],
"dependencies": {
"@fiatjaf/git-natural-api": "npm:@jsr/fiatjaf__git-natural-api@^0.2.3",
"@radix-ui/react-accordion": "^1.2.12",
@@ -11521,6 +11524,10 @@
"integrity": "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==",
"license": "MIT"
},
"node_modules/relay-auth-manager": {
"resolved": "packages/relay-auth-manager",
"link": true
},
"node_modules/remark": {
"version": "15.0.1",
"resolved": "https://registry.npmjs.org/remark/-/remark-15.0.1.tgz",
@@ -13308,6 +13315,12 @@
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"packages/relay-auth-manager": {
"version": "0.1.0",
"peerDependencies": {
"rxjs": "^7.0.0"
}
}
}
}

View File

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

View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 Alejandro Gómez
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -0,0 +1,179 @@
# relay-auth-manager
Generic NIP-42 relay authentication manager for Nostr clients. Handles auth challenges, user preferences, and auto-auth — framework and storage agnostic.
## Install
This is a workspace package. It has a single peer dependency on `rxjs >= 7`.
## Quick Start
```typescript
import { RelayAuthManager } from "relay-auth-manager";
const manager = new RelayAuthManager({
pool, // relay pool (applesauce-relay compatible)
signer$, // Observable<AuthSigner | null>
storage: localStorage, // optional persistence
});
// React to pending challenges (need user interaction)
manager.pendingChallenges$.subscribe((challenges) => {
for (const c of challenges) {
showAuthPrompt(c.relayUrl, c.challenge);
}
});
// User accepts
await manager.authenticate("wss://relay.example.com");
// User rejects (optionally remember for session)
manager.reject("wss://relay.example.com", true);
// Set a persistent preference
manager.setPreference("wss://relay.example.com", "always");
```
## Constructor Options
```typescript
new RelayAuthManager(options: RelayAuthManagerOptions)
```
| Option | Type | Default | Description |
| --------------- | -------------------------------- | ---------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `pool` | `AuthRelayPool` | _required_ | Relay pool to monitor. Relays are auto-monitored on `add$` and cleaned up on `remove$`. |
| `signer$` | `Observable<AuthSigner \| null>` | _required_ | Current signer. Emit `null` when logged out or read-only. |
| `storage` | `AuthPreferenceStorage` | `undefined` | Persistent storage for preferences. Anything with `getItem`/`setItem` works. |
| `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
### `states$: BehaviorSubject<ReadonlyMap<string, RelayAuthState>>`
All relay auth states. Emits a new Map on any state change.
```typescript
manager.states$.subscribe((states) => {
for (const [url, state] of states) {
console.log(url, state.status, state.connected);
}
});
```
### `pendingChallenges$: BehaviorSubject<PendingAuthChallenge[]>`
Challenges that need user interaction. Already filtered — only includes relays where:
- Status is `"challenge_received"`
- A signer is available
- Challenge hasn't expired
- Preference isn't `"never"`
- User hasn't rejected this session
## Methods
### Authentication
| Method | Description |
| --------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `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. 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
| Method | Description |
| --------------------- | ------------------------------------------------------------------------------------------ |
| `monitorRelay(relay)` | Start monitoring a relay for challenges. Idempotent. Called automatically for pool relays. |
| `unmonitorRelay(url)` | Stop monitoring. Called automatically on pool `remove$`. |
### State Queries
| Method | Description |
| ---------------------- | --------------------------------------------------------------------------------------- |
| `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
| Method | Description |
| ----------- | -------------------------------------------------------------------------- |
| `destroy()` | Unsubscribe everything, complete observables. Safe to call multiple times. |
## Auth Lifecycle
Each relay progresses through these states:
```
none ──challenge──▶ challenge_received ──accept──▶ authenticating ──success──▶ authenticated
▲ │ │
│ reject failed ◀──retry──┐
│ ▼ │ │
└──── disconnect ──── rejected failed ──────────┘
```
Disconnect from any state resets to `none`. Failed relays can be retried via `retry()`.
## Preferences
Preferences control what happens when a challenge arrives:
| Preference | Behavior |
| ---------- | --------------------------------------------------------------------- |
| `"always"` | Auto-authenticate (no user prompt). Waits for signer if unavailable. |
| `"never"` | Auto-reject (no user prompt). |
| `"ask"` | Show in `pendingChallenges$` for user to decide. This is the default. |
## Storage
Pass any object with `getItem(key): string | null` and `setItem(key, value): void`. Both `localStorage` and `sessionStorage` work out of the box.
Preferences are stored as JSON:
```json
{
"wss://relay.example.com": "always",
"wss://other.relay.com": "never"
}
```
Custom storage backends (IndexedDB, SQLite, etc.) can be used by wrapping them in the sync interface — e.g., using an in-memory cache with async write-through.
## State Machine
The auth state machine is exported as a pure function for direct use or testing:
```typescript
import { transitionAuthState } from "relay-auth-manager";
const result = transitionAuthState("none", {
type: "CHALLENGE_RECEIVED",
challenge: "abc123",
preference: "always",
});
// { newStatus: "authenticating", shouldAutoAuth: true, clearChallenge: false }
```
## Interfaces
The package defines minimal interfaces for its dependencies so it doesn't import `applesauce-relay` directly:
- **`AuthRelayPool`** — `relay(url)`, `add$`, `remove$`
- **`AuthRelay`** — `url`, `connected$`, `challenge$`, `authenticated$`, `authenticate(signer)`
- **`AuthSigner`** — `signEvent(event)`
These are compatible with applesauce-relay but any implementation that satisfies the shapes will work.

View File

@@ -0,0 +1,36 @@
{
"name": "relay-auth-manager",
"version": "0.1.0",
"description": "Generic NIP-42 relay authentication manager for Nostr clients. Framework and storage agnostic.",
"type": "module",
"license": "MIT",
"author": "Alejandro Gómez",
"keywords": [
"nostr",
"nip-42",
"relay",
"authentication",
"auth"
],
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"files": [
"dist",
"README.md",
"LICENSE"
],
"scripts": {
"build": "tsc --project tsconfig.build.json",
"clean": "rm -rf dist",
"prepublishOnly": "npm run clean && npm run build"
},
"peerDependencies": {
"rxjs": "^7.0.0"
}
}

View File

@@ -1,16 +1,15 @@
import { describe, it, expect } from "vitest";
import { transitionAuthState, type AuthEvent } from "./auth-state-machine";
import type { AuthStatus } from "@/types/relay-state";
import { transitionAuthState, type AuthEvent } from "../auth-state-machine.js";
import type { AuthStatus } from "../types.js";
describe("Auth State Machine", () => {
describe("none state transitions", () => {
it("should transition to challenge_received when receiving challenge with ask preference", () => {
describe("none state", () => {
it("should transition to challenge_received on challenge with ask preference", () => {
const result = transitionAuthState("none", {
type: "CHALLENGE_RECEIVED",
challenge: "test-challenge",
preference: "ask",
});
expect(result.newStatus).toBe("challenge_received");
expect(result.shouldAutoAuth).toBe(false);
expect(result.clearChallenge).toBe(false);
@@ -22,7 +21,6 @@ describe("Auth State Machine", () => {
challenge: "test-challenge",
preference: "always",
});
expect(result.newStatus).toBe("authenticating");
expect(result.shouldAutoAuth).toBe(true);
expect(result.clearChallenge).toBe(false);
@@ -34,7 +32,6 @@ describe("Auth State Machine", () => {
challenge: "test-challenge",
preference: "never",
});
expect(result.newStatus).toBe("rejected");
expect(result.shouldAutoAuth).toBe(false);
expect(result.clearChallenge).toBe(true);
@@ -45,127 +42,192 @@ 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 auto-reject new challenge with never preference", () => {
const result = transitionAuthState("authenticated", {
type: "CHALLENGE_RECEIVED",
challenge: "new-challenge",
preference: "never",
});
expect(result.newStatus).toBe("rejected");
expect(result.shouldAutoAuth).toBe(false);
expect(result.clearChallenge).toBe(true);
});
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 +237,6 @@ describe("Auth State Machine", () => {
challenge: "retry-challenge",
preference: "always",
});
expect(result.newStatus).toBe("authenticating");
expect(result.shouldAutoAuth).toBe(true);
});
@@ -186,113 +247,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 +413,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");
}
}
});
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,7 @@
import type { AuthStatus, AuthPreference } from "@/types/relay-state";
import type { AuthPreference, AuthStatus } from "./types.js";
/**
* Events that trigger auth state transitions
* Events that trigger auth state transitions.
*/
export type AuthEvent =
| {
@@ -16,25 +16,28 @@ export type AuthEvent =
| { type: "DISCONNECTED" };
/**
* Result of an auth state transition
* Result of an auth state transition.
*/
export interface AuthTransitionResult {
/** The new auth status after the transition */
newStatus: AuthStatus;
shouldAutoAuth: boolean; // True if preference is "always" and should auto-authenticate
clearChallenge: boolean; // True if challenge should be cleared
/** True if the manager should automatically authenticate (preference is "always") */
shouldAutoAuth: boolean;
/** True if the current challenge should be cleared */
clearChallenge: boolean;
}
/**
* Pure function implementing the auth state machine
* Pure function implementing the NIP-42 auth state machine.
*
* @param currentStatus - Current auth status
* @param event - Event triggering the transition
* @returns New state and any side effects to perform
* @returns Transition result with new status and side-effect flags
*/
export function transitionAuthState(
currentStatus: AuthStatus,
event: AuthEvent,
): AuthTransitionResult {
// Default result - no change
const noChange: AuthTransitionResult = {
newStatus: currentStatus,
shouldAutoAuth: false,
@@ -44,7 +47,6 @@ export function transitionAuthState(
switch (currentStatus) {
case "none":
if (event.type === "CHALLENGE_RECEIVED") {
// Check if we should auto-authenticate based on preference
if (event.preference === "always") {
return {
newStatus: "authenticating",
@@ -52,14 +54,12 @@ export function transitionAuthState(
clearChallenge: false,
};
} else if (event.preference === "never") {
// Immediately reject if preference is never
return {
newStatus: "rejected",
shouldAutoAuth: false,
clearChallenge: true,
};
} else {
// Default: ask user
return {
newStatus: "challenge_received",
shouldAutoAuth: false,
@@ -83,6 +83,13 @@ export function transitionAuthState(
shouldAutoAuth: false,
clearChallenge: true,
};
case "AUTH_SUCCESS":
// Relay confirmed auth while prompt was still showing
return {
newStatus: "authenticated",
shouldAutoAuth: false,
clearChallenge: true,
};
case "DISCONNECTED":
return {
newStatus: "none",
@@ -125,7 +132,6 @@ export function transitionAuthState(
clearChallenge: true,
};
}
// If we get a new challenge while authenticated, transition to challenge_received
if (event.type === "CHALLENGE_RECEIVED") {
if (event.preference === "always") {
return {
@@ -133,6 +139,12 @@ export function transitionAuthState(
shouldAutoAuth: true,
clearChallenge: false,
};
} else if (event.preference === "never") {
return {
newStatus: "rejected",
shouldAutoAuth: false,
clearChallenge: true,
};
}
return {
newStatus: "challenge_received",
@@ -144,7 +156,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 +186,6 @@ export function transitionAuthState(
return noChange;
default: {
// Exhaustive check
const _exhaustive: never = currentStatus;
return _exhaustive;
}

View File

@@ -0,0 +1,14 @@
export { RelayAuthManager } from "./relay-auth-manager.js";
export { transitionAuthState } from "./auth-state-machine.js";
export type { AuthEvent, AuthTransitionResult } from "./auth-state-machine.js";
export type {
AuthStatus,
AuthPreference,
AuthSigner,
AuthRelay,
AuthRelayPool,
AuthPreferenceStorage,
RelayAuthState,
RelayAuthManagerOptions,
PendingAuthChallenge,
} from "./types.js";

View File

@@ -0,0 +1,623 @@
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,
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 readonly normalizeUrl: (url: string) => string;
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.normalizeUrl = options.normalizeUrl ?? defaultNormalizeUrl;
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.
*
* 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);
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 {
// 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) {
// 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;
}
}
/**
* 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(this.normalizeForKey(url));
}
this.emitState();
}
/**
* Set auth preference for a relay. Persists to storage if available.
*/
setPreference(relayUrl: string, preference: AuthPreference): void {
const url = this.normalizeForKey(relayUrl);
this.preferences.set(url, preference);
this.savePreferences();
this.emitState();
}
/**
* Get auth preference for a relay.
*/
getPreference(relayUrl: string): AuthPreference | undefined {
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.
*/
getAllPreferences(): ReadonlyMap<string, AuthPreference> {
return new Map(this.preferences);
}
/**
* Get auth state for a specific relay. Returns a snapshot (not a live reference).
*/
getRelayState(relayUrl: string): RelayAuthState | undefined {
const url = this.resolveRelayUrl(relayUrl);
const state = this._relayStates.get(url);
return state ? { ...state } : undefined;
}
/**
* Get all relay auth states. Returns a snapshot (not a live reference).
*/
getAllStates(): ReadonlyMap<string, RelayAuthState> {
return snapshotStates(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(this.normalizeForKey(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);
// Only mark as failed if still in authenticating state — the relay
// may have disconnected or received a new event while we were awaiting
if (s && s.status === "authenticating") {
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(this.normalizeForKey(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 === "authenticating") {
s.status = "failed";
this.emitState();
}
});
}
}
}
}
private shouldPrompt(url: string): boolean {
const normalized = this.normalizeForKey(url);
const pref = this.preferences.get(normalized);
if (pref === "never") return false;
if (this.sessionRejections.has(normalized)) return false;
return true;
}
private isChallengeExpired(receivedAt: number, now = Date.now()): boolean {
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
try {
const normalized = this.normalizeUrl(url);
if (this._relayStates.has(normalized)) return normalized;
} catch {
// Normalization failed, fall through
}
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 {
// Derive and emit pending challenges FIRST.
// This ensures that subscribers to states$ who read pendingChallenges$.value
// see consistent, up-to-date data (not stale values from a previous emission).
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);
// Emit states snapshot after pendingChallenges is updated
this.states$.next(snapshotStates(this._relayStates));
}
}
/**
* Create a deep-copied snapshot of relay states so consumers
* never hold mutable references to internal objects.
*/
function snapshotStates(
states: Map<string, RelayAuthState>,
): Map<string, RelayAuthState> {
const copy = new Map<string, RelayAuthState>();
for (const [url, state] of states) {
copy.set(url, { ...state });
}
return copy;
}
/**
* Default URL normalization for relay URLs.
* Adds wss:// prefix if missing and strips trailing slashes.
*/
function defaultNormalizeUrl(url: string): string {
let u = url.trim();
if (!u.startsWith("ws://") && !u.startsWith("wss://")) {
u = `wss://${u}`;
}
return u.replace(/\/+$/, "");
}

View File

@@ -0,0 +1,115 @@
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>;
/**
* 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

@@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "Node16",
"moduleResolution": "Node16",
"strict": true,
"isolatedModules": true,
"skipLibCheck": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"outDir": "dist",
"rootDir": "src"
},
"include": ["src"],
"exclude": ["src/__tests__"]
}

View File

@@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"isolatedModules": true,
"skipLibCheck": true,
"noEmit": true
},
"include": ["src"]
}

View File

@@ -244,16 +244,9 @@ function RelayCard({ relay }: RelayCardProps) {
}
setIsSavingPreference(true);
try {
await setAuthPreference(relay.url, value);
toast.success("Preference saved");
} catch (error) {
toast.error(
`Failed to save preference: ${error instanceof Error ? error.message : "Unknown error"}`,
);
} finally {
setIsSavingPreference(false);
}
setAuthPreference(relay.url, value);
toast.success("Preference saved");
setIsSavingPreference(false);
}}
>
<DropdownMenuRadioItem value="ask">Ask</DropdownMenuRadioItem>

View File

@@ -155,7 +155,7 @@ export function GlobalAuthPrompt() {
challenge={challenge.challenge}
onAuthenticate={async (remember) => {
if (remember) {
await setAuthPreference(challenge.relayUrl, "always");
setAuthPreference(challenge.relayUrl, "always");
}
activeToasts.current.delete(key);
@@ -184,7 +184,7 @@ export function GlobalAuthPrompt() {
}}
onReject={async (remember) => {
if (remember) {
await setAuthPreference(challenge.relayUrl, "never");
setAuthPreference(challenge.relayUrl, "never");
}
rejectAuth(challenge.relayUrl, !remember);

View File

@@ -31,9 +31,7 @@ export function AppShell({ children, hideBottomBar = false }: AppShellProps) {
// Initialize global relay state manager
useEffect(() => {
relayStateManager.initialize().catch((err) => {
console.error("Failed to initialize relay state manager:", err);
});
relayStateManager.initialize();
}, []);
// Sync relay state with Jotai

View File

@@ -6,13 +6,15 @@ import accounts from "@/services/accounts";
* Check if an account can sign events
* Read-only accounts cannot sign and should not be prompted for auth
*
* Uses account.type (from applesauce-accounts) instead of constructor.name
* to be robust against minification.
*
* @param account - The account to check (can be undefined)
* @returns true if the account can sign, false otherwise
*/
export function canAccountSign(account: typeof accounts.active): boolean {
if (!account) return false;
const accountType = account.constructor.name;
return accountType !== "ReadonlyAccount";
return account.type !== "readonly";
}
/**

View File

@@ -50,16 +50,14 @@ export function useRelayState() {
return relayState?.relays[normalizedUrl];
},
// Get auth preference
getAuthPreference: async (
url: string,
): Promise<AuthPreference | undefined> => {
return await relayStateManager.getAuthPreference(url);
// Get auth preference (now synchronous)
getAuthPreference: (url: string): AuthPreference | undefined => {
return relayStateManager.getAuthPreference(url);
},
// Set auth preference
setAuthPreference: async (url: string, preference: AuthPreference) => {
await relayStateManager.setAuthPreference(url, preference);
// Set auth preference (now synchronous)
setAuthPreference: (url: string, preference: AuthPreference) => {
relayStateManager.setAuthPreference(url, preference);
},
// Authenticate with relay

View File

@@ -0,0 +1,81 @@
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";
const STORAGE_KEY = "relay-auth-preferences";
/**
* One-time migration: move auth preferences from Dexie to localStorage.
* Runs in the background on first load. After migration, Dexie rows are
* cleared so it doesn't run again.
*/
function migrateFromDexie() {
// Skip if localStorage already has preferences (already migrated)
if (localStorage.getItem(STORAGE_KEY)) return;
db.relayAuthPreferences
.toArray()
.then((rows) => {
if (rows.length === 0) return;
const prefs: Record<string, string> = {};
for (const row of rows) {
if (
row.preference === "always" ||
row.preference === "never" ||
row.preference === "ask"
) {
prefs[row.url] = row.preference;
}
}
localStorage.setItem(STORAGE_KEY, JSON.stringify(prefs));
// Inject into the already-running manager
for (const [url, pref] of Object.entries(prefs)) {
relayAuthManager.setPreference(url, pref as AuthPreference);
}
// Clean up Dexie table
db.relayAuthPreferences.clear();
})
.catch(() => {
// Ignore migration errors — user just re-answers prompts
});
}
/**
* 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;
}),
),
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
migrateFromDexie();
export default relayAuthManager;

View File

@@ -1,50 +1,40 @@
import type { IRelay } from "applesauce-relay";
import { combineLatest, firstValueFrom, race, timer } from "rxjs";
import { filter, map, startWith } from "rxjs/operators";
import { combineLatest } from "rxjs";
import { map, startWith } from "rxjs/operators";
import type {
RelayState,
GlobalRelayState,
AuthPreference,
} from "@/types/relay-state";
import { transitionAuthState, type AuthEvent } from "@/lib/auth-state-machine";
import { createLogger } from "@/lib/logger";
import { normalizeRelayURL } from "@/lib/relay-url";
import { canAccountSign } from "@/hooks/useAccount";
import pool from "./relay-pool";
import accountManager from "./accounts";
import db from "./db";
const logger = createLogger("RelayStateManager");
import relayAuthManager from "./relay-auth";
const MAX_NOTICES = 20;
const MAX_ERRORS = 20;
const CHALLENGE_TTL = 5 * 60 * 1000; // 5 minutes in milliseconds
/**
* Observable values emitted by relay observables
* Note: Using startWith() to ensure immediate emission with current values
* Observable values emitted by relay observables (connection + notices only)
*/
interface RelayObservableValues {
connected: boolean;
notices: string[]; // notices is an array of strings
challenge: string | null | undefined; // challenge can be null or undefined
authenticated: boolean;
notices: string[];
}
/**
* Singleton service for managing global relay state
* Subscribes to all relay observables and maintains state for all relays
* Singleton service for managing global relay state.
*
* Tracks relay connection state, notices, and errors.
* Delegates all NIP-42 authentication logic to the generic RelayAuthManager.
*/
class RelayStateManager {
private relayStates: Map<string, RelayState> = new Map();
private subscriptions: Map<string, () => void> = new Map();
private listeners: Set<(state: GlobalRelayState) => void> = new Set();
private authPreferences: Map<string, AuthPreference> = new Map();
private sessionRejections: Set<string> = new Set();
private initialized = false;
private pollingIntervalId?: NodeJS.Timeout;
private lastNotifiedState?: GlobalRelayState;
private stateVersion = 0;
private authUnsubscribe?: () => void;
constructor() {
// Don't perform async operations in constructor
@@ -55,15 +45,23 @@ 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 and pending challenge changes
const stateSub = relayAuthManager.states$.subscribe(() => {
this.notifyListeners();
});
const challengeSub = relayAuthManager.pendingChallenges$.subscribe(() => {
this.notifyListeners();
});
this.authUnsubscribe = () => {
stateSub.unsubscribe();
challengeSub.unsubscribe();
};
// Subscribe to existing relays
pool.relays.forEach((relay) => {
this.monitorRelay(relay);
@@ -108,9 +106,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 +118,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 +134,7 @@ class RelayStateManager {
url,
connectionState: "disconnected",
authStatus: "none",
authPreference: this.authPreferences.get(url),
authPreference: relayAuthManager.getPreference(url),
notices: [],
errors: [],
stats: {
@@ -153,9 +146,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 +165,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 +188,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 +340,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 +350,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();

View File

@@ -24,7 +24,8 @@
/* Path aliases */
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
"@/*": ["./src/*"],
"relay-auth-manager": ["./packages/relay-auth-manager/src/index.ts"]
}
},
"include": ["src"]

View File

@@ -12,6 +12,11 @@ export default defineConfig({
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
// Resolve workspace package source directly (bypasses dist/)
"relay-auth-manager": path.resolve(
__dirname,
"./packages/relay-auth-manager/src/index.ts",
),
},
},
server: {