feat: basic server

This commit is contained in:
complex 2025-04-08 15:37:08 +02:00
parent ad5babda43
commit 873847689c
9 changed files with 7041 additions and 2450 deletions

6808
client/package-lock.json generated

File diff suppressed because it is too large Load Diff

1919
server/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

@ -1,14 +1,13 @@
{
"name": "http-to-nostr-server",
"name": "nostr-http-server",
"version": "1.0.0",
"description": "A server for processing HTTP request events from Nostr Kind 21120",
"description": "Nostr HTTP Request Server",
"main": "dist/index.js",
"scripts": {
"build": "tsc",
"start": "node dist/index.js",
"build": "tsc",
"dev": "ts-node src/index.ts",
"watch": "nodemon --exec ts-node src/index.ts",
"clean": "rm -rf dist"
"test": "jest"
},
"keywords": [
"nostr",
@ -20,17 +19,16 @@
"dependencies": {
"@nostr-dev-kit/ndk": "^2.0.0",
"express": "^4.18.2",
"node-fetch": "^3.3.2",
"websocket-polyfill": "^0.0.3",
"ws": "^8.14.2"
"node-fetch": "^2.6.9",
"ws": "^8.13.0"
},
"devDependencies": {
"@types/express": "^4.17.18",
"@types/node": "^20.8.4",
"@types/ws": "^8.5.6",
"nodemon": "^3.0.1",
"@types/express": "^4.17.17",
"@types/node": "^18.15.11",
"@types/node-fetch": "^2.6.12",
"@types/ws": "^8.5.4",
"ts-node": "^10.9.1",
"typescript": "^5.2.2"
"typescript": "^5.0.4"
},
"type": "module"
}
}

@ -16,6 +16,6 @@ export const relayUrls = [
// Server configuration
export const serverConfig = {
port: process.env.PORT || 3000,
port: process.env.PORT ? parseInt(process.env.PORT) : 3001,
expirationTime: 3600 // 1 hour in seconds
};

@ -1,39 +0,0 @@
import * as crypto from 'crypto';
import * as nostrTools from 'nostr-tools';
// Generate a new random private key
function generatePrivateKey(): string {
return nostrTools.generatePrivateKey();
}
// Derive public key from private key
function getPublicKey(privateKey: string): string {
return nostrTools.getPublicKey(privateKey);
}
// Convert hex public key to npub format
function convertToNpub(publicKey: string): string {
return nostrTools.nip19.npubEncode(publicKey);
}
// Convert hex private key to nsec format
function convertToNsec(privateKey: string): string {
return nostrTools.nip19.nsecEncode(privateKey);
}
// Generate and display keys
function main() {
const privateKey = generatePrivateKey();
const publicKey = getPublicKey(privateKey);
const npub = convertToNpub(publicKey);
const nsec = convertToNsec(privateKey);
console.log('Generated Nostr Key Pair:');
console.log('------------------------');
console.log('Private Key (hex):', privateKey);
console.log('Public Key (hex):', publicKey);
console.log('NPUB:', npub);
console.log('NSEC:', nsec);
}
main();

@ -1,11 +1,11 @@
import express from 'express';
import { WebSocket, WebSocketServer } from 'ws';
import { NDKEvent, NDK, NDKPrivateKeySigner, NDKFilter, NDKRelay } from '@nostr-dev-kit/ndk';
import { createServer } from 'http';
import fetch from 'node-fetch';
import { serverKeys, relayUrls, serverConfig } from './config';
import * as crypto from 'crypto';
// Configuration
/**
* Main entry point for the Nostr HTTP Request Server
*/
import { serverKeys, relayUrls, serverConfig } from './config.js';
import { NostrHttpServer } from './server.js';
// Create server configuration
const config = {
port: serverConfig.port,
relayUrls: relayUrls,
@ -14,259 +14,22 @@ const config = {
npub: serverKeys.npub
};
console.log(`Server running with npub: ${config.npub}`);
// Create and start server
const server = new NostrHttpServer(config);
// Create NDK instance with the private key signer
const signer = new NDKPrivateKeySigner(config.privateKey);
const ndk = new NDK({
explicitRelayUrls: config.relayUrls,
signer
// Handle process termination
process.on('SIGINT', () => {
console.log('Shutting down server...');
process.exit(0);
});
// Initialize NDK
(async () => {
try {
await ndk.connect();
console.log('NDK Connected to relays');
} catch (error) {
console.error('Failed to connect to relays:', error);
process.exit(1);
}
})();
// Set up Express server
const app = express();
const server = createServer(app);
app.use(express.json());
// Basic route to display server info
app.get('/', (req, res) => {
res.send(`
<h1>HTTP Request Server</h1>
<p>This server listens for Nostr kind 21120 events and processes HTTP requests.</p>
<p>Server pubkey: ${config.pubkey}</p>
`);
process.on('unhandledRejection', (error: Error) => {
console.error('Unhandled promise rejection:', error);
process.exit(1);
});
// Start the HTTP server
server.listen(config.port, () => {
console.log(`Server running on http://localhost:${config.port}`);
});
// Subscribe to kind 21120 events
async function subscribeToEvents() {
try {
// Create filter for kind 21120 events addressed to us
const filter: NDKFilter = {
kinds: [21120],
'#p': [config.pubkey]
};
// Subscribe to events
ndk.subscribe(filter, {
closeOnEose: false,
// Handle each received event
callback: (event: NDKEvent) => {
processEvent(event);
}
});
console.log('Subscribed to events with filter:', filter);
} catch (error) {
console.error('Error subscribing to events:', error);
}
}
// Start subscriptions
(async () => {
try {
await subscribeToEvents();
console.log('Event subscriptions started');
} catch (error) {
console.error('Failed to start subscriptions:', error);
}
})();
// Process incoming HTTP request events
async function processEvent(event: NDKEvent) {
console.log('Received event:', event.id);
// Verify this is a request (has p tag pointing to us)
const pTag = event.getMatchingTags('p').find(tag => tag[1] === config.pubkey);
if (!pTag) {
console.log('Not a request for this server, ignoring');
return;
}
try {
// Extract the encrypted HTTP request from content
const encryptedContent = event.content;
// Use NDK's built-in NIP-44 decryption with our private key
let httpRequest: string;
try {
// NDK provides NIP-44 decryption through the event object or signer
httpRequest = await ndk.signer?.decrypt(encryptedContent) || '';
console.log('Successfully decrypted content with NIP-44');
} catch (decryptError) {
console.error('Failed to decrypt content with NIP-44:', decryptError);
// Fallback in case of decryption failure
httpRequest = encryptedContent;
}
console.log('Decrypted HTTP request:', httpRequest);
// Parse HTTP request
const parsedRequest = parseHttpRequest(httpRequest);
if (!parsedRequest) {
throw new Error('Failed to parse HTTP request');
}
// Execute the HTTP request
const response = await executeHttpRequest(parsedRequest);
// Send response event
await sendResponseEvent(event.id, response);
} catch (error) {
console.error('Error processing event:', error);
// Send error response
const errorResponse = `HTTP/1.1 500 Internal Server Error
Content-Type: text/plain
Content-Length: ${error.toString().length}
${error.toString()}`;
await sendResponseEvent(event.id, errorResponse);
}
}
// Parse an HTTP request string into components
function parseHttpRequest(requestStr: string): any {
try {
const lines = requestStr.split('\n');
const firstLine = lines[0].trim().split(' ');
if (firstLine.length < 3) {
throw new Error('Invalid request line');
}
const method = firstLine[0];
const path = firstLine[1];
const protocol = firstLine[2];
// Parse headers
const headers: Record<string, string> = {};
let i = 1;
while (i < lines.length && lines[i].trim() !== '') {
const headerLine = lines[i].trim();
const separatorIndex = headerLine.indexOf(':');
if (separatorIndex > 0) {
const key = headerLine.substring(0, separatorIndex).trim();
const value = headerLine.substring(separatorIndex + 1).trim();
headers[key] = value;
}
i++;
}
// Parse body (if any)
let body = '';
if (i < lines.length - 1) {
body = lines.slice(i + 1).join('\n');
}
return { method, path, protocol, headers, body };
} catch (error) {
console.error('Error parsing HTTP request:', error);
return null;
}
}
// Execute an HTTP request
async function executeHttpRequest(request: any): Promise<string> {
try {
// Build URL from path
const url = new URL(request.path, 'http://localhost');
// Prepare fetch options
const options: any = {
method: request.method,
headers: request.headers
};
// Add body if present
if (request.body && request.method !== 'GET' && request.method !== 'HEAD') {
options.body = request.body;
}
console.log(`Executing HTTP request to ${url}`);
// Execute the request
const response = await fetch(url.toString(), options);
// Build response string
let responseStr = `HTTP/1.1 ${response.status} ${response.statusText}\n`;
// Add headers
response.headers.forEach((value, key) => {
responseStr += `${key}: ${value}\n`;
});
// Add empty line between headers and body
responseStr += '\n';
// Add body
const body = await response.text();
responseStr += body;
return responseStr;
} catch (error) {
console.error('Error executing HTTP request:', error);
throw error;
}
}
// Send response event using NDK
async function sendResponseEvent(requestId: string, responseContent: string) {
try {
// For a valid Nostr event, we need to manually set all required fields
const rawEvent = {
kind: 21120,
content: responseContent,
created_at: Math.floor(Date.now() / 1000),
pubkey: config.pubkey,
tags: [
['e', requestId], // E tag points to request event
['expiration', (Math.floor(Date.now() / 1000) + 3600).toString()] // 1 hour from now
],
id: '', // Will be generated during signing
sig: '' // Will be generated during signing
};
// Create a new NDK event from the raw event
const event = new NDKEvent(ndk, rawEvent);
try {
// Sign the event with our private key
await event.sign();
console.log('Response event signed successfully');
// Publish to relays
await event.publish();
console.log(`Response sent for request ${requestId}`);
} catch (signError) {
console.error('Error signing/publishing event:', signError);
console.log('Event data:', rawEvent);
return;
}
} catch (error) {
console.error('Error sending response event:', error);
}
}
// The server is already started when NDK connects to relays earlier
console.log('HTTP to Nostr server is ready and listening for events');
// Start the server
server.start().catch(error => {
console.error('Failed to start server:', error);
process.exit(1);
});

322
server/src/server.ts Normal file

@ -0,0 +1,322 @@
/**
* Nostr HTTP Request Server Implementation
* Handles kind 21120 events for HTTP request processing
*/
import NDK from '@nostr-dev-kit/ndk';
import { NDKUser, NDKEvent, NDKFilter, NDKPrivateKeySigner } from '@nostr-dev-kit/ndk';
import express from 'express';
import { createServer } from 'http';
import { ServerConfig, HttpRequest, RateLimitConfig, RateLimitState } from './types';
/**
* Nostr HTTP Server class
*/
export class NostrHttpServer {
private ndk: NDK;
private config: ServerConfig;
private rateLimits: Map<string, RateLimitState>;
private rateLimitConfig: RateLimitConfig;
/**
* Create a new Nostr HTTP Server
* @param config Server configuration
*/
constructor(config: ServerConfig) {
this.config = config;
this.rateLimits = new Map();
this.rateLimitConfig = {
maxRequests: 100,
windowMs: 60000 // 1 minute
};
// Initialize NDK with private key signer
const signer = new NDKPrivateKeySigner(config.privateKey);
this.ndk = new NDK({
explicitRelayUrls: config.relayUrls,
signer
});
}
/**
* Start the server
*/
public async start(): Promise<void> {
try {
// Connect to NDK
await this.ndk.connect();
console.log('Connected to Nostr relays');
// Subscribe to events
await this.subscribeToEvents();
console.log('Subscribed to kind 21120 events');
// Set up Express server for status endpoint
const app = express();
const server = createServer(app);
app.get('/', (req: express.Request, res: express.Response) => {
res.send(`
<h1>Nostr HTTP Request Server</h1>
<p>Server npub: ${this.config.npub}</p>
<p>Status: Running</p>
`);
});
server.listen(this.config.port, () => {
console.log(`Server running on http://localhost:${this.config.port}`);
});
} catch (error) {
console.error('Failed to start server:', error);
throw error;
}
}
/**
* Subscribe to kind 21120 events
*/
private async subscribeToEvents(): Promise<void> {
const filter: NDKFilter = {
kinds: [21120 as any], // Type assertion to bypass NDKKind type check
'#p': [this.config.pubkey]
};
// Create a subscription with the filter and options
const subscription = this.ndk.subscribe(filter, {
closeOnEose: false,
groupable: false
});
// Set up event handler
subscription.on('event', (event: NDKEvent) => {
this.processEvent(event).catch(error => {
console.error('Error processing event:', error);
});
});
}
/**
* Process incoming Nostr events
* @param event The Nostr event to process
*/
private async processEvent(event: NDKEvent): Promise<void> {
// Check rate limit
if (!this.checkRateLimit(event.pubkey)) {
await this.sendErrorResponse(event.id, 'Rate limit exceeded');
return;
}
try {
// Verify this is a request for us
const pTag = event.getMatchingTags('p').find((tag: string[]) => tag[1] === this.config.pubkey);
if (!pTag) {
console.log('Not a request for this server, ignoring');
return;
}
// Decrypt content
const decryptedContent = await this.decryptContent(event.pubkey, event.content);
if (!decryptedContent) {
throw new Error('Failed to decrypt content');
}
// Parse HTTP request
const httpRequest = this.parseHttpRequest(decryptedContent);
if (!httpRequest) {
throw new Error('Failed to parse HTTP request');
}
// Execute HTTP request (or return dummy response)
const response = await this.executeHttpRequest(httpRequest);
// Send response
await this.sendResponseEvent(event.id, response);
} catch (error) {
console.error('Error processing event:', error);
await this.sendErrorResponse(event.id, error instanceof Error ? error.message : 'Unknown error');
}
}
/**
* Decrypt event content using NIP-44
* @param pubkey The public key of the sender
* @param encryptedContent The encrypted content to decrypt
*/
private async decryptContent(pubkey: string, encryptedContent: string): Promise<string | null> {
try {
const user = new NDKUser({ pubkey });
return await this.ndk.signer?.decrypt(user, encryptedContent) || null;
} catch (error) {
console.error('Decryption error:', error);
return null;
}
}
/**
* Parse HTTP request string into structured format
* @param requestStr The HTTP request string to parse
*/
private parseHttpRequest(requestStr: string): HttpRequest | null {
try {
const lines = requestStr.split('\n');
const firstLine = lines[0].trim().split(' ');
if (firstLine.length < 3) {
throw new Error('Invalid request line');
}
const [method, path, protocol] = firstLine;
// Parse headers
const headers: Record<string, string> = {};
let i = 1;
while (i < lines.length && lines[i].trim() !== '') {
const headerLine = lines[i].trim();
const separatorIndex = headerLine.indexOf(':');
if (separatorIndex > 0) {
const key = headerLine.substring(0, separatorIndex).trim();
const value = headerLine.substring(separatorIndex + 1).trim();
headers[key] = value;
}
i++;
}
// Parse body
const body = lines.slice(i + 1).join('\n');
return { method, path, protocol, headers, body };
} catch (error) {
console.error('Error parsing HTTP request:', error);
return null;
}
}
/**
* Execute HTTP request or return a dummy response
* @param request The HTTP request to execute
*/
private async executeHttpRequest(request: HttpRequest): Promise<string> {
// Return a simple Hello World response instead of executing the actual request
const responseBody = `
<!DOCTYPE html>
<html>
<head>
<title>Hello World</title>
<style>
body {
font-family: Arial, sans-serif;
max-width: 800px;
margin: 0 auto;
padding: 20px;
line-height: 1.6;
}
h1 {
color: #333;
}
.info {
background-color: #f5f5f5;
padding: 15px;
border-radius: 5px;
margin-top: 20px;
}
</style>
</head>
<body>
<h1>Hello World from Nostr HTTP Server!</h1>
<p>This is a dummy response from the Nostr HTTP server.</p>
<div class="info">
<h2>Request Information:</h2>
<p><strong>Method:</strong> ${request.method}</p>
<p><strong>Path:</strong> ${request.path}</p>
<p><strong>Protocol:</strong> ${request.protocol}</p>
<p><strong>Timestamp:</strong> ${new Date().toISOString()}</p>
</div>
</body>
</html>`;
// Build response string
let responseStr = `HTTP/1.1 200 OK
Content-Type: text/html
Content-Length: ${responseBody.length}
${responseBody}`;
return responseStr;
}
/**
* Send response event
* @param requestId The ID of the request event
* @param responseContent The response content to send
*/
private async sendResponseEvent(requestId: string, responseContent: string): Promise<void> {
try {
const rawEvent = {
kind: 21120,
content: responseContent,
created_at: Math.floor(Date.now() / 1000),
pubkey: this.config.pubkey,
tags: [
['e', requestId],
['expiration', (Math.floor(Date.now() / 1000) + 3600).toString()]
]
};
const event = new NDKEvent(this.ndk, rawEvent);
await event.sign();
await event.publish();
} catch (error) {
console.error('Error sending response event:', error);
throw error;
}
}
/**
* Send error response event
* @param requestId The ID of the request event
* @param errorMessage The error message to send
*/
private async sendErrorResponse(requestId: string, errorMessage: string): Promise<void> {
const errorResponse = `HTTP/1.1 500 Internal Server Error
Content-Type: text/plain
Content-Length: ${errorMessage.length}
${errorMessage}`;
await this.sendResponseEvent(requestId, errorResponse);
}
/**
* Check if a client has exceeded rate limits
* @param clientId The client's public key
*/
private checkRateLimit(clientId: string): boolean {
const now = Date.now();
const state = this.rateLimits.get(clientId);
if (!state) {
this.rateLimits.set(clientId, {
count: 1,
resetTime: now + this.rateLimitConfig.windowMs
});
return true;
}
if (now > state.resetTime) {
this.rateLimits.set(clientId, {
count: 1,
resetTime: now + this.rateLimitConfig.windowMs
});
return true;
}
if (state.count >= this.rateLimitConfig.maxRequests) {
return false;
}
state.count++;
return true;
}
}

71
server/src/types.ts Normal file

@ -0,0 +1,71 @@
/**
* Type definitions for the Nostr HTTP Request Server
*/
import { NDKEvent } from '@nostr-dev-kit/ndk';
/**
* HTTP Request structure
*/
export interface HttpRequest {
method: string;
path: string;
protocol: string;
headers: Record<string, string>;
body: string;
}
/**
* HTTP Response structure
*/
export interface HttpResponse {
status: number;
statusText: string;
headers: Record<string, string>;
body: string;
}
/**
* Server configuration
*/
export interface ServerConfig {
port: number;
relayUrls: string[];
privateKey: string;
pubkey: string;
npub: string;
}
/**
* Event processing result
*/
export interface EventProcessingResult {
success: boolean;
error?: string;
response?: string;
}
/**
* Rate limiting configuration
*/
export interface RateLimitConfig {
maxRequests: number;
windowMs: number;
}
/**
* Rate limit state for a client
*/
export interface RateLimitState {
count: number;
resetTime: number;
}
/**
* Extended NDKEvent with our custom properties
*/
export interface NostrHttpEvent extends NDKEvent {
requestId: string;
timestamp: number;
clientPubkey: string;
}

@ -2,14 +2,25 @@
"compilerOptions": {
"target": "ES2020",
"module": "ES2020",
"moduleResolution": "node",
"esModuleInterop": true,
"lib": [
"ES2020"
],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"sourceMap": true,
"resolveJsonModule": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
"forceConsistentCasingInFileNames": true,
"moduleResolution": "bundler",
"resolveJsonModule": true,
"declaration": true,
"sourceMap": true
},
"include": ["src/**/*"]
"include": [
"src/**/*"
],
"exclude": [
"node_modules",
"dist"
]
}