Files
grimoire/packages/relay-auth-manager/src/auth-state-machine.ts
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

194 lines
5.0 KiB
TypeScript

import type { AuthPreference, AuthStatus } from "./types.js";
/**
* Events that trigger auth state transitions.
*/
export type AuthEvent =
| {
type: "CHALLENGE_RECEIVED";
challenge: string;
preference?: AuthPreference;
}
| { type: "USER_ACCEPTED" }
| { type: "USER_REJECTED" }
| { type: "AUTH_SUCCESS" }
| { type: "AUTH_FAILED" }
| { type: "DISCONNECTED" };
/**
* Result of an auth state transition.
*/
export interface AuthTransitionResult {
/** The new auth status after the transition */
newStatus: AuthStatus;
/** 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 NIP-42 auth state machine.
*
* @param currentStatus - Current auth status
* @param event - Event triggering the transition
* @returns Transition result with new status and side-effect flags
*/
export function transitionAuthState(
currentStatus: AuthStatus,
event: AuthEvent,
): AuthTransitionResult {
const noChange: AuthTransitionResult = {
newStatus: currentStatus,
shouldAutoAuth: false,
clearChallenge: false,
};
switch (currentStatus) {
case "none":
if (event.type === "CHALLENGE_RECEIVED") {
if (event.preference === "always") {
return {
newStatus: "authenticating",
shouldAutoAuth: true,
clearChallenge: false,
};
} else if (event.preference === "never") {
return {
newStatus: "rejected",
shouldAutoAuth: false,
clearChallenge: true,
};
} else {
return {
newStatus: "challenge_received",
shouldAutoAuth: false,
clearChallenge: false,
};
}
}
return noChange;
case "challenge_received":
switch (event.type) {
case "USER_ACCEPTED":
return {
newStatus: "authenticating",
shouldAutoAuth: false,
clearChallenge: false,
};
case "USER_REJECTED":
return {
newStatus: "rejected",
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",
shouldAutoAuth: false,
clearChallenge: true,
};
default:
return noChange;
}
case "authenticating":
switch (event.type) {
case "AUTH_SUCCESS":
return {
newStatus: "authenticated",
shouldAutoAuth: false,
clearChallenge: true,
};
case "AUTH_FAILED":
return {
newStatus: "failed",
shouldAutoAuth: false,
clearChallenge: true,
};
case "DISCONNECTED":
return {
newStatus: "none",
shouldAutoAuth: false,
clearChallenge: true,
};
default:
return noChange;
}
case "authenticated":
if (event.type === "DISCONNECTED") {
return {
newStatus: "none",
shouldAutoAuth: false,
clearChallenge: true,
};
}
if (event.type === "CHALLENGE_RECEIVED") {
if (event.preference === "always") {
return {
newStatus: "authenticating",
shouldAutoAuth: true,
clearChallenge: false,
};
} else if (event.preference === "never") {
return {
newStatus: "rejected",
shouldAutoAuth: false,
clearChallenge: true,
};
}
return {
newStatus: "challenge_received",
shouldAutoAuth: false,
clearChallenge: false,
};
}
return noChange;
case "failed":
case "rejected":
if (event.type === "CHALLENGE_RECEIVED") {
if (event.preference === "always") {
return {
newStatus: "authenticating",
shouldAutoAuth: true,
clearChallenge: false,
};
} else if (event.preference === "never") {
return {
newStatus: "rejected",
shouldAutoAuth: false,
clearChallenge: true,
};
}
return {
newStatus: "challenge_received",
shouldAutoAuth: false,
clearChallenge: false,
};
}
if (event.type === "DISCONNECTED") {
return {
newStatus: "none",
shouldAutoAuth: false,
clearChallenge: true,
};
}
return noChange;
default: {
const _exhaustive: never = currentStatus;
return _exhaustive;
}
}
}