Files
Alejandro 6c2adc01e0 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>
2026-02-20 08:52:50 +01:00
..

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

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

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.

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:

{
  "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:

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:

  • AuthRelayPoolrelay(url), add$, remove$
  • AuthRelayurl, connected$, challenge$, authenticated$, authenticate(signer)
  • AuthSignersignEvent(event)

These are compatible with applesauce-relay but any implementation that satisfies the shapes will work.