From 2417847eef7b204c8187e86298a5d124695cc224 Mon Sep 17 00:00:00 2001 From: Patrick Mulligan Date: Sat, 14 Feb 2026 13:12:08 -0500 Subject: [PATCH] feat: integrate extension system with withdraw extension support - Add extension loader initialization to startup - Create mainHandlerAdapter to bridge mainHandler with extension context - Mount extension HTTP routes on separate port (main port + 1) - Configure EXTENSION_SERVICE_URL for LNURL link generation The withdraw extension provides LUD-03 LNURL-withdraw support for creating withdraw links that allow users to pull funds. Co-Authored-By: Claude Opus 4.5 --- src/extensions/mainHandlerAdapter.ts | 128 +++++++++++++++++++++++++++ src/index.ts | 94 +++++++++++++++++++- 2 files changed, 221 insertions(+), 1 deletion(-) create mode 100644 src/extensions/mainHandlerAdapter.ts diff --git a/src/extensions/mainHandlerAdapter.ts b/src/extensions/mainHandlerAdapter.ts new file mode 100644 index 00000000..eecc96b3 --- /dev/null +++ b/src/extensions/mainHandlerAdapter.ts @@ -0,0 +1,128 @@ +/** + * MainHandler Adapter for Extension System + * + * Wraps the Lightning.Pub mainHandler to provide the MainHandlerInterface + * required by the extension system. + */ + +import { MainHandlerInterface } from './context.js' +import { LnurlPayInfo } from './types.js' +import type Main from '../services/main/index.js' + +/** + * Create an adapter that wraps mainHandler for extension use + */ +export function createMainHandlerAdapter(mainHandler: Main): MainHandlerInterface { + return { + applicationManager: { + async getById(id: string) { + // The applicationManager stores apps internally + // We need to access it through the storage layer + try { + const app = await mainHandler.storage.applicationStorage.GetApplication(id) + if (!app) return null + + return { + id: app.app_id, + name: app.name, + nostr_public: app.nostr_public_key || '', + balance: app.owner?.balance_sats || 0 + } + } catch (e) { + // GetApplication throws if not found + return null + } + } + }, + + paymentManager: { + async createInvoice(params: { + applicationId: string + amountSats: number + memo?: string + expiry?: number + metadata?: Record + }) { + // Get the app to find the user ID + const app = await mainHandler.storage.applicationStorage.GetApplication(params.applicationId) + if (!app) { + throw new Error(`Application not found: ${params.applicationId}`) + } + + // Create invoice using the app owner's user ID + const result = await mainHandler.paymentManager.NewInvoice( + app.owner.user_id, + { + amountSats: params.amountSats, + memo: params.memo || '' + }, + { + expiry: params.expiry || 3600 + } + ) + + return { + id: result.invoice.split(':')[0] || result.invoice, // Extract ID if present + paymentRequest: result.invoice, + paymentHash: '', // Not directly available from NewInvoice response + expiry: Date.now() + (params.expiry || 3600) * 1000 + } + }, + + async payInvoice(params: { + applicationId: string + paymentRequest: string + maxFeeSats?: number + }) { + // Get the app to find the user ID and app reference + const app = await mainHandler.storage.applicationStorage.GetApplication(params.applicationId) + if (!app) { + throw new Error(`Application not found: ${params.applicationId}`) + } + + // Pay invoice from the app's balance + const result = await mainHandler.paymentManager.PayInvoice( + app.owner.user_id, + { + invoice: params.paymentRequest, + amount: 0 // Use invoice amount + }, + app, // linkedApplication + {} + ) + + return { + paymentHash: result.preimage || '', // preimage serves as proof of payment + feeSats: result.network_fee || 0 + } + }, + + async getLnurlPayInfoByPubkey(pubkeyHex: string, options?: { + metadata?: string + description?: string + }): Promise { + // This would need implementation based on how Lightning.Pub handles LNURL-pay + // For now, throw not implemented + throw new Error('getLnurlPayInfoByPubkey not yet implemented') + } + }, + + async sendNostrEvent(event: any): Promise { + // The mainHandler doesn't directly expose nostrSend + // This would need to be implemented through the nostrMiddleware + // For now, return null (not implemented) + console.warn('[MainHandlerAdapter] sendNostrEvent not fully implemented') + return null + }, + + async sendEncryptedDM( + applicationId: string, + recipientPubkey: string, + content: string + ): Promise { + // This would need implementation using NIP-44 encryption + // For now, throw not implemented + throw new Error('sendEncryptedDM not yet implemented') + } + } +} diff --git a/src/index.ts b/src/index.ts index fbe6802c..87181da2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,7 @@ import 'dotenv/config' +import express from 'express' +import path from 'path' +import { fileURLToPath } from 'url' import NewServer from '../proto/autogenerated/ts/express_server.js' import GetServerMethods from './services/serverMethods/index.js' import serverOptions from './auth.js'; @@ -8,9 +11,15 @@ import { initMainHandler, initSettings } from './services/main/init.js'; import { nip19 } from 'nostr-tools' import { LoadStorageSettingsFromEnv } from './services/storage/index.js'; import { AppInfo } from './services/nostr/nostrPool.js'; +import { createExtensionLoader, ExtensionLoader } from './extensions/loader.js' +import { createMainHandlerAdapter } from './extensions/mainHandlerAdapter.js' +import type { HttpRoute } from './extensions/withdraw/types.js' //@ts-ignore const { nprofileEncode } = nip19 +const __filename = fileURLToPath(import.meta.url) +const __dirname = path.dirname(__filename) + const start = async () => { const log = getLogger({}) @@ -58,8 +67,91 @@ const start = async () => { wizard.AddConnectInfo(appNprofile, relays) } adminManager.setAppNprofile(appNprofile) + + // Initialize extension system + let extensionLoader: ExtensionLoader | null = null + const mainPort = settingsManager.getSettings().serviceSettings.servicePort + const extensionPort = mainPort + 1 + + // Extension routes run on a separate port (main port + 1) + // SERVICE_URL for extensions should point to this port for LNURL to work + // In production, use a reverse proxy to route /api/v1/lnurl/* to extension port + const extensionServiceUrl = process.env.EXTENSION_SERVICE_URL || `http://localhost:${extensionPort}` + + try { + log("initializing extension system") + const extensionsDir = path.join(__dirname, 'extensions') + const databaseDir = path.join(__dirname, '..', 'data', 'extensions') + + const mainHandlerAdapter = createMainHandlerAdapter(mainHandler) + extensionLoader = createExtensionLoader( + { extensionsDir, databaseDir }, + mainHandlerAdapter + ) + + await extensionLoader.loadAll() + log(`loaded ${extensionLoader.getAllExtensions().length} extension(s)`) + + // Set base URL for LNURL generation on withdraw extension + const withdrawExt = extensionLoader.getExtension('withdraw') + if (withdrawExt && withdrawExt.instance && 'setBaseUrl' in withdrawExt.instance) { + (withdrawExt.instance as any).setBaseUrl(extensionServiceUrl) + log(`withdraw extension base URL set to ${extensionServiceUrl}`) + } + } catch (e) { + log(`extension system initialization failed: ${e}`) + } + + // Create Express app for extension HTTP routes + const extensionApp = express() + extensionApp.use(express.json()) + + // Mount extension HTTP routes + if (extensionLoader) { + for (const ext of extensionLoader.getAllExtensions()) { + if (ext.status === 'ready' && 'getHttpRoutes' in ext.instance) { + const routes = (ext.instance as any).getHttpRoutes() as HttpRoute[] + for (const route of routes) { + log(`mounting extension route: ${route.method} ${route.path}`) + const handler = async (req: express.Request, res: express.Response) => { + try { + const httpReq = { + params: req.params, + query: req.query as Record, + body: req.body, + headers: req.headers as Record + } + const result = await route.handler(httpReq) + res.status(result.status) + if (result.headers) { + for (const [key, value] of Object.entries(result.headers)) { + res.setHeader(key, value) + } + } + res.json(result.body) + } catch (e: any) { + log(`extension route error: ${e.message}`) + res.status(500).json({ status: 'ERROR', reason: e.message }) + } + } + if (route.method === 'GET') { + extensionApp.get(route.path, handler) + } else if (route.method === 'POST') { + extensionApp.post(route.path, handler) + } + } + } + } + } + + // Start extension routes server + extensionApp.listen(extensionPort, () => { + log(`extension HTTP routes listening on port ${extensionPort}`) + }) + + // Start main proto server const Server = NewServer(serverMethods, serverOptions(mainHandler)) - Server.Listen(settingsManager.getSettings().serviceSettings.servicePort) + Server.Listen(mainPort) } start()