Browse Source

implemented AUTH (NIP-42)

main
Silberengel 4 weeks ago
parent
commit
550dbc80cb
  1. 264
      src/lib/services/nostr/nostr-client.ts
  2. 4
      src/routes/api/search/+server.ts
  3. 5
      src/routes/docs/+page.svelte

264
src/lib/services/nostr/nostr-client.ts

@ -4,12 +4,23 @@ @@ -4,12 +4,23 @@
import type { NostrEvent, NostrFilter } from '../../types/nostr.js';
import logger from '../logger.js';
import { isNIP07Available, getPublicKeyWithNIP07, signEventWithNIP07 } from './nip07-signer.js';
// Polyfill WebSocket for Node.js environments (lazy initialization)
// Note: The 'module' import warning in browser builds is expected and harmless.
// This code only executes in Node.js/server environments.
let wsPolyfillInitialized = false;
async function initializeWebSocketPolyfill() {
if (wsPolyfillInitialized) return;
if (typeof global === 'undefined' || typeof global.WebSocket !== 'undefined') {
// Check if WebSocket already exists (browser or already polyfilled)
if (typeof WebSocket !== 'undefined') {
wsPolyfillInitialized = true;
return;
}
// Skip in browser environment - WebSocket should be native
if (typeof window !== 'undefined') {
wsPolyfillInitialized = true;
return;
}
@ -20,22 +31,25 @@ async function initializeWebSocketPolyfill() { @@ -20,22 +31,25 @@ async function initializeWebSocketPolyfill() {
return;
}
// Only attempt polyfill in Node.js runtime
// This import is only executed server-side, but Vite may still analyze it
try {
// Dynamic import to avoid bundling for browser
const { createRequire } = await import('module');
const requireFunc = createRequire(import.meta.url);
// @ts-ignore - Dynamic import that only runs in Node.js
const moduleModule = await import('module');
const requireFunc = moduleModule.createRequire(import.meta.url);
const WebSocketImpl = requireFunc('ws');
global.WebSocket = WebSocketImpl as any;
(global as any).WebSocket = WebSocketImpl;
wsPolyfillInitialized = true;
} catch {
// ws package not available, will fail at runtime in Node.js
logger.warn('WebSocket polyfill not available. Install "ws" package for Node.js support.');
wsPolyfillInitialized = true; // Mark as initialized to avoid repeated warnings
} catch (error) {
// ws package not available or import failed
// This is expected in browser builds, so we don't warn
wsPolyfillInitialized = true; // Mark as initialized to avoid repeated attempts
}
}
// Initialize on module load if in Node.js (fire and forget)
if (typeof process !== 'undefined' && process.versions?.node) {
// Only in SSR/server environment - check for window to exclude browser
if (typeof process !== 'undefined' && process.versions?.node && typeof window === 'undefined') {
initializeWebSocketPolyfill().catch(() => {
// Ignore errors during initialization
});
@ -43,11 +57,72 @@ if (typeof process !== 'undefined' && process.versions?.node) { @@ -43,11 +57,72 @@ if (typeof process !== 'undefined' && process.versions?.node) {
export class NostrClient {
private relays: string[] = [];
private authenticatedRelays: Set<string> = new Set();
constructor(relays: string[]) {
this.relays = relays;
}
/**
* Handle AUTH challenge from relay and authenticate using NIP-42
*/
private async handleAuthChallenge(ws: WebSocket, relay: string, challenge: string): Promise<boolean> {
// Only try to authenticate if NIP-07 is available (browser environment)
if (typeof window === 'undefined' || !isNIP07Available()) {
return false;
}
try {
const pubkey = await getPublicKeyWithNIP07();
// Create auth event (kind 22242)
const authEvent: Omit<NostrEvent, 'sig' | 'id'> = {
kind: 22242,
pubkey,
created_at: Math.floor(Date.now() / 1000),
tags: [],
content: challenge
};
// Sign the event (NIP-07 will calculate the ID)
const signedEvent = await signEventWithNIP07(authEvent);
// Send AUTH response
ws.send(JSON.stringify(['AUTH', signedEvent]));
// Wait for OK response with timeout
return new Promise((resolve) => {
const timeout = setTimeout(() => {
resolve(false);
}, 5000);
const okHandler = (event: MessageEvent) => {
try {
const message = JSON.parse(event.data);
if (message[0] === 'OK' && message[1] === 'auth') {
clearTimeout(timeout);
ws.removeEventListener('message', okHandler);
if (message[2] === true) {
this.authenticatedRelays.add(relay);
resolve(true);
} else {
logger.warn({ relay, reason: message[3] }, 'AUTH rejected by relay');
resolve(false);
}
}
} catch {
// Ignore parse errors, continue waiting
}
};
ws.addEventListener('message', okHandler);
});
} catch (error) {
logger.error({ error, relay }, 'Failed to authenticate with relay');
return false;
}
}
async fetchEvents(filters: NostrFilter[]): Promise<NostrEvent[]> {
const events: NostrEvent[] = [];
@ -76,18 +151,24 @@ export class NostrClient { @@ -76,18 +151,24 @@ export class NostrClient {
// Ensure WebSocket polyfill is initialized
await initializeWebSocketPolyfill();
return new Promise((resolve, reject) => {
const ws = new WebSocket(relay);
return new Promise((resolve) => {
let ws: WebSocket | null = null;
const events: NostrEvent[] = [];
let resolved = false;
let timeoutId: ReturnType<typeof setTimeout> | null = null;
let connectionTimeoutId: ReturnType<typeof setTimeout> | null = null;
let authHandled = false;
const cleanup = () => {
if (timeoutId) {
clearTimeout(timeoutId);
timeoutId = null;
}
if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) {
if (connectionTimeoutId) {
clearTimeout(connectionTimeoutId);
connectionTimeoutId = null;
}
if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) {
try {
ws.close();
} catch {
@ -96,7 +177,7 @@ export class NostrClient { @@ -96,7 +177,7 @@ export class NostrClient {
}
};
const resolveOnce = (value: NostrEvent[]) => {
const resolveOnce = (value: NostrEvent[] = []) => {
if (!resolved) {
resolved = true;
cleanup();
@ -104,26 +185,67 @@ export class NostrClient { @@ -104,26 +185,67 @@ export class NostrClient {
}
};
const rejectOnce = (error: Error) => {
if (!resolved) {
resolved = true;
cleanup();
reject(error);
let authPromise: Promise<boolean> | null = null;
try {
ws = new WebSocket(relay);
} catch (error) {
// Connection failed immediately
resolveOnce([]);
return;
}
// Connection timeout - if we can't connect within 3 seconds, give up
connectionTimeoutId = setTimeout(() => {
if (!resolved && ws && ws.readyState !== WebSocket.OPEN) {
resolveOnce([]);
}
};
}, 3000);
ws.onopen = () => {
try {
ws.send(JSON.stringify(['REQ', 'sub', ...filters]));
} catch (error) {
rejectOnce(error instanceof Error ? error : new Error(String(error)));
if (connectionTimeoutId) {
clearTimeout(connectionTimeoutId);
connectionTimeoutId = null;
}
// Connection opened, wait for AUTH challenge or proceed
// If no AUTH challenge comes within 1 second, send REQ
setTimeout(() => {
if (!authHandled && ws && ws.readyState === WebSocket.OPEN) {
try {
ws.send(JSON.stringify(['REQ', 'sub', ...filters]));
} catch {
// Connection might have closed
resolveOnce(events);
}
}
}, 1000);
};
ws.onmessage = (event: MessageEvent) => {
ws.onmessage = async (event: MessageEvent) => {
try {
const message = JSON.parse(event.data);
// Handle AUTH challenge
if (message[0] === 'AUTH' && message[1] && !authHandled) {
authHandled = true;
authPromise = this.handleAuthChallenge(ws!, relay, message[1]);
const authenticated = await authPromise;
// After authentication, send the REQ
if (ws && ws.readyState === WebSocket.OPEN) {
try {
ws.send(JSON.stringify(['REQ', 'sub', ...filters]));
} catch {
resolveOnce(events);
}
}
return;
}
// Wait for auth to complete before processing other messages
if (authPromise) {
await authPromise;
}
if (message[0] === 'EVENT') {
events.push(message[2]);
} else if (message[0] === 'EOSE') {
@ -134,8 +256,12 @@ export class NostrClient { @@ -134,8 +256,12 @@ export class NostrClient {
}
};
ws.onerror = (error) => {
rejectOnce(new Error(`WebSocket error for ${relay}: ${error}`));
ws.onerror = () => {
// Silently handle connection errors - some relays may be down
// Don't log or reject, just resolve with empty results
if (!resolved) {
resolveOnce([]);
}
};
ws.onclose = () => {
@ -145,9 +271,10 @@ export class NostrClient { @@ -145,9 +271,10 @@ export class NostrClient {
}
};
// Overall timeout - resolve with what we have after 8 seconds
timeoutId = setTimeout(() => {
resolveOnce(events);
}, 5000);
}, 8000);
});
}
@ -175,16 +302,22 @@ export class NostrClient { @@ -175,16 +302,22 @@ export class NostrClient {
await initializeWebSocketPolyfill();
return new Promise((resolve, reject) => {
const ws = new WebSocket(relay);
let ws: WebSocket | null = null;
let resolved = false;
let timeoutId: ReturnType<typeof setTimeout> | null = null;
let connectionTimeoutId: ReturnType<typeof setTimeout> | null = null;
let authHandled = false;
const cleanup = () => {
if (timeoutId) {
clearTimeout(timeoutId);
timeoutId = null;
}
if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) {
if (connectionTimeoutId) {
clearTimeout(connectionTimeoutId);
connectionTimeoutId = null;
}
if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) {
try {
ws.close();
} catch {
@ -209,18 +342,65 @@ export class NostrClient { @@ -209,18 +342,65 @@ export class NostrClient {
}
};
try {
ws = new WebSocket(relay);
} catch (error) {
rejectOnce(new Error(`Failed to create WebSocket connection to ${relay}`));
return;
}
// Connection timeout - if we can't connect within 3 seconds, reject
connectionTimeoutId = setTimeout(() => {
if (!resolved && ws && ws.readyState !== WebSocket.OPEN) {
rejectOnce(new Error(`Connection timeout for ${relay}`));
}
}, 3000);
let authPromise: Promise<boolean> | null = null;
ws.onopen = () => {
try {
ws.send(JSON.stringify(['EVENT', nostrEvent]));
} catch (error) {
rejectOnce(error instanceof Error ? error : new Error(String(error)));
if (connectionTimeoutId) {
clearTimeout(connectionTimeoutId);
connectionTimeoutId = null;
}
// Connection opened, wait for AUTH challenge or proceed
// If no AUTH challenge comes within 1 second, send EVENT
setTimeout(() => {
if (!authHandled && ws && ws.readyState === WebSocket.OPEN) {
try {
ws.send(JSON.stringify(['EVENT', nostrEvent]));
} catch (error) {
rejectOnce(error instanceof Error ? error : new Error(String(error)));
}
}
}, 1000);
};
ws.onmessage = (event: MessageEvent) => {
ws.onmessage = async (event: MessageEvent) => {
try {
const message = JSON.parse(event.data);
// Handle AUTH challenge
if (message[0] === 'AUTH' && message[1] && !authHandled) {
authHandled = true;
authPromise = this.handleAuthChallenge(ws!, relay, message[1]);
await authPromise;
// After authentication attempt, send the EVENT
if (ws && ws.readyState === WebSocket.OPEN) {
try {
ws.send(JSON.stringify(['EVENT', nostrEvent]));
} catch (error) {
rejectOnce(error instanceof Error ? error : new Error(String(error)));
}
}
return;
}
// Wait for auth to complete before processing other messages
if (authPromise) {
await authPromise;
}
if (message[0] === 'OK' && message[1] === nostrEvent.id) {
if (message[2] === true) {
resolveOnce();
@ -233,8 +413,16 @@ export class NostrClient { @@ -233,8 +413,16 @@ export class NostrClient {
}
};
ws.onerror = (error) => {
rejectOnce(new Error(`WebSocket error for ${relay}: ${error}`));
ws.onerror = () => {
// Silently handle connection errors - reject after a short delay
// to allow connection to attempt
if (!resolved) {
setTimeout(() => {
if (!resolved) {
rejectOnce(new Error(`Connection failed for ${relay}`));
}
}, 100);
}
};
ws.onclose = () => {
@ -246,7 +434,7 @@ export class NostrClient { @@ -246,7 +434,7 @@ export class NostrClient {
timeoutId = setTimeout(() => {
rejectOnce(new Error('Publish timeout'));
}, 5000);
}, 10000);
});
}
}

4
src/routes/api/search/+server.ts

@ -15,7 +15,6 @@ import logger from '$lib/services/logger.js'; @@ -15,7 +15,6 @@ import logger from '$lib/services/logger.js';
const repoRoot = process.env.GIT_REPO_ROOT || '/repos';
const fileManager = new FileManager(repoRoot);
const nostrClient = new NostrClient(DEFAULT_NOSTR_RELAYS);
export const GET: RequestHandler = async ({ url }) => {
const query = url.searchParams.get('q');
@ -31,6 +30,9 @@ export const GET: RequestHandler = async ({ url }) => { @@ -31,6 +30,9 @@ export const GET: RequestHandler = async ({ url }) => {
}
try {
// Create a new client instance for each search to ensure fresh connections
const nostrClient = new NostrClient(DEFAULT_NOSTR_RELAYS);
const results: {
repos: Array<{ id: string; name: string; description: string; owner: string; npub: string }>;
code: Array<{ repo: string; npub: string; file: string; matches: number }>;

5
src/routes/docs/+page.svelte

@ -59,11 +59,6 @@ @@ -59,11 +59,6 @@
</div>
<style>
.subtitle {
color: var(--text-muted);
margin: 0;
}
.docs-content {
background: var(--card-bg);
padding: 2rem;

Loading…
Cancel
Save