From c96cf26d0eb5ead4308ea20a491ef046090b2af3 Mon Sep 17 00:00:00 2001 From: Patrick Mulligan Date: Fri, 13 Feb 2026 12:50:26 -0500 Subject: [PATCH] feat(extensions): add extension loader infrastructure Adds a modular extension system for Lightning.Pub that allows third-party functionality to be added without modifying core code. Features: - ExtensionLoader: discovers and loads extensions from directory - ExtensionContext: provides extensions with access to Lightning.Pub APIs - ExtensionDatabase: isolated SQLite database per extension - Lifecycle management: initialize, shutdown, health checks - RPC method registration: extensions can add new RPC methods - Event dispatching: routes payments and Nostr events to extensions Co-Authored-By: Claude Opus 4.5 --- src/extensions/context.ts | 288 ++++++++++++++++++++++++++ src/extensions/database.ts | 148 ++++++++++++++ src/extensions/index.ts | 56 +++++ src/extensions/loader.ts | 406 +++++++++++++++++++++++++++++++++++++ src/extensions/types.ts | 231 +++++++++++++++++++++ 5 files changed, 1129 insertions(+) create mode 100644 src/extensions/context.ts create mode 100644 src/extensions/database.ts create mode 100644 src/extensions/index.ts create mode 100644 src/extensions/loader.ts create mode 100644 src/extensions/types.ts diff --git a/src/extensions/context.ts b/src/extensions/context.ts new file mode 100644 index 00000000..3916eb1e --- /dev/null +++ b/src/extensions/context.ts @@ -0,0 +1,288 @@ +import { + ExtensionContext, + ExtensionDatabase, + ExtensionInfo, + ApplicationInfo, + CreateInvoiceOptions, + CreatedInvoice, + PaymentReceivedData, + NostrEvent, + UnsignedNostrEvent, + RpcMethodHandler +} from './types.js' + +/** + * Main Handler interface (from Lightning.Pub) + * This is a minimal interface - the actual MainHandler has more methods + */ +export interface MainHandlerInterface { + // Application management + applicationManager: { + getById(id: string): Promise + } + + // Payment operations + paymentManager: { + createInvoice(params: { + applicationId: string + amountSats: number + memo?: string + expiry?: number + metadata?: Record + }): Promise<{ + id: string + paymentRequest: string + paymentHash: string + expiry: number + }> + + payInvoice(params: { + applicationId: string + paymentRequest: string + maxFeeSats?: number + }): Promise<{ + paymentHash: string + feeSats: number + }> + } + + // Nostr operations + sendNostrEvent(event: any): Promise + sendEncryptedDM(applicationId: string, recipientPubkey: string, content: string): Promise +} + +/** + * Callback registries for extension events + */ +interface CallbackRegistries { + paymentReceived: Array<(payment: PaymentReceivedData) => Promise> + nostrEvent: Array<(event: NostrEvent, applicationId: string) => Promise> +} + +/** + * Registered RPC method + */ +interface RegisteredMethod { + extensionId: string + handler: RpcMethodHandler +} + +/** + * Extension Context Implementation + * + * Provides the interface for extensions to interact with Lightning.Pub. + * Each extension gets its own context instance. + */ +export class ExtensionContextImpl implements ExtensionContext { + private callbacks: CallbackRegistries = { + paymentReceived: [], + nostrEvent: [] + } + + constructor( + private extensionInfo: ExtensionInfo, + private database: ExtensionDatabase, + private mainHandler: MainHandlerInterface, + private methodRegistry: Map + ) {} + + /** + * Get information about an application + */ + async getApplication(applicationId: string): Promise { + try { + const app = await this.mainHandler.applicationManager.getById(applicationId) + if (!app) return null + + return { + id: app.id, + name: app.name, + nostr_public: app.nostr_public, + balance_sats: app.balance || 0 + } + } catch (e) { + this.log('error', `Failed to get application ${applicationId}:`, e) + return null + } + } + + /** + * Create a Lightning invoice + */ + async createInvoice(amountSats: number, options: CreateInvoiceOptions = {}): Promise { + // Note: In practice, this needs an applicationId. Extensions typically + // get this from the RPC request context. For now, we'll need to handle + // this in the actual implementation. + throw new Error('createInvoice requires applicationId from request context') + } + + /** + * Create invoice with explicit application ID + * This is the internal method used by extensions + */ + async createInvoiceForApp( + applicationId: string, + amountSats: number, + options: CreateInvoiceOptions = {} + ): Promise { + const result = await this.mainHandler.paymentManager.createInvoice({ + applicationId, + amountSats, + memo: options.memo, + expiry: options.expiry, + metadata: { + ...options.metadata, + extension: this.extensionInfo.id + } + }) + + return { + id: result.id, + paymentRequest: result.paymentRequest, + paymentHash: result.paymentHash, + expiry: result.expiry + } + } + + /** + * Pay a Lightning invoice + */ + async payInvoice( + applicationId: string, + paymentRequest: string, + maxFeeSats?: number + ): Promise<{ paymentHash: string; feeSats: number }> { + return this.mainHandler.paymentManager.payInvoice({ + applicationId, + paymentRequest, + maxFeeSats + }) + } + + /** + * Send an encrypted DM via Nostr + */ + async sendEncryptedDM( + applicationId: string, + recipientPubkey: string, + content: string + ): Promise { + return this.mainHandler.sendEncryptedDM(applicationId, recipientPubkey, content) + } + + /** + * Publish a Nostr event + */ + async publishNostrEvent(event: UnsignedNostrEvent): Promise { + return this.mainHandler.sendNostrEvent(event) + } + + /** + * Subscribe to payment received callbacks + */ + onPaymentReceived(callback: (payment: PaymentReceivedData) => Promise): void { + this.callbacks.paymentReceived.push(callback) + } + + /** + * Subscribe to incoming Nostr events + */ + onNostrEvent(callback: (event: NostrEvent, applicationId: string) => Promise): void { + this.callbacks.nostrEvent.push(callback) + } + + /** + * Register an RPC method + */ + registerMethod(name: string, handler: RpcMethodHandler): void { + const fullName = name.startsWith(`${this.extensionInfo.id}.`) + ? name + : `${this.extensionInfo.id}.${name}` + + if (this.methodRegistry.has(fullName)) { + throw new Error(`RPC method ${fullName} already registered`) + } + + this.methodRegistry.set(fullName, { + extensionId: this.extensionInfo.id, + handler + }) + + this.log('debug', `Registered RPC method: ${fullName}`) + } + + /** + * Get the extension's database + */ + getDatabase(): ExtensionDatabase { + return this.database + } + + /** + * Log a message + */ + log(level: 'debug' | 'info' | 'warn' | 'error', message: string, ...args: any[]): void { + const prefix = `[Extension:${this.extensionInfo.id}]` + switch (level) { + case 'debug': + console.debug(prefix, message, ...args) + break + case 'info': + console.info(prefix, message, ...args) + break + case 'warn': + console.warn(prefix, message, ...args) + break + case 'error': + console.error(prefix, message, ...args) + break + } + } + + // ===== Internal Methods (called by ExtensionLoader) ===== + + /** + * Dispatch payment received event to extension callbacks + */ + async dispatchPaymentReceived(payment: PaymentReceivedData): Promise { + for (const callback of this.callbacks.paymentReceived) { + try { + await callback(payment) + } catch (e) { + this.log('error', 'Error in payment callback:', e) + } + } + } + + /** + * Dispatch Nostr event to extension callbacks + */ + async dispatchNostrEvent(event: NostrEvent, applicationId: string): Promise { + for (const callback of this.callbacks.nostrEvent) { + try { + await callback(event, applicationId) + } catch (e) { + this.log('error', 'Error in Nostr event callback:', e) + } + } + } + + /** + * Get registered callbacks for external access + */ + getCallbacks(): CallbackRegistries { + return this.callbacks + } +} + +/** + * Create an extension context + */ +export function createExtensionContext( + extensionInfo: ExtensionInfo, + database: ExtensionDatabase, + mainHandler: MainHandlerInterface, + methodRegistry: Map +): ExtensionContextImpl { + return new ExtensionContextImpl(extensionInfo, database, mainHandler, methodRegistry) +} diff --git a/src/extensions/database.ts b/src/extensions/database.ts new file mode 100644 index 00000000..6f300c36 --- /dev/null +++ b/src/extensions/database.ts @@ -0,0 +1,148 @@ +import Database from 'better-sqlite3' +import path from 'path' +import fs from 'fs' +import { ExtensionDatabase } from './types.js' + +/** + * Extension Database Implementation + * + * Provides isolated SQLite database access for each extension. + * Uses better-sqlite3 for synchronous, high-performance access. + */ +export class ExtensionDatabaseImpl implements ExtensionDatabase { + private db: Database.Database + private extensionId: string + + constructor(extensionId: string, databaseDir: string) { + this.extensionId = extensionId + + // Ensure database directory exists + if (!fs.existsSync(databaseDir)) { + fs.mkdirSync(databaseDir, { recursive: true }) + } + + // Create database file for this extension + const dbPath = path.join(databaseDir, `${extensionId}.db`) + this.db = new Database(dbPath) + + // Enable WAL mode for better concurrency + this.db.pragma('journal_mode = WAL') + + // Enable foreign keys + this.db.pragma('foreign_keys = ON') + + // Create metadata table for tracking migrations + this.db.exec(` + CREATE TABLE IF NOT EXISTS _extension_meta ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL + ) + `) + } + + /** + * Execute a write query (INSERT, UPDATE, DELETE, CREATE, etc.) + */ + async execute(sql: string, params: any[] = []): Promise<{ changes?: number; lastId?: number }> { + try { + const stmt = this.db.prepare(sql) + const result = stmt.run(...params) + + return { + changes: result.changes, + lastId: result.lastInsertRowid as number + } + } catch (e) { + console.error(`[Extension:${this.extensionId}] Database execute error:`, e) + throw e + } + } + + /** + * Execute a read query (SELECT) + */ + async query(sql: string, params: any[] = []): Promise { + try { + const stmt = this.db.prepare(sql) + return stmt.all(...params) as T[] + } catch (e) { + console.error(`[Extension:${this.extensionId}] Database query error:`, e) + throw e + } + } + + /** + * Execute multiple statements in a transaction + */ + async transaction(fn: () => Promise): Promise { + const runTransaction = this.db.transaction(() => { + // Note: better-sqlite3 transactions are synchronous + // We wrap the async function but it executes synchronously + return fn() + }) + + return runTransaction() as T + } + + /** + * Get a metadata value + */ + async getMeta(key: string): Promise { + const rows = await this.query<{ value: string }>( + 'SELECT value FROM _extension_meta WHERE key = ?', + [key] + ) + return rows.length > 0 ? rows[0].value : null + } + + /** + * Set a metadata value + */ + async setMeta(key: string, value: string): Promise { + await this.execute( + `INSERT INTO _extension_meta (key, value) VALUES (?, ?) + ON CONFLICT(key) DO UPDATE SET value = excluded.value`, + [key, value] + ) + } + + /** + * Get current migration version + */ + async getMigrationVersion(): Promise { + const version = await this.getMeta('migration_version') + return version ? parseInt(version, 10) : 0 + } + + /** + * Set migration version + */ + async setMigrationVersion(version: number): Promise { + await this.setMeta('migration_version', String(version)) + } + + /** + * Close the database connection + */ + close(): void { + this.db.close() + } + + /** + * Get the underlying database for advanced operations + * (Use with caution - bypasses isolation) + */ + getUnderlyingDb(): Database.Database { + return this.db + } +} + +/** + * Create an extension database instance + */ +export function createExtensionDatabase( + extensionId: string, + databaseDir: string +): ExtensionDatabaseImpl { + return new ExtensionDatabaseImpl(extensionId, databaseDir) +} diff --git a/src/extensions/index.ts b/src/extensions/index.ts new file mode 100644 index 00000000..208b7c86 --- /dev/null +++ b/src/extensions/index.ts @@ -0,0 +1,56 @@ +/** + * Lightning.Pub Extension System + * + * This module provides the extension infrastructure for Lightning.Pub. + * Extensions can add functionality like marketplaces, subscriptions, + * tipping, and more. + * + * Usage: + * + * ```typescript + * import { createExtensionLoader, ExtensionLoaderConfig } from './extensions' + * + * const config: ExtensionLoaderConfig = { + * extensionsDir: './extensions', + * databaseDir: './data/extensions' + * } + * + * const loader = createExtensionLoader(config, mainHandler) + * await loader.loadAll() + * + * // Call extension methods + * const result = await loader.callMethod( + * 'marketplace.createStall', + * { name: 'My Shop', currency: 'sat', shipping_zones: [...] }, + * applicationId + * ) + * ``` + */ + +// Export types +export { + Extension, + ExtensionInfo, + ExtensionContext, + ExtensionDatabase, + ExtensionModule, + ExtensionConstructor, + LoadedExtension, + ExtensionLoaderConfig, + ApplicationInfo, + CreateInvoiceOptions, + CreatedInvoice, + PaymentReceivedData, + NostrEvent, + UnsignedNostrEvent, + RpcMethodHandler +} from './types.js' + +// Export loader +export { ExtensionLoader, createExtensionLoader } from './loader.js' + +// Export database utilities +export { ExtensionDatabaseImpl, createExtensionDatabase } from './database.js' + +// Export context utilities +export { ExtensionContextImpl, createExtensionContext, MainHandlerInterface } from './context.js' diff --git a/src/extensions/loader.ts b/src/extensions/loader.ts new file mode 100644 index 00000000..9fc453c0 --- /dev/null +++ b/src/extensions/loader.ts @@ -0,0 +1,406 @@ +import path from 'path' +import fs from 'fs' +import { + Extension, + ExtensionInfo, + ExtensionModule, + LoadedExtension, + ExtensionLoaderConfig, + RpcMethodHandler, + PaymentReceivedData, + NostrEvent +} from './types.js' +import { ExtensionDatabaseImpl, createExtensionDatabase } from './database.js' +import { ExtensionContextImpl, createExtensionContext, MainHandlerInterface } from './context.js' + +/** + * Registered RPC method entry + */ +interface RegisteredMethod { + extensionId: string + handler: RpcMethodHandler +} + +/** + * Extension Loader + * + * Discovers, loads, and manages Lightning.Pub extensions. + * Provides lifecycle management and event dispatching. + */ +export class ExtensionLoader { + private config: ExtensionLoaderConfig + private mainHandler: MainHandlerInterface + private extensions: Map = new Map() + private contexts: Map = new Map() + private methodRegistry: Map = new Map() + private initialized = false + + constructor(config: ExtensionLoaderConfig, mainHandler: MainHandlerInterface) { + this.config = config + this.mainHandler = mainHandler + } + + /** + * Discover and load all extensions + */ + async loadAll(): Promise { + if (this.initialized) { + throw new Error('Extension loader already initialized') + } + + console.log('[Extensions] Loading extensions from:', this.config.extensionsDir) + + // Ensure directories exist + if (!fs.existsSync(this.config.extensionsDir)) { + console.log('[Extensions] Extensions directory does not exist, creating...') + fs.mkdirSync(this.config.extensionsDir, { recursive: true }) + this.initialized = true + return + } + + if (!fs.existsSync(this.config.databaseDir)) { + fs.mkdirSync(this.config.databaseDir, { recursive: true }) + } + + // Discover extensions + const extensionDirs = await this.discoverExtensions() + console.log(`[Extensions] Found ${extensionDirs.length} extension(s)`) + + // Load extensions in dependency order + const loadOrder = await this.resolveDependencies(extensionDirs) + + for (const extDir of loadOrder) { + try { + await this.loadExtension(extDir) + } catch (e) { + console.error(`[Extensions] Failed to load extension from ${extDir}:`, e) + } + } + + this.initialized = true + console.log(`[Extensions] Loaded ${this.extensions.size} extension(s)`) + } + + /** + * Discover extension directories + */ + private async discoverExtensions(): Promise { + const entries = fs.readdirSync(this.config.extensionsDir, { withFileTypes: true }) + const extensionDirs: string[] = [] + + for (const entry of entries) { + if (!entry.isDirectory()) continue + + const extDir = path.join(this.config.extensionsDir, entry.name) + const indexPath = path.join(extDir, 'index.ts') + const indexJsPath = path.join(extDir, 'index.js') + + // Check for index file + if (fs.existsSync(indexPath) || fs.existsSync(indexJsPath)) { + // Check enabled/disabled lists + if (this.config.disabledExtensions?.includes(entry.name)) { + console.log(`[Extensions] Skipping disabled extension: ${entry.name}`) + continue + } + + if (this.config.enabledExtensions && + !this.config.enabledExtensions.includes(entry.name)) { + console.log(`[Extensions] Skipping non-enabled extension: ${entry.name}`) + continue + } + + extensionDirs.push(extDir) + } + } + + return extensionDirs + } + + /** + * Resolve extension dependencies and return load order + */ + private async resolveDependencies(extensionDirs: string[]): Promise { + // For now, simple alphabetical order + // TODO: Implement proper dependency resolution with topological sort + return extensionDirs.sort() + } + + /** + * Load a single extension + */ + private async loadExtension(extensionDir: string): Promise { + const dirName = path.basename(extensionDir) + console.log(`[Extensions] Loading extension: ${dirName}`) + + // Determine index file path + let indexPath = path.join(extensionDir, 'index.js') + if (!fs.existsSync(indexPath)) { + indexPath = path.join(extensionDir, 'index.ts') + } + + // Dynamic import + const moduleUrl = `file://${indexPath}` + const module = await import(moduleUrl) as ExtensionModule + + if (!module.default) { + throw new Error(`Extension ${dirName} has no default export`) + } + + // Instantiate extension + const ExtensionClass = module.default + const instance = new ExtensionClass() as Extension + + if (!instance.info) { + throw new Error(`Extension ${dirName} has no info property`) + } + + const info = instance.info + + // Validate extension ID matches directory name + if (info.id !== dirName) { + console.warn( + `[Extensions] Extension ID '${info.id}' doesn't match directory '${dirName}'` + ) + } + + // Check for duplicate + if (this.extensions.has(info.id)) { + throw new Error(`Extension ${info.id} already loaded`) + } + + // Create isolated database + const database = createExtensionDatabase(info.id, this.config.databaseDir) + + // Create context + const context = createExtensionContext( + info, + database, + this.mainHandler, + this.methodRegistry + ) + + // Track as loading + const loaded: LoadedExtension = { + info, + instance, + database, + status: 'loading', + loadedAt: Date.now() + } + this.extensions.set(info.id, loaded) + this.contexts.set(info.id, context) + + try { + // Initialize extension + await instance.initialize(context, database) + + loaded.status = 'ready' + console.log(`[Extensions] Extension ${info.id} v${info.version} loaded successfully`) + } catch (e) { + loaded.status = 'error' + loaded.error = e as Error + console.error(`[Extensions] Extension ${info.id} initialization failed:`, e) + throw e + } + } + + /** + * Unload a specific extension + */ + async unloadExtension(extensionId: string): Promise { + const loaded = this.extensions.get(extensionId) + if (!loaded) { + throw new Error(`Extension ${extensionId} not found`) + } + + console.log(`[Extensions] Unloading extension: ${extensionId}`) + + try { + // Call shutdown if available + if (loaded.instance.shutdown) { + await loaded.instance.shutdown() + } + + loaded.status = 'stopped' + } catch (e) { + console.error(`[Extensions] Error during ${extensionId} shutdown:`, e) + } + + // Close database + if (loaded.database instanceof ExtensionDatabaseImpl) { + loaded.database.close() + } + + // Remove registered methods + for (const [name, method] of this.methodRegistry.entries()) { + if (method.extensionId === extensionId) { + this.methodRegistry.delete(name) + } + } + + // Remove from maps + this.extensions.delete(extensionId) + this.contexts.delete(extensionId) + } + + /** + * Shutdown all extensions + */ + async shutdown(): Promise { + console.log('[Extensions] Shutting down all extensions...') + + for (const extensionId of this.extensions.keys()) { + try { + await this.unloadExtension(extensionId) + } catch (e) { + console.error(`[Extensions] Error unloading ${extensionId}:`, e) + } + } + + console.log('[Extensions] All extensions shut down') + } + + /** + * Get a loaded extension + */ + getExtension(extensionId: string): LoadedExtension | undefined { + return this.extensions.get(extensionId) + } + + /** + * Get all loaded extensions + */ + getAllExtensions(): LoadedExtension[] { + return Array.from(this.extensions.values()) + } + + /** + * Check if an extension is loaded and ready + */ + isReady(extensionId: string): boolean { + const ext = this.extensions.get(extensionId) + return ext?.status === 'ready' + } + + /** + * Get all registered RPC methods + */ + getRegisteredMethods(): Map { + return this.methodRegistry + } + + /** + * Call an extension RPC method + */ + async callMethod( + methodName: string, + request: any, + applicationId: string, + userPubkey?: string + ): Promise { + const method = this.methodRegistry.get(methodName) + if (!method) { + throw new Error(`Unknown method: ${methodName}`) + } + + const ext = this.extensions.get(method.extensionId) + if (!ext || ext.status !== 'ready') { + throw new Error(`Extension ${method.extensionId} not ready`) + } + + return method.handler(request, applicationId, userPubkey) + } + + /** + * Check if a method exists + */ + hasMethod(methodName: string): boolean { + return this.methodRegistry.has(methodName) + } + + /** + * Dispatch payment received event to all extensions + */ + async dispatchPaymentReceived(payment: PaymentReceivedData): Promise { + for (const context of this.contexts.values()) { + try { + await context.dispatchPaymentReceived(payment) + } catch (e) { + console.error('[Extensions] Error dispatching payment:', e) + } + } + } + + /** + * Dispatch Nostr event to all extensions + */ + async dispatchNostrEvent(event: NostrEvent, applicationId: string): Promise { + for (const context of this.contexts.values()) { + try { + await context.dispatchNostrEvent(event, applicationId) + } catch (e) { + console.error('[Extensions] Error dispatching Nostr event:', e) + } + } + } + + /** + * Run health checks on all extensions + */ + async healthCheck(): Promise> { + const results = new Map() + + for (const [id, ext] of this.extensions.entries()) { + if (ext.status !== 'ready') { + results.set(id, false) + continue + } + + try { + if (ext.instance.healthCheck) { + results.set(id, await ext.instance.healthCheck()) + } else { + results.set(id, true) + } + } catch (e) { + results.set(id, false) + } + } + + return results + } + + /** + * Get extension status summary + */ + getStatus(): { + total: number + ready: number + error: number + extensions: Array<{ id: string; name: string; version: string; status: string }> + } { + const extensions = this.getAllExtensions().map(ext => ({ + id: ext.info.id, + name: ext.info.name, + version: ext.info.version, + status: ext.status + })) + + return { + total: extensions.length, + ready: extensions.filter(e => e.status === 'ready').length, + error: extensions.filter(e => e.status === 'error').length, + extensions + } + } +} + +/** + * Create an extension loader instance + */ +export function createExtensionLoader( + config: ExtensionLoaderConfig, + mainHandler: MainHandlerInterface +): ExtensionLoader { + return new ExtensionLoader(config, mainHandler) +} diff --git a/src/extensions/types.ts b/src/extensions/types.ts new file mode 100644 index 00000000..86b5ebee --- /dev/null +++ b/src/extensions/types.ts @@ -0,0 +1,231 @@ +/** + * Extension System Core Types + * + * These types define the contract between Lightning.Pub and extensions. + */ + +/** + * Extension metadata + */ +export interface ExtensionInfo { + id: string // Unique identifier (lowercase, no spaces) + name: string // Display name + version: string // Semver version + description: string // Short description + author: string // Author name or organization + minPubVersion?: string // Minimum Lightning.Pub version required + dependencies?: string[] // Other extension IDs this depends on +} + +/** + * Extension database interface + * Provides isolated database access for each extension + */ +export interface ExtensionDatabase { + /** + * Execute a write query (INSERT, UPDATE, DELETE, CREATE, etc.) + */ + execute(sql: string, params?: any[]): Promise<{ changes?: number; lastId?: number }> + + /** + * Execute a read query (SELECT) + */ + query(sql: string, params?: any[]): Promise + + /** + * Execute multiple statements in a transaction + */ + transaction(fn: () => Promise): Promise +} + +/** + * Application info provided to extensions + */ +export interface ApplicationInfo { + id: string + name: string + nostr_public: string // Application's Nostr pubkey (hex) + balance_sats: number +} + +/** + * Invoice creation options + */ +export interface CreateInvoiceOptions { + memo?: string + expiry?: number // Seconds until expiry + metadata?: Record // Custom metadata for callbacks +} + +/** + * Created invoice result + */ +export interface CreatedInvoice { + id: string // Internal invoice ID + paymentRequest: string // BOLT11 invoice string + paymentHash: string // Payment hash (hex) + expiry: number // Expiry timestamp +} + +/** + * Payment received callback data + */ +export interface PaymentReceivedData { + invoiceId: string + paymentHash: string + amountSats: number + metadata?: Record +} + +/** + * Nostr event structure (minimal) + */ +export interface NostrEvent { + id: string + pubkey: string + created_at: number + kind: number + tags: string[][] + content: string + sig?: string +} + +/** + * Unsigned Nostr event for publishing + */ +export interface UnsignedNostrEvent { + kind: number + pubkey: string + created_at: number + tags: string[][] + content: string +} + +/** + * RPC method handler function + */ +export type RpcMethodHandler = ( + request: any, + applicationId: string, + userPubkey?: string +) => Promise + +/** + * Extension context - interface provided to extensions for interacting with Lightning.Pub + */ +export interface ExtensionContext { + /** + * Get information about an application + */ + getApplication(applicationId: string): Promise + + /** + * Create a Lightning invoice + */ + createInvoice(amountSats: number, options?: CreateInvoiceOptions): Promise + + /** + * Pay a Lightning invoice (requires sufficient balance) + */ + payInvoice(applicationId: string, paymentRequest: string, maxFeeSats?: number): Promise<{ + paymentHash: string + feeSats: number + }> + + /** + * Send an encrypted DM via Nostr (NIP-44) + */ + sendEncryptedDM(applicationId: string, recipientPubkey: string, content: string): Promise + + /** + * Publish a Nostr event (signed by application's key) + */ + publishNostrEvent(event: UnsignedNostrEvent): Promise + + /** + * Subscribe to payment received callbacks + */ + onPaymentReceived(callback: (payment: PaymentReceivedData) => Promise): void + + /** + * Subscribe to incoming Nostr events for the application + */ + onNostrEvent(callback: (event: NostrEvent, applicationId: string) => Promise): void + + /** + * Register an RPC method + */ + registerMethod(name: string, handler: RpcMethodHandler): void + + /** + * Get the extension's isolated database + */ + getDatabase(): ExtensionDatabase + + /** + * Log a message (prefixed with extension ID) + */ + log(level: 'debug' | 'info' | 'warn' | 'error', message: string, ...args: any[]): void +} + +/** + * Extension interface - what extensions must implement + */ +export interface Extension { + /** + * Extension metadata + */ + readonly info: ExtensionInfo + + /** + * Initialize the extension + * Called once when the extension is loaded + */ + initialize(ctx: ExtensionContext, db: ExtensionDatabase): Promise + + /** + * Shutdown the extension + * Called when Lightning.Pub is shutting down + */ + shutdown?(): Promise + + /** + * Health check + * Return true if extension is healthy + */ + healthCheck?(): Promise +} + +/** + * Extension constructor type + */ +export type ExtensionConstructor = new () => Extension + +/** + * Extension module default export + */ +export interface ExtensionModule { + default: ExtensionConstructor +} + +/** + * Loaded extension state + */ +export interface LoadedExtension { + info: ExtensionInfo + instance: Extension + database: ExtensionDatabase + status: 'loading' | 'ready' | 'error' | 'stopped' + error?: Error + loadedAt: number +} + +/** + * Extension loader configuration + */ +export interface ExtensionLoaderConfig { + extensionsDir: string // Directory containing extensions + databaseDir: string // Directory for extension databases + enabledExtensions?: string[] // If set, only load these extensions + disabledExtensions?: string[] // Extensions to skip +}