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

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