Browse Source

implemented AUTH (NIP-42)

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

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

@ -4,12 +4,23 @@
import type { NostrEvent, NostrFilter } from '../../types/nostr.js'; import type { NostrEvent, NostrFilter } from '../../types/nostr.js';
import logger from '../logger.js'; import logger from '../logger.js';
import { isNIP07Available, getPublicKeyWithNIP07, signEventWithNIP07 } from './nip07-signer.js';
// Polyfill WebSocket for Node.js environments (lazy initialization) // 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; let wsPolyfillInitialized = false;
async function initializeWebSocketPolyfill() { async function initializeWebSocketPolyfill() {
if (wsPolyfillInitialized) return; 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; wsPolyfillInitialized = true;
return; return;
} }
@ -20,22 +31,25 @@ async function initializeWebSocketPolyfill() {
return; return;
} }
// Only attempt polyfill in Node.js runtime
// This import is only executed server-side, but Vite may still analyze it
try { try {
// Dynamic import to avoid bundling for browser // @ts-ignore - Dynamic import that only runs in Node.js
const { createRequire } = await import('module'); const moduleModule = await import('module');
const requireFunc = createRequire(import.meta.url); const requireFunc = moduleModule.createRequire(import.meta.url);
const WebSocketImpl = requireFunc('ws'); const WebSocketImpl = requireFunc('ws');
global.WebSocket = WebSocketImpl as any; (global as any).WebSocket = WebSocketImpl;
wsPolyfillInitialized = true; wsPolyfillInitialized = true;
} catch { } catch (error) {
// ws package not available, will fail at runtime in Node.js // ws package not available or import failed
logger.warn('WebSocket polyfill not available. Install "ws" package for Node.js support.'); // This is expected in browser builds, so we don't warn
wsPolyfillInitialized = true; // Mark as initialized to avoid repeated warnings wsPolyfillInitialized = true; // Mark as initialized to avoid repeated attempts
} }
} }
// Initialize on module load if in Node.js (fire and forget) // 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(() => { initializeWebSocketPolyfill().catch(() => {
// Ignore errors during initialization // Ignore errors during initialization
}); });
@ -43,11 +57,72 @@ if (typeof process !== 'undefined' && process.versions?.node) {
export class NostrClient { export class NostrClient {
private relays: string[] = []; private relays: string[] = [];
private authenticatedRelays: Set<string> = new Set();
constructor(relays: string[]) { constructor(relays: string[]) {
this.relays = relays; 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[]> { async fetchEvents(filters: NostrFilter[]): Promise<NostrEvent[]> {
const events: NostrEvent[] = []; const events: NostrEvent[] = [];
@ -76,18 +151,24 @@ export class NostrClient {
// Ensure WebSocket polyfill is initialized // Ensure WebSocket polyfill is initialized
await initializeWebSocketPolyfill(); await initializeWebSocketPolyfill();
return new Promise((resolve, reject) => { return new Promise((resolve) => {
const ws = new WebSocket(relay); let ws: WebSocket | null = null;
const events: NostrEvent[] = []; const events: NostrEvent[] = [];
let resolved = false; let resolved = false;
let timeoutId: ReturnType<typeof setTimeout> | null = null; let timeoutId: ReturnType<typeof setTimeout> | null = null;
let connectionTimeoutId: ReturnType<typeof setTimeout> | null = null;
let authHandled = false;
const cleanup = () => { const cleanup = () => {
if (timeoutId) { if (timeoutId) {
clearTimeout(timeoutId); clearTimeout(timeoutId);
timeoutId = null; 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 { try {
ws.close(); ws.close();
} catch { } catch {
@ -96,34 +177,75 @@ export class NostrClient {
} }
}; };
const resolveOnce = (value: NostrEvent[]) => { const resolveOnce = (value: NostrEvent[] = []) => {
if (!resolved) { if (!resolved) {
resolved = true; resolved = true;
cleanup(); cleanup();
resolve(value); resolve(value);
} }
}; };
const rejectOnce = (error: Error) => { let authPromise: Promise<boolean> | null = null;
if (!resolved) {
resolved = true; try {
cleanup(); ws = new WebSocket(relay);
reject(error); } 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 = () => { ws.onopen = () => {
try { if (connectionTimeoutId) {
ws.send(JSON.stringify(['REQ', 'sub', ...filters])); clearTimeout(connectionTimeoutId);
} catch (error) { connectionTimeoutId = null;
rejectOnce(error instanceof Error ? error : new Error(String(error)));
} }
// 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 { try {
const message = JSON.parse(event.data); 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') { if (message[0] === 'EVENT') {
events.push(message[2]); events.push(message[2]);
} else if (message[0] === 'EOSE') { } else if (message[0] === 'EOSE') {
@ -134,8 +256,12 @@ export class NostrClient {
} }
}; };
ws.onerror = (error) => { ws.onerror = () => {
rejectOnce(new Error(`WebSocket error for ${relay}: ${error}`)); // Silently handle connection errors - some relays may be down
// Don't log or reject, just resolve with empty results
if (!resolved) {
resolveOnce([]);
}
}; };
ws.onclose = () => { ws.onclose = () => {
@ -145,9 +271,10 @@ export class NostrClient {
} }
}; };
// Overall timeout - resolve with what we have after 8 seconds
timeoutId = setTimeout(() => { timeoutId = setTimeout(() => {
resolveOnce(events); resolveOnce(events);
}, 5000); }, 8000);
}); });
} }
@ -175,16 +302,22 @@ export class NostrClient {
await initializeWebSocketPolyfill(); await initializeWebSocketPolyfill();
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const ws = new WebSocket(relay); let ws: WebSocket | null = null;
let resolved = false; let resolved = false;
let timeoutId: ReturnType<typeof setTimeout> | null = null; let timeoutId: ReturnType<typeof setTimeout> | null = null;
let connectionTimeoutId: ReturnType<typeof setTimeout> | null = null;
let authHandled = false;
const cleanup = () => { const cleanup = () => {
if (timeoutId) { if (timeoutId) {
clearTimeout(timeoutId); clearTimeout(timeoutId);
timeoutId = null; 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 { try {
ws.close(); ws.close();
} catch { } catch {
@ -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 = () => { ws.onopen = () => {
try { if (connectionTimeoutId) {
ws.send(JSON.stringify(['EVENT', nostrEvent])); clearTimeout(connectionTimeoutId);
} catch (error) { connectionTimeoutId = null;
rejectOnce(error instanceof Error ? error : new Error(String(error)));
} }
// 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 { try {
const message = JSON.parse(event.data); 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[0] === 'OK' && message[1] === nostrEvent.id) {
if (message[2] === true) { if (message[2] === true) {
resolveOnce(); resolveOnce();
@ -233,8 +413,16 @@ export class NostrClient {
} }
}; };
ws.onerror = (error) => { ws.onerror = () => {
rejectOnce(new Error(`WebSocket error for ${relay}: ${error}`)); // 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 = () => { ws.onclose = () => {
@ -246,7 +434,7 @@ export class NostrClient {
timeoutId = setTimeout(() => { timeoutId = setTimeout(() => {
rejectOnce(new Error('Publish timeout')); 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';
const repoRoot = process.env.GIT_REPO_ROOT || '/repos'; const repoRoot = process.env.GIT_REPO_ROOT || '/repos';
const fileManager = new FileManager(repoRoot); const fileManager = new FileManager(repoRoot);
const nostrClient = new NostrClient(DEFAULT_NOSTR_RELAYS);
export const GET: RequestHandler = async ({ url }) => { export const GET: RequestHandler = async ({ url }) => {
const query = url.searchParams.get('q'); const query = url.searchParams.get('q');
@ -31,6 +30,9 @@ export const GET: RequestHandler = async ({ url }) => {
} }
try { try {
// Create a new client instance for each search to ensure fresh connections
const nostrClient = new NostrClient(DEFAULT_NOSTR_RELAYS);
const results: { const results: {
repos: Array<{ id: string; name: string; description: string; owner: string; npub: string }>; repos: Array<{ id: string; name: string; description: string; owner: string; npub: string }>;
code: Array<{ repo: string; npub: string; file: string; matches: number }>; code: Array<{ repo: string; npub: string; file: string; matches: number }>;

5
src/routes/docs/+page.svelte

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

Loading…
Cancel
Save