mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-04-17 19:07:06 +02:00
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:
13
package-lock.json
generated
13
package-lock.json
generated
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,9 @@
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
"packages/*"
|
||||
],
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
|
||||
21
packages/relay-auth-manager/LICENSE
Normal file
21
packages/relay-auth-manager/LICENSE
Normal 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.
|
||||
179
packages/relay-auth-manager/README.md
Normal file
179
packages/relay-auth-manager/README.md
Normal 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.
|
||||
36
packages/relay-auth-manager/package.json
Normal file
36
packages/relay-auth-manager/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
1380
packages/relay-auth-manager/src/__tests__/relay-auth-manager.test.ts
Normal file
1380
packages/relay-auth-manager/src/__tests__/relay-auth-manager.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
}
|
||||
14
packages/relay-auth-manager/src/index.ts
Normal file
14
packages/relay-auth-manager/src/index.ts
Normal 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";
|
||||
623
packages/relay-auth-manager/src/relay-auth-manager.ts
Normal file
623
packages/relay-auth-manager/src/relay-auth-manager.ts
Normal 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(/\/+$/, "");
|
||||
}
|
||||
115
packages/relay-auth-manager/src/types.ts
Normal file
115
packages/relay-auth-manager/src/types.ts
Normal 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;
|
||||
}
|
||||
17
packages/relay-auth-manager/tsconfig.build.json
Normal file
17
packages/relay-auth-manager/tsconfig.build.json
Normal 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__"]
|
||||
}
|
||||
15
packages/relay-auth-manager/tsconfig.json
Normal file
15
packages/relay-auth-manager/tsconfig.json
Normal 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"]
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
|
||||
81
src/services/relay-auth.ts
Normal file
81
src/services/relay-auth.ts
Normal 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;
|
||||
@@ -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();
|
||||
|
||||
@@ -24,7 +24,8 @@
|
||||
/* Path aliases */
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
"@/*": ["./src/*"],
|
||||
"relay-auth-manager": ["./packages/relay-auth-manager/src/index.ts"]
|
||||
}
|
||||
},
|
||||
"include": ["src"]
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user