diff --git a/src/extensions/README.md b/src/extensions/README.md new file mode 100644 index 00000000..2f4e0b15 --- /dev/null +++ b/src/extensions/README.md @@ -0,0 +1,731 @@ +# Lightning.Pub Extension System + +A modular extension system that allows third-party functionality to be added to Lightning.Pub without modifying core code. + +## Table of Contents + +- [Overview](#overview) +- [Architecture](#architecture) +- [Creating an Extension](#creating-an-extension) +- [Extension Lifecycle](#extension-lifecycle) +- [ExtensionContext API](#extensioncontext-api) +- [Database Isolation](#database-isolation) +- [RPC Methods](#rpc-methods) +- [HTTP Routes](#http-routes) +- [Event Handling](#event-handling) +- [Configuration](#configuration) +- [Examples](#examples) + +--- + +## Overview + +The extension system provides: + +- **Modularity**: Extensions are self-contained modules with their own code and data +- **Isolation**: Each extension gets its own SQLite database +- **Integration**: Extensions can register RPC methods, handle events, and interact with Lightning.Pub's payment and Nostr systems +- **Lifecycle Management**: Automatic discovery, loading, and graceful shutdown + +### Built-in Extensions + +| Extension | Description | +|-----------|-------------| +| `marketplace` | NIP-15 Nostr marketplace for selling products via Lightning | +| `withdraw` | LNURL-withdraw (LUD-03) for vouchers, faucets, and gifts | + +--- + +## Architecture + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ Lightning.Pub │ +├─────────────────────────────────────────────────────────────────┤ +│ Extension Loader │ +│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ +│ │ Extension A │ │ Extension B │ │ Extension C │ ... │ +│ │ ┌───────┐ │ │ ┌───────┐ │ │ ┌───────┐ │ │ +│ │ │Context│ │ │ │Context│ │ │ │Context│ │ │ +│ │ └───────┘ │ │ └───────┘ │ │ └───────┘ │ │ +│ │ ┌───────┐ │ │ ┌───────┐ │ │ ┌───────┐ │ │ +│ │ │ DB │ │ │ │ DB │ │ │ │ DB │ │ │ +│ │ └───────┘ │ │ └───────┘ │ │ └───────┘ │ │ +│ └─────────────┘ └─────────────┘ └─────────────┘ │ +├─────────────────────────────────────────────────────────────────┤ +│ Payment Manager │ Nostr Transport │ Application Manager │ +└─────────────────────────────────────────────────────────────────┘ +``` + +### Key Components + +| Component | File | Description | +|-----------|------|-------------| +| `ExtensionLoader` | `loader.ts` | Discovers, loads, and manages extensions | +| `ExtensionContext` | `context.ts` | Bridge between extensions and Lightning.Pub | +| `ExtensionDatabase` | `database.ts` | Isolated SQLite database per extension | + +--- + +## Creating an Extension + +### Directory Structure + +``` +src/extensions/ +└── my-extension/ + ├── index.ts # Main entry point (required) + ├── types.ts # TypeScript interfaces + ├── migrations.ts # Database migrations + └── managers/ # Business logic + └── myManager.ts +``` + +### Minimal Extension + +```typescript +// src/extensions/my-extension/index.ts + +import { Extension, ExtensionInfo, ExtensionContext, ExtensionDatabase } from '../types.js' + +export default class MyExtension implements Extension { + readonly info: ExtensionInfo = { + id: 'my-extension', // Must match directory name + name: 'My Extension', + version: '1.0.0', + description: 'Does something useful', + author: 'Your Name', + minPubVersion: '1.0.0' // Minimum Lightning.Pub version + } + + async initialize(ctx: ExtensionContext, db: ExtensionDatabase): Promise { + // Run migrations + await db.execute(` + CREATE TABLE IF NOT EXISTS my_table ( + id TEXT PRIMARY KEY, + data TEXT + ) + `) + + // Register RPC methods + ctx.registerMethod('my-extension.doSomething', async (req, appId) => { + return { result: 'done' } + }) + + ctx.log('info', 'Extension initialized') + } + + async shutdown(): Promise { + // Cleanup resources + } +} +``` + +### Extension Interface + +```typescript +interface Extension { + // Required: Extension metadata + readonly info: ExtensionInfo + + // Required: Called once when extension is loaded + initialize(ctx: ExtensionContext, db: ExtensionDatabase): Promise + + // Optional: Called when Lightning.Pub shuts down + shutdown?(): Promise + + // Optional: Health check for monitoring + healthCheck?(): Promise +} + +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 + minPubVersion?: string // Minimum Lightning.Pub version + dependencies?: string[] // Other extension IDs required +} +``` + +--- + +## Extension Lifecycle + +``` +┌──────────────┐ +│ Discover │ Scan extensions directory for index.ts files +└──────┬───────┘ + │ + ▼ +┌──────────────┐ +│ Load │ Import module, instantiate class +└──────┬───────┘ + │ + ▼ +┌──────────────┐ +│ Initialize │ Create database, call initialize() +└──────┬───────┘ + │ + ▼ +┌──────────────┐ +│ Ready │ Extension is active, handling requests +└──────┬───────┘ + │ + ▼ (on shutdown) +┌──────────────┐ +│ Shutdown │ Call shutdown(), close database +└──────────────┘ +``` + +### States + +| State | Description | +|-------|-------------| +| `loading` | Extension is being loaded | +| `ready` | Extension is active and healthy | +| `error` | Initialization failed | +| `stopped` | Extension has been shut down | + +--- + +## ExtensionContext API + +The `ExtensionContext` is passed to your extension during initialization. It provides access to Lightning.Pub functionality. + +### Application Management + +```typescript +// Get information about an application +const app = await ctx.getApplication(applicationId) +// Returns: { id, name, nostr_public, balance_sats } | null +``` + +### Payment Operations + +```typescript +// Create a Lightning invoice +const invoice = await ctx.createInvoice(amountSats, { + memo: 'Payment for service', + expiry: 3600, // seconds + metadata: { order_id: '123' } // Returned in payment callback +}) +// Returns: { id, paymentRequest, paymentHash, expiry } + +// Pay a Lightning invoice +const result = await ctx.payInvoice(applicationId, bolt11Invoice, maxFeeSats) +// Returns: { paymentHash, feeSats } +``` + +### Nostr Operations + +```typescript +// Send encrypted DM (NIP-44) +const eventId = await ctx.sendEncryptedDM(applicationId, recipientPubkey, content) + +// Publish a Nostr event (signed by application's key) +const eventId = await ctx.publishNostrEvent({ + kind: 30017, + pubkey: appPubkey, + created_at: Math.floor(Date.now() / 1000), + tags: [['d', 'identifier']], + content: JSON.stringify(data) +}) +``` + +### RPC Method Registration + +```typescript +// Register a method that can be called via RPC +ctx.registerMethod('my-extension.methodName', async (request, applicationId, userPubkey?) => { + // request: The RPC request payload + // applicationId: The calling application's ID + // userPubkey: The user's Nostr pubkey (if authenticated) + + return { result: 'success' } +}) +``` + +### Event Subscriptions + +```typescript +// Subscribe to payment received events +ctx.onPaymentReceived(async (payment) => { + // payment: { invoiceId, paymentHash, amountSats, metadata } + + if (payment.metadata?.extension === 'my-extension') { + // Handle payment for this extension + } +}) + +// Subscribe to incoming Nostr events +ctx.onNostrEvent(async (event, applicationId) => { + // event: { id, pubkey, kind, tags, content, created_at } + // applicationId: The application this event is for + + if (event.kind === 4) { // DM + // Handle incoming message + } +}) +``` + +### Logging + +```typescript +ctx.log('debug', 'Detailed debugging info') +ctx.log('info', 'Normal operation info') +ctx.log('warn', 'Warning message') +ctx.log('error', 'Error occurred', errorObject) +``` + +--- + +## Database Isolation + +Each extension gets its own SQLite database file at: +``` +{databaseDir}/{extension-id}.db +``` + +### Database Interface + +```typescript +interface ExtensionDatabase { + // Execute write queries (INSERT, UPDATE, DELETE, CREATE) + execute(sql: string, params?: any[]): Promise<{ changes?: number; lastId?: number }> + + // Execute read queries (SELECT) + query(sql: string, params?: any[]): Promise + + // Run multiple statements in a transaction + transaction(fn: () => Promise): Promise +} +``` + +### Migration Pattern + +```typescript +// migrations.ts + +export interface Migration { + version: number + name: string + up: (db: ExtensionDatabase) => Promise +} + +export const migrations: Migration[] = [ + { + version: 1, + name: 'create_initial_tables', + up: async (db) => { + await db.execute(` + CREATE TABLE items ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + created_at INTEGER NOT NULL + ) + `) + } + }, + { + version: 2, + name: 'add_status_column', + up: async (db) => { + await db.execute(`ALTER TABLE items ADD COLUMN status TEXT DEFAULT 'active'`) + } + } +] + +// Run migrations in initialize() +export async function runMigrations(db: ExtensionDatabase): Promise { + const result = await db.query<{ value: string }>( + `SELECT value FROM _extension_meta WHERE key = 'migration_version'` + ).catch(() => []) + + const currentVersion = result.length > 0 ? parseInt(result[0].value, 10) : 0 + + for (const migration of migrations) { + if (migration.version > currentVersion) { + console.log(`Running migration ${migration.version}: ${migration.name}`) + await migration.up(db) + await db.execute( + `INSERT INTO _extension_meta (key, value) VALUES ('migration_version', ?) + ON CONFLICT(key) DO UPDATE SET value = excluded.value`, + [String(migration.version)] + ) + } + } +} +``` + +--- + +## RPC Methods + +Extensions register RPC methods that can be called by clients. + +### Naming Convention + +Methods should be namespaced with the extension ID: +``` +{extension-id}.{methodName} +``` + +Examples: +- `marketplace.createStall` +- `withdraw.createLink` + +### Method Handler Signature + +```typescript +type RpcMethodHandler = ( + request: any, // The request payload + applicationId: string, // The calling application + userPubkey?: string // The authenticated user (if any) +) => Promise +``` + +### Example + +```typescript +ctx.registerMethod('my-extension.createItem', async (req, appId, userPubkey) => { + // Validate request + if (!req.name) { + throw new Error('Name is required') + } + + // Create item + const item = await this.manager.create(appId, req) + + // Return response + return { item } +}) +``` + +--- + +## HTTP Routes + +Some extensions need HTTP endpoints (e.g., LNURL protocol). Extensions can define routes that the main application mounts. + +### Defining Routes + +```typescript +interface HttpRoute { + method: 'GET' | 'POST' + path: string + handler: (req: HttpRequest) => Promise +} + +interface HttpRequest { + params: Record // URL path params + query: Record // Query string params + body?: any // POST body + headers: Record +} + +interface HttpResponse { + status: number + body: any + headers?: Record +} +``` + +### Example + +```typescript +class MyExtension implements Extension { + getHttpRoutes(): HttpRoute[] { + return [ + { + method: 'GET', + path: '/api/v1/my-extension/:id', + handler: async (req) => { + const item = await this.getItem(req.params.id) + return { + status: 200, + body: item, + headers: { 'Content-Type': 'application/json' } + } + } + } + ] + } +} +``` + +--- + +## Event Handling + +### Payment Callbacks + +When you create an invoice with metadata, you'll receive that metadata back in the payment callback: + +```typescript +// Creating invoice with metadata +const invoice = await ctx.createInvoice(1000, { + metadata: { + extension: 'my-extension', + order_id: 'order-123' + } +}) + +// Handling payment +ctx.onPaymentReceived(async (payment) => { + if (payment.metadata?.extension === 'my-extension') { + const orderId = payment.metadata.order_id + await this.handlePayment(orderId, payment) + } +}) +``` + +### Nostr Events + +Subscribe to Nostr events for your application: + +```typescript +ctx.onNostrEvent(async (event, applicationId) => { + // Filter by event kind + if (event.kind === 4) { // Encrypted DM + await this.handleDirectMessage(event, applicationId) + } +}) +``` + +--- + +## Configuration + +### Loader Configuration + +```typescript +interface ExtensionLoaderConfig { + extensionsDir: string // Directory containing extensions + databaseDir: string // Directory for extension databases + enabledExtensions?: string[] // Whitelist (if set, only these load) + disabledExtensions?: string[] // Blacklist +} +``` + +### Usage + +```typescript +import { createExtensionLoader } from './extensions' + +const loader = createExtensionLoader({ + extensionsDir: './src/extensions', + databaseDir: './data/extensions', + disabledExtensions: ['experimental-ext'] +}, mainHandler) + +await loader.loadAll() + +// Call extension methods +const result = await loader.callMethod( + 'marketplace.createStall', + { name: 'My Shop', currency: 'sat', shipping_zones: [] }, + applicationId, + userPubkey +) + +// Dispatch events +loader.dispatchPaymentReceived(paymentData) +loader.dispatchNostrEvent(event, applicationId) + +// Shutdown +await loader.shutdown() +``` + +--- + +## Examples + +### Example: Simple Counter Extension + +```typescript +// src/extensions/counter/index.ts + +import { Extension, ExtensionInfo, ExtensionContext, ExtensionDatabase } from '../types.js' + +export default class CounterExtension implements Extension { + readonly info: ExtensionInfo = { + id: 'counter', + name: 'Simple Counter', + version: '1.0.0', + description: 'A simple counter for each application', + author: 'Example' + } + + private db!: ExtensionDatabase + + async initialize(ctx: ExtensionContext, db: ExtensionDatabase): Promise { + this.db = db + + await db.execute(` + CREATE TABLE IF NOT EXISTS counters ( + application_id TEXT PRIMARY KEY, + count INTEGER NOT NULL DEFAULT 0 + ) + `) + + ctx.registerMethod('counter.increment', async (req, appId) => { + await db.execute( + `INSERT INTO counters (application_id, count) VALUES (?, 1) + ON CONFLICT(application_id) DO UPDATE SET count = count + 1`, + [appId] + ) + const result = await db.query<{ count: number }>( + 'SELECT count FROM counters WHERE application_id = ?', + [appId] + ) + return { count: result[0]?.count || 0 } + }) + + ctx.registerMethod('counter.get', async (req, appId) => { + const result = await db.query<{ count: number }>( + 'SELECT count FROM counters WHERE application_id = ?', + [appId] + ) + return { count: result[0]?.count || 0 } + }) + + ctx.registerMethod('counter.reset', async (req, appId) => { + await db.execute( + 'UPDATE counters SET count = 0 WHERE application_id = ?', + [appId] + ) + return { count: 0 } + }) + } +} +``` + +### Example: Payment-Triggered Extension + +```typescript +// src/extensions/donations/index.ts + +import { Extension, ExtensionContext, ExtensionDatabase } from '../types.js' + +export default class DonationsExtension implements Extension { + readonly info = { + id: 'donations', + name: 'Donations', + version: '1.0.0', + description: 'Accept donations with thank-you messages', + author: 'Example' + } + + private db!: ExtensionDatabase + private ctx!: ExtensionContext + + async initialize(ctx: ExtensionContext, db: ExtensionDatabase): Promise { + this.db = db + this.ctx = ctx + + await db.execute(` + CREATE TABLE IF NOT EXISTS donations ( + id TEXT PRIMARY KEY, + application_id TEXT NOT NULL, + amount_sats INTEGER NOT NULL, + donor_pubkey TEXT, + message TEXT, + created_at INTEGER NOT NULL + ) + `) + + // Create donation invoice + ctx.registerMethod('donations.createInvoice', async (req, appId) => { + const invoice = await ctx.createInvoice(req.amount_sats, { + memo: req.message || 'Donation', + metadata: { + extension: 'donations', + donor_pubkey: req.donor_pubkey, + message: req.message + } + }) + return { invoice: invoice.paymentRequest } + }) + + // Handle successful payments + ctx.onPaymentReceived(async (payment) => { + if (payment.metadata?.extension !== 'donations') return + + // Record donation + await db.execute( + `INSERT INTO donations (id, application_id, amount_sats, donor_pubkey, message, created_at) + VALUES (?, ?, ?, ?, ?, ?)`, + [ + payment.paymentHash, + payment.metadata.application_id, + payment.amountSats, + payment.metadata.donor_pubkey, + payment.metadata.message, + Math.floor(Date.now() / 1000) + ] + ) + + // Send thank-you DM if donor has pubkey + if (payment.metadata.donor_pubkey) { + await ctx.sendEncryptedDM( + payment.metadata.application_id, + payment.metadata.donor_pubkey, + `Thank you for your donation of ${payment.amountSats} sats!` + ) + } + }) + + // List donations + ctx.registerMethod('donations.list', async (req, appId) => { + const donations = await db.query( + `SELECT * FROM donations WHERE application_id = ? ORDER BY created_at DESC LIMIT ?`, + [appId, req.limit || 50] + ) + return { donations } + }) + } +} +``` + +--- + +## Best Practices + +1. **Namespace your methods**: Always prefix RPC methods with your extension ID +2. **Use migrations**: Never modify existing migration files; create new ones +3. **Handle errors gracefully**: Throw descriptive errors, don't return error objects +4. **Clean up in shutdown**: Close connections, cancel timers, etc. +5. **Log appropriately**: Use debug for verbose info, error for failures +6. **Validate inputs**: Check request parameters before processing +7. **Use transactions**: For multi-step database operations +8. **Document your API**: Include types and descriptions for RPC methods + +--- + +## Troubleshooting + +### Extension not loading + +1. Check that directory name matches `info.id` +2. Verify `index.ts` has a default export +3. Check for TypeScript/import errors in logs + +### Database errors + +1. Check migration syntax +2. Verify column types match queries +3. Look for migration version conflicts + +### RPC method not found + +1. Verify method is registered in `initialize()` +2. Check method name includes extension prefix +3. Ensure extension status is `ready` + +### Payment callbacks not firing + +1. Verify `metadata.extension` matches your extension ID +2. Check that `onPaymentReceived` is registered in `initialize()` +3. Confirm invoice was created through the extension diff --git a/src/extensions/context.ts b/src/extensions/context.ts new file mode 100644 index 00000000..b1c6e8d6 --- /dev/null +++ b/src/extensions/context.ts @@ -0,0 +1,309 @@ +import { + ExtensionContext, + ExtensionDatabase, + ExtensionInfo, + ApplicationInfo, + CreateInvoiceOptions, + CreatedInvoice, + PaymentReceivedData, + NostrEvent, + UnsignedNostrEvent, + RpcMethodHandler, + LnurlPayInfo +} 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 + }> + + /** + * Get LNURL-pay info for a user by their Nostr pubkey + * This enables Lightning Address (LUD-16) and zap (NIP-57) support + */ + getLnurlPayInfoByPubkey(pubkeyHex: string, options?: { + metadata?: string + description?: string + }): Promise + } + + // 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) + } + + /** + * Get LNURL-pay info for a user by pubkey + * Enables Lightning Address and zap support + */ + async getLnurlPayInfo(pubkeyHex: string, options?: { + metadata?: string + description?: string + }): Promise { + return this.mainHandler.paymentManager.getLnurlPayInfoByPubkey(pubkeyHex, options) + } + + /** + * 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/marketplace/index.ts b/src/extensions/marketplace/index.ts new file mode 100644 index 00000000..0bd470e3 --- /dev/null +++ b/src/extensions/marketplace/index.ts @@ -0,0 +1,390 @@ +import { + Extension, ExtensionContext, ExtensionDatabase, ExtensionInfo, + CreateStallRequest, UpdateStallRequest, + CreateProductRequest, UpdateProductRequest, + CreateOrderRequest +} from './types.js' +import { migrations } from './migrations.js' +import { StallManager } from './managers/stallManager.js' +import { ProductManager } from './managers/productManager.js' +import { OrderManager } from './managers/orderManager.js' +import { MessageManager } from './managers/messageManager.js' +import { parseOrderRequest, parseMessageType, NostrEvent } from './nostr/parser.js' +import { EVENT_KINDS } from './nostr/kinds.js' +import { validatePubkey } from './utils/validation.js' + +/** + * Marketplace Extension for Lightning.Pub + * + * Implements NIP-15 compatible marketplace functionality: + * - Stall management (kind 30017 events) + * - Product listings (kind 30018 events) + * - Order processing via encrypted DMs + * - Customer relationship management + */ +export default class MarketplaceExtension implements Extension { + readonly info: ExtensionInfo = { + id: 'marketplace', + name: 'Nostr Marketplace', + version: '1.0.0', + description: 'NIP-15 compatible marketplace for selling products via Lightning', + author: 'Lightning.Pub', + minPubVersion: '1.0.0' + } + + private stallManager!: StallManager + private productManager!: ProductManager + private orderManager!: OrderManager + private messageManager!: MessageManager + + /** + * Initialize the extension + */ + async initialize(ctx: ExtensionContext, db: ExtensionDatabase): Promise { + // Run migrations + for (const migration of migrations) { + await migration.up(db) + } + + // Initialize managers + this.stallManager = new StallManager(db, ctx) + this.productManager = new ProductManager(db, ctx) + this.orderManager = new OrderManager(db, ctx) + this.messageManager = new MessageManager(db, ctx) + + // Register RPC methods + this.registerRpcMethods(ctx) + + // Subscribe to payment callbacks + ctx.onPaymentReceived(async (payment) => { + if (payment.metadata?.extension === 'marketplace' && payment.metadata?.order_id) { + await this.orderManager.handlePayment(payment.invoiceId) + } + }) + + // Subscribe to incoming Nostr events + ctx.onNostrEvent(async (event, applicationId) => { + await this.handleNostrEvent(event, applicationId) + }) + + console.log(`[Marketplace] Extension initialized`) + } + + /** + * Cleanup on shutdown + */ + async shutdown(): Promise { + console.log(`[Marketplace] Extension shutting down`) + } + + /** + * Register all RPC methods with the extension context + */ + private registerRpcMethods(ctx: ExtensionContext): void { + // ===== Stall Methods ===== + + ctx.registerMethod('marketplace.createStall', async (req, appId) => { + const stall = await this.stallManager.create(appId, req as CreateStallRequest) + return { stall } + }) + + ctx.registerMethod('marketplace.getStall', async (req, appId) => { + const stall = await this.stallManager.get(req.id, appId) + if (!stall) throw new Error('Stall not found') + return { stall } + }) + + ctx.registerMethod('marketplace.listStalls', async (_req, appId) => { + const stalls = await this.stallManager.list(appId) + return { stalls } + }) + + ctx.registerMethod('marketplace.updateStall', async (req, appId) => { + const stall = await this.stallManager.update(req.id, appId, req as UpdateStallRequest) + if (!stall) throw new Error('Stall not found') + return { stall } + }) + + ctx.registerMethod('marketplace.deleteStall', async (req, appId) => { + const success = await this.stallManager.delete(req.id, appId) + if (!success) throw new Error('Stall not found') + return { success } + }) + + ctx.registerMethod('marketplace.publishStall', async (req, appId) => { + const stall = await this.stallManager.get(req.id, appId) + if (!stall) throw new Error('Stall not found') + const eventId = await this.stallManager.publishToNostr(stall) + return { event_id: eventId } + }) + + // ===== Product Methods ===== + + ctx.registerMethod('marketplace.createProduct', async (req, appId) => { + const product = await this.productManager.create( + appId, + req.stall_id, + req as CreateProductRequest + ) + return { product } + }) + + ctx.registerMethod('marketplace.getProduct', async (req, appId) => { + const product = await this.productManager.get(req.id, appId) + if (!product) throw new Error('Product not found') + return { product } + }) + + ctx.registerMethod('marketplace.listProducts', async (req, appId) => { + const products = await this.productManager.list(appId, { + stall_id: req.stall_id, + category: req.category, + active_only: req.active_only, + limit: req.limit, + offset: req.offset + }) + return { products } + }) + + ctx.registerMethod('marketplace.updateProduct', async (req, appId) => { + const product = await this.productManager.update(req.id, appId, req as UpdateProductRequest) + if (!product) throw new Error('Product not found') + return { product } + }) + + ctx.registerMethod('marketplace.deleteProduct', async (req, appId) => { + const success = await this.productManager.delete(req.id, appId) + if (!success) throw new Error('Product not found') + return { success } + }) + + ctx.registerMethod('marketplace.updateInventory', async (req, appId) => { + const newQuantity = await this.productManager.updateQuantity(req.id, appId, req.delta) + return { quantity: newQuantity } + }) + + ctx.registerMethod('marketplace.checkStock', async (req, appId) => { + const result = await this.productManager.checkStock(req.items, appId) + return result + }) + + // ===== Order Methods ===== + + ctx.registerMethod('marketplace.createOrder', async (req, appId, userPubkey) => { + if (!userPubkey) throw new Error('User pubkey required') + const order = await this.orderManager.createFromRpc( + appId, + userPubkey, + req as CreateOrderRequest, + req.app_user_id + ) + return { order } + }) + + ctx.registerMethod('marketplace.getOrder', async (req, appId) => { + const order = await this.orderManager.get(req.id, appId) + if (!order) throw new Error('Order not found') + return { order } + }) + + ctx.registerMethod('marketplace.listOrders', async (req, appId) => { + const orders = await this.orderManager.list(appId, { + stall_id: req.stall_id, + customer_pubkey: req.customer_pubkey, + status: req.status, + limit: req.limit, + offset: req.offset + }) + return { orders } + }) + + ctx.registerMethod('marketplace.createInvoice', async (req, appId) => { + const order = await this.orderManager.createInvoice(req.order_id, appId) + return { order } + }) + + ctx.registerMethod('marketplace.updateOrderStatus', async (req, appId) => { + const order = await this.orderManager.updateStatus( + req.id, + appId, + req.status, + req.message + ) + if (!order) throw new Error('Order not found') + return { order } + }) + + ctx.registerMethod('marketplace.sendPaymentRequest', async (req, appId) => { + const order = await this.orderManager.get(req.order_id, appId) + if (!order) throw new Error('Order not found') + + // Create invoice if not exists + if (!order.invoice) { + await this.orderManager.createInvoice(req.order_id, appId) + } + + const updatedOrder = await this.orderManager.get(req.order_id, appId) + if (!updatedOrder?.invoice) throw new Error('Failed to create invoice') + + await this.orderManager.sendPaymentRequestDM(updatedOrder) + return { order: updatedOrder } + }) + + // ===== Message/Customer Methods ===== + + ctx.registerMethod('marketplace.listMessages', async (req, appId) => { + const messages = await this.messageManager.listMessages(appId, { + customer_pubkey: req.customer_pubkey, + order_id: req.order_id, + incoming_only: req.incoming_only, + unread_only: req.unread_only, + limit: req.limit, + offset: req.offset + }) + return { messages } + }) + + ctx.registerMethod('marketplace.getConversation', async (req, appId) => { + if (!validatePubkey(req.customer_pubkey)) { + throw new Error('Invalid customer pubkey') + } + const messages = await this.messageManager.getConversation( + appId, + req.customer_pubkey, + req.limit + ) + return { messages } + }) + + ctx.registerMethod('marketplace.sendMessage', async (req, appId) => { + if (!validatePubkey(req.customer_pubkey)) { + throw new Error('Invalid customer pubkey') + } + const message = await this.messageManager.sendMessage( + appId, + req.customer_pubkey, + req.message, + req.order_id + ) + return { message } + }) + + ctx.registerMethod('marketplace.markMessagesRead', async (req, appId) => { + if (!validatePubkey(req.customer_pubkey)) { + throw new Error('Invalid customer pubkey') + } + const count = await this.messageManager.markAsRead(appId, req.customer_pubkey) + return { marked_read: count } + }) + + ctx.registerMethod('marketplace.getUnreadCount', async (_req, appId) => { + const count = await this.messageManager.getUnreadCount(appId) + return { unread_count: count } + }) + + ctx.registerMethod('marketplace.listCustomers', async (req, appId) => { + const customers = await this.messageManager.listCustomers(appId, { + has_orders: req.has_orders, + has_unread: req.has_unread, + limit: req.limit, + offset: req.offset + }) + return { customers } + }) + + ctx.registerMethod('marketplace.getCustomer', async (req, appId) => { + if (!validatePubkey(req.pubkey)) { + throw new Error('Invalid customer pubkey') + } + const customer = await this.messageManager.getCustomer(req.pubkey, appId) + if (!customer) throw new Error('Customer not found') + return { customer } + }) + + ctx.registerMethod('marketplace.getCustomerStats', async (_req, appId) => { + const stats = await this.messageManager.getCustomerStats(appId) + return stats + }) + + // ===== Bulk Operations ===== + + ctx.registerMethod('marketplace.republishAll', async (req, appId) => { + let stallCount = 0 + let productCount = 0 + + if (req.stall_id) { + // Republish specific stall and its products + const stall = await this.stallManager.get(req.stall_id, appId) + if (stall) { + await this.stallManager.publishToNostr(stall) + stallCount = 1 + productCount = await this.productManager.republishAllForStall(req.stall_id, appId) + } + } else { + // Republish all + stallCount = await this.stallManager.republishAll(appId) + const stalls = await this.stallManager.list(appId) + for (const stall of stalls) { + productCount += await this.productManager.republishAllForStall(stall.id, appId) + } + } + + return { + stalls_published: stallCount, + products_published: productCount + } + }) + } + + /** + * Handle incoming Nostr events (DMs for orders) + */ + private async handleNostrEvent(event: NostrEvent, applicationId: string): Promise { + // Only handle DMs for now + if (event.kind !== EVENT_KINDS.DIRECT_MESSAGE) { + return + } + + try { + // Decrypt the message content (context handles this) + const decrypted = event.content // Assume pre-decrypted by context + + // Store the message + await this.messageManager.storeIncoming( + applicationId, + event.pubkey, + decrypted, + event.id, + event.created_at + ) + + // Parse message type + const { type, parsed } = parseMessageType(decrypted) + + // Handle order requests + if (type === 'order_request') { + const orderReq = parseOrderRequest(decrypted) + if (orderReq) { + const order = await this.orderManager.createFromNostr( + applicationId, + event.pubkey, + orderReq, + event.id + ) + + // Create invoice and send payment request + const withInvoice = await this.orderManager.createInvoice(order.id, applicationId) + await this.orderManager.sendPaymentRequestDM(withInvoice) + + console.log(`[Marketplace] Created order ${order.id} from Nostr DM`) + } + } + } catch (e) { + console.error('[Marketplace] Error handling Nostr event:', e) + } + } +} + +// Export types for external use +export * from './types.js' +export { EVENT_KINDS, MESSAGE_TYPES } from './nostr/kinds.js' diff --git a/src/extensions/marketplace/managers/messageManager.ts b/src/extensions/marketplace/managers/messageManager.ts new file mode 100644 index 00000000..a70d0700 --- /dev/null +++ b/src/extensions/marketplace/managers/messageManager.ts @@ -0,0 +1,497 @@ +import { + ExtensionContext, ExtensionDatabase, + DirectMessage, Customer, MessageType +} from '../types.js' +import { generateId } from '../utils/validation.js' +import { parseMessageType } from '../nostr/parser.js' + +/** + * Database row for direct message + */ +interface MessageRow { + id: string + application_id: string + order_id: string | null + customer_pubkey: string + message_type: string + content: string + incoming: number + nostr_event_id: string + nostr_event_created_at: number + created_at: number + read: number +} + +/** + * Database row for customer + */ +interface CustomerRow { + pubkey: string + application_id: string + name: string | null + about: string | null + picture: string | null + total_orders: number + total_spent_sats: number + unread_messages: number + first_seen_at: number + last_seen_at: number +} + +/** + * Convert database row to DirectMessage object + */ +function rowToMessage(row: MessageRow): DirectMessage { + return { + id: row.id, + application_id: row.application_id, + order_id: row.order_id || undefined, + customer_pubkey: row.customer_pubkey, + message_type: row.message_type as MessageType, + content: row.content, + incoming: row.incoming === 1, + nostr_event_id: row.nostr_event_id, + nostr_event_created_at: row.nostr_event_created_at, + created_at: row.created_at, + read: row.read === 1 + } +} + +/** + * Convert database row to Customer object + */ +function rowToCustomer(row: CustomerRow): Customer { + return { + pubkey: row.pubkey, + application_id: row.application_id, + name: row.name || undefined, + about: row.about || undefined, + picture: row.picture || undefined, + total_orders: row.total_orders, + total_spent_sats: row.total_spent_sats, + unread_messages: row.unread_messages, + first_seen_at: row.first_seen_at, + last_seen_at: row.last_seen_at + } +} + +/** + * Query options for listing messages + */ +interface ListMessagesOptions { + customer_pubkey?: string + order_id?: string + incoming_only?: boolean + unread_only?: boolean + limit?: number + offset?: number +} + +/** + * Query options for listing customers + */ +interface ListCustomersOptions { + has_orders?: boolean + has_unread?: boolean + limit?: number + offset?: number +} + +/** + * MessageManager - Handles customer DMs and customer management + */ +export class MessageManager { + constructor( + private db: ExtensionDatabase, + private ctx: ExtensionContext + ) {} + + // ===== Message Methods ===== + + /** + * Store incoming message from Nostr + */ + async storeIncoming( + applicationId: string, + customerPubkey: string, + decryptedContent: string, + nostrEventId: string, + nostrEventCreatedAt: number, + orderId?: string + ): Promise { + // Parse message type + const { type } = parseMessageType(decryptedContent) + + const now = Math.floor(Date.now() / 1000) + const id = generateId() + + const message: DirectMessage = { + id, + application_id: applicationId, + order_id: orderId, + customer_pubkey: customerPubkey, + message_type: type, + content: decryptedContent, + incoming: true, + nostr_event_id: nostrEventId, + nostr_event_created_at: nostrEventCreatedAt, + created_at: now, + read: false + } + + // Check for duplicate event + const existing = await this.db.query( + 'SELECT id FROM direct_messages WHERE nostr_event_id = ?', + [nostrEventId] + ) + if (existing.length > 0) { + return message // Already stored, return without error + } + + // Insert message + await this.db.execute( + `INSERT INTO direct_messages ( + id, application_id, order_id, customer_pubkey, message_type, + content, incoming, nostr_event_id, nostr_event_created_at, + created_at, read + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [ + message.id, + message.application_id, + message.order_id || null, + message.customer_pubkey, + message.message_type, + message.content, + 1, // incoming = true + message.nostr_event_id, + message.nostr_event_created_at, + message.created_at, + 0 // read = false + ] + ) + + // Update customer unread count + await this.incrementUnread(applicationId, customerPubkey) + + // Ensure customer exists + await this.ensureCustomer(applicationId, customerPubkey) + + return message + } + + /** + * Store outgoing message (sent by merchant) + */ + async storeOutgoing( + applicationId: string, + customerPubkey: string, + content: string, + nostrEventId: string, + orderId?: string + ): Promise { + const { type } = parseMessageType(content) + const now = Math.floor(Date.now() / 1000) + const id = generateId() + + const message: DirectMessage = { + id, + application_id: applicationId, + order_id: orderId, + customer_pubkey: customerPubkey, + message_type: type, + content, + incoming: false, + nostr_event_id: nostrEventId, + nostr_event_created_at: now, + created_at: now, + read: true // Outgoing messages are always "read" + } + + await this.db.execute( + `INSERT INTO direct_messages ( + id, application_id, order_id, customer_pubkey, message_type, + content, incoming, nostr_event_id, nostr_event_created_at, + created_at, read + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [ + message.id, + message.application_id, + message.order_id || null, + message.customer_pubkey, + message.message_type, + message.content, + 0, // incoming = false + message.nostr_event_id, + message.nostr_event_created_at, + message.created_at, + 1 // read = true + ] + ) + + return message + } + + /** + * Get message by ID + */ + async getMessage(id: string, applicationId: string): Promise { + const rows = await this.db.query( + 'SELECT * FROM direct_messages WHERE id = ? AND application_id = ?', + [id, applicationId] + ) + + if (rows.length === 0) return null + return rowToMessage(rows[0]) + } + + /** + * List messages with filters + */ + async listMessages( + applicationId: string, + options: ListMessagesOptions = {} + ): Promise { + let sql = 'SELECT * FROM direct_messages WHERE application_id = ?' + const params: any[] = [applicationId] + + if (options.customer_pubkey) { + sql += ' AND customer_pubkey = ?' + params.push(options.customer_pubkey) + } + + if (options.order_id) { + sql += ' AND order_id = ?' + params.push(options.order_id) + } + + if (options.incoming_only) { + sql += ' AND incoming = 1' + } + + if (options.unread_only) { + sql += ' AND read = 0' + } + + sql += ' ORDER BY nostr_event_created_at DESC' + + if (options.limit) { + sql += ' LIMIT ?' + params.push(options.limit) + if (options.offset) { + sql += ' OFFSET ?' + params.push(options.offset) + } + } + + const rows = await this.db.query(sql, params) + return rows.map(rowToMessage) + } + + /** + * Get conversation thread with a customer + */ + async getConversation( + applicationId: string, + customerPubkey: string, + limit: number = 50 + ): Promise { + const rows = await this.db.query( + `SELECT * FROM direct_messages + WHERE application_id = ? AND customer_pubkey = ? + ORDER BY nostr_event_created_at DESC + LIMIT ?`, + [applicationId, customerPubkey, limit] + ) + + // Return in chronological order + return rows.map(rowToMessage).reverse() + } + + /** + * Mark messages as read + */ + async markAsRead(applicationId: string, customerPubkey: string): Promise { + const result = await this.db.execute( + `UPDATE direct_messages SET read = 1 + WHERE application_id = ? AND customer_pubkey = ? AND read = 0`, + [applicationId, customerPubkey] + ) + + // Reset customer unread count + await this.db.execute( + `UPDATE customers SET unread_messages = 0 + WHERE application_id = ? AND pubkey = ?`, + [applicationId, customerPubkey] + ) + + return result.changes || 0 + } + + /** + * Get unread message count + */ + async getUnreadCount(applicationId: string): Promise { + const result = await this.db.query<{ count: number }>( + `SELECT COUNT(*) as count FROM direct_messages + WHERE application_id = ? AND incoming = 1 AND read = 0`, + [applicationId] + ) + return result[0]?.count || 0 + } + + /** + * Send a plain text message to customer + */ + async sendMessage( + applicationId: string, + customerPubkey: string, + message: string, + orderId?: string + ): Promise { + // Send via Nostr + const eventId = await this.ctx.sendEncryptedDM( + applicationId, + customerPubkey, + message + ) + + // Store outgoing message + return this.storeOutgoing( + applicationId, + customerPubkey, + message, + eventId, + orderId + ) + } + + // ===== Customer Methods ===== + + /** + * Get customer by pubkey + */ + async getCustomer(pubkey: string, applicationId: string): Promise { + const rows = await this.db.query( + 'SELECT * FROM customers WHERE pubkey = ? AND application_id = ?', + [pubkey, applicationId] + ) + + if (rows.length === 0) return null + return rowToCustomer(rows[0]) + } + + /** + * List customers with filters + */ + async listCustomers( + applicationId: string, + options: ListCustomersOptions = {} + ): Promise { + let sql = 'SELECT * FROM customers WHERE application_id = ?' + const params: any[] = [applicationId] + + if (options.has_orders) { + sql += ' AND total_orders > 0' + } + + if (options.has_unread) { + sql += ' AND unread_messages > 0' + } + + sql += ' ORDER BY last_seen_at DESC' + + if (options.limit) { + sql += ' LIMIT ?' + params.push(options.limit) + if (options.offset) { + sql += ' OFFSET ?' + params.push(options.offset) + } + } + + const rows = await this.db.query(sql, params) + return rows.map(rowToCustomer) + } + + /** + * Update customer profile from Nostr metadata + */ + async updateCustomerProfile( + pubkey: string, + applicationId: string, + profile: { name?: string; about?: string; picture?: string } + ): Promise { + await this.ensureCustomer(applicationId, pubkey) + + await this.db.execute( + `UPDATE customers SET + name = COALESCE(?, name), + about = COALESCE(?, about), + picture = COALESCE(?, picture), + last_seen_at = ? + WHERE pubkey = ? AND application_id = ?`, + [ + profile.name || null, + profile.about || null, + profile.picture || null, + Math.floor(Date.now() / 1000), + pubkey, + applicationId + ] + ) + + return this.getCustomer(pubkey, applicationId) + } + + /** + * Get customer statistics summary + */ + async getCustomerStats(applicationId: string): Promise<{ + total_customers: number + customers_with_orders: number + total_unread: number + }> { + const total = await this.db.query<{ count: number }>( + 'SELECT COUNT(*) as count FROM customers WHERE application_id = ?', + [applicationId] + ) + + const withOrders = await this.db.query<{ count: number }>( + 'SELECT COUNT(*) as count FROM customers WHERE application_id = ? AND total_orders > 0', + [applicationId] + ) + + const unread = await this.db.query<{ sum: number }>( + 'SELECT SUM(unread_messages) as sum FROM customers WHERE application_id = ?', + [applicationId] + ) + + return { + total_customers: total[0]?.count || 0, + customers_with_orders: withOrders[0]?.count || 0, + total_unread: unread[0]?.sum || 0 + } + } + + // ===== Private Helpers ===== + + private async ensureCustomer(applicationId: string, pubkey: string): Promise { + const now = Math.floor(Date.now() / 1000) + + await this.db.execute( + `INSERT INTO customers (pubkey, application_id, first_seen_at, last_seen_at) + VALUES (?, ?, ?, ?) + ON CONFLICT(pubkey, application_id) DO UPDATE SET + last_seen_at = ?`, + [pubkey, applicationId, now, now, now] + ) + } + + private async incrementUnread(applicationId: string, pubkey: string): Promise { + await this.db.execute( + `UPDATE customers SET unread_messages = unread_messages + 1 + WHERE pubkey = ? AND application_id = ?`, + [pubkey, applicationId] + ) + } +} diff --git a/src/extensions/marketplace/managers/orderManager.ts b/src/extensions/marketplace/managers/orderManager.ts new file mode 100644 index 00000000..30d8c8d9 --- /dev/null +++ b/src/extensions/marketplace/managers/orderManager.ts @@ -0,0 +1,607 @@ +import { + ExtensionContext, ExtensionDatabase, + Order, Stall, Product, NIP15OrderRequest, + CreateOrderRequest, OrderItem, OrderStatus +} from '../types.js' +import { generateId, validateOrderItems } from '../utils/validation.js' +import { convertToSats, SupportedCurrency } from '../utils/currency.js' +import { buildPaymentRequestContent, buildOrderStatusContent } from '../nostr/events.js' + +/** + * Database row for order + */ +interface OrderRow { + id: string + application_id: string + stall_id: string + customer_pubkey: string + customer_app_user_id: string | null + items: string // JSON + shipping_zone_id: string + shipping_address: string + contact: string // JSON + subtotal_sats: number + shipping_sats: number + total_sats: number + original_currency: string + exchange_rate: number | null + invoice: string | null + invoice_id: string | null + status: string + nostr_event_id: string | null + created_at: number + paid_at: number | null + shipped_at: number | null + completed_at: number | null +} + +/** + * Convert database row to Order object + */ +function rowToOrder(row: OrderRow): Order { + return { + id: row.id, + application_id: row.application_id, + stall_id: row.stall_id, + customer_pubkey: row.customer_pubkey, + customer_app_user_id: row.customer_app_user_id || undefined, + items: JSON.parse(row.items), + shipping_zone_id: row.shipping_zone_id, + shipping_address: row.shipping_address, + contact: JSON.parse(row.contact), + subtotal_sats: row.subtotal_sats, + shipping_sats: row.shipping_sats, + total_sats: row.total_sats, + original_currency: row.original_currency as SupportedCurrency, + exchange_rate: row.exchange_rate || undefined, + invoice: row.invoice || undefined, + invoice_id: row.invoice_id || undefined, + status: row.status as OrderStatus, + nostr_event_id: row.nostr_event_id || undefined, + created_at: row.created_at, + paid_at: row.paid_at || undefined, + shipped_at: row.shipped_at || undefined, + completed_at: row.completed_at || undefined + } +} + +/** + * Query options for listing orders + */ +interface ListOrdersOptions { + stall_id?: string + customer_pubkey?: string + status?: OrderStatus + limit?: number + offset?: number +} + +/** + * OrderManager - Handles order lifecycle and payment integration + */ +export class OrderManager { + constructor( + private db: ExtensionDatabase, + private ctx: ExtensionContext + ) {} + + /** + * Create order from NIP-15 order request (via Nostr DM) + */ + async createFromNostr( + applicationId: string, + customerPubkey: string, + orderReq: NIP15OrderRequest, + nostrEventId?: string + ): Promise { + // Validate items + validateOrderItems(orderReq.items) + + // Find stall from first product + const firstProduct = await this.getProduct(orderReq.items[0].product_id, applicationId) + if (!firstProduct) { + throw new Error('Product not found') + } + + const stall = await this.getStall(firstProduct.stall_id, applicationId) + if (!stall) { + throw new Error('Stall not found') + } + + // Calculate totals + const itemsWithDetails = await this.enrichOrderItems(orderReq.items, applicationId) + const { subtotal, shippingCost, total, exchangeRate } = await this.calculateTotals( + itemsWithDetails, + stall, + orderReq.shipping_id + ) + + const now = Math.floor(Date.now() / 1000) + const id = orderReq.id || generateId() + + const order: Order = { + id, + application_id: applicationId, + stall_id: stall.id, + customer_pubkey: customerPubkey, + items: itemsWithDetails, + shipping_zone_id: orderReq.shipping_id, + shipping_address: orderReq.address || '', + contact: orderReq.contact || {}, + subtotal_sats: subtotal, + shipping_sats: shippingCost, + total_sats: total, + original_currency: stall.currency, + exchange_rate: exchangeRate, + status: 'pending', + nostr_event_id: nostrEventId, + created_at: now + } + + // Insert into database + await this.insertOrder(order) + + // Reserve inventory + await this.reserveInventory(order) + + // Update customer stats + await this.updateCustomerStats(applicationId, customerPubkey) + + return order + } + + /** + * Create order from RPC request (native client) + */ + async createFromRpc( + applicationId: string, + customerPubkey: string, + req: CreateOrderRequest, + appUserId?: string + ): Promise { + // Validate items + validateOrderItems(req.items) + + // Find stall + const stall = await this.getStall(req.stall_id, applicationId) + if (!stall) { + throw new Error('Stall not found') + } + + // Calculate totals + const itemsWithDetails = await this.enrichOrderItems(req.items, applicationId) + const { subtotal, shippingCost, total, exchangeRate } = await this.calculateTotals( + itemsWithDetails, + stall, + req.shipping_zone_id + ) + + const now = Math.floor(Date.now() / 1000) + const id = generateId() + + const order: Order = { + id, + application_id: applicationId, + stall_id: stall.id, + customer_pubkey: customerPubkey, + customer_app_user_id: appUserId, + items: itemsWithDetails, + shipping_zone_id: req.shipping_zone_id, + shipping_address: req.shipping_address || '', + contact: req.contact || {}, + subtotal_sats: subtotal, + shipping_sats: shippingCost, + total_sats: total, + original_currency: stall.currency, + exchange_rate: exchangeRate, + status: 'pending', + created_at: now + } + + // Insert into database + await this.insertOrder(order) + + // Reserve inventory + await this.reserveInventory(order) + + // Update customer stats + await this.updateCustomerStats(applicationId, customerPubkey) + + return order + } + + /** + * Get order by ID + */ + async get(id: string, applicationId: string): Promise { + const rows = await this.db.query( + 'SELECT * FROM orders WHERE id = ? AND application_id = ?', + [id, applicationId] + ) + + if (rows.length === 0) return null + return rowToOrder(rows[0]) + } + + /** + * List orders with filters + */ + async list(applicationId: string, options: ListOrdersOptions = {}): Promise { + let sql = 'SELECT * FROM orders WHERE application_id = ?' + const params: any[] = [applicationId] + + if (options.stall_id) { + sql += ' AND stall_id = ?' + params.push(options.stall_id) + } + + if (options.customer_pubkey) { + sql += ' AND customer_pubkey = ?' + params.push(options.customer_pubkey) + } + + if (options.status) { + sql += ' AND status = ?' + params.push(options.status) + } + + sql += ' ORDER BY created_at DESC' + + if (options.limit) { + sql += ' LIMIT ?' + params.push(options.limit) + if (options.offset) { + sql += ' OFFSET ?' + params.push(options.offset) + } + } + + const rows = await this.db.query(sql, params) + return rows.map(rowToOrder) + } + + /** + * Create invoice for an order + */ + async createInvoice(id: string, applicationId: string): Promise { + const order = await this.get(id, applicationId) + if (!order) { + throw new Error('Order not found') + } + + if (order.status !== 'pending') { + throw new Error(`Order already ${order.status}`) + } + + // Create invoice via Lightning.Pub + const invoice = await this.ctx.createInvoice(order.total_sats, { + memo: `Order ${order.id}`, + expiry: 3600, // 1 hour + metadata: { + extension: 'marketplace', + order_id: order.id + } + }) + + // Update order with invoice + await this.db.execute( + `UPDATE orders SET invoice = ?, invoice_id = ? WHERE id = ?`, + [invoice.paymentRequest, invoice.id, order.id] + ) + + order.invoice = invoice.paymentRequest + order.invoice_id = invoice.id + + return order + } + + /** + * Handle invoice payment callback + */ + async handlePayment(invoiceId: string): Promise { + // Find order by invoice ID + const rows = await this.db.query( + 'SELECT * FROM orders WHERE invoice_id = ? AND status = ?', + [invoiceId, 'pending'] + ) + + if (rows.length === 0) return null + + const order = rowToOrder(rows[0]) + const now = Math.floor(Date.now() / 1000) + + // Update order status + await this.db.execute( + `UPDATE orders SET status = ?, paid_at = ? WHERE id = ?`, + ['paid', now, order.id] + ) + + order.status = 'paid' + order.paid_at = now + + // Update customer stats + await this.updateCustomerSpent(order.application_id, order.customer_pubkey, order.total_sats) + + // Send payment confirmation DM + await this.sendOrderStatusDM(order, 'Payment received! Your order is being processed.') + + return order + } + + /** + * Update order status + */ + async updateStatus( + id: string, + applicationId: string, + status: OrderStatus, + message?: string + ): Promise { + const order = await this.get(id, applicationId) + if (!order) return null + + const now = Math.floor(Date.now() / 1000) + const updates: string[] = ['status = ?'] + const params: any[] = [status] + + // Set timestamp based on status + if (status === 'paid' && !order.paid_at) { + updates.push('paid_at = ?') + params.push(now) + } else if (status === 'shipped' && !order.shipped_at) { + updates.push('shipped_at = ?') + params.push(now) + } else if (status === 'completed' && !order.completed_at) { + updates.push('completed_at = ?') + params.push(now) + } + + params.push(id, applicationId) + + await this.db.execute( + `UPDATE orders SET ${updates.join(', ')} WHERE id = ? AND application_id = ?`, + params + ) + + const updated = await this.get(id, applicationId) + + // Send status update DM + if (updated && message) { + await this.sendOrderStatusDM(updated, message) + } + + // Handle cancellation - restore inventory + if (status === 'cancelled' && order.status === 'pending') { + await this.restoreInventory(order) + } + + return updated + } + + /** + * Send payment request DM to customer + */ + async sendPaymentRequestDM(order: Order): Promise { + if (!order.invoice) { + throw new Error('Order has no invoice') + } + + const content = buildPaymentRequestContent(order, order.invoice) + + await this.ctx.sendEncryptedDM( + order.application_id, + order.customer_pubkey, + JSON.stringify(content) + ) + } + + /** + * Send order status update DM to customer + */ + async sendOrderStatusDM(order: Order, message: string): Promise { + const content = buildOrderStatusContent(order, message) + + await this.ctx.sendEncryptedDM( + order.application_id, + order.customer_pubkey, + JSON.stringify(content) + ) + } + + // ===== Private Helpers ===== + + private async insertOrder(order: Order): Promise { + await this.db.execute( + `INSERT INTO orders ( + id, application_id, stall_id, customer_pubkey, customer_app_user_id, + items, shipping_zone_id, shipping_address, contact, + subtotal_sats, shipping_sats, total_sats, original_currency, exchange_rate, + invoice, invoice_id, status, nostr_event_id, created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [ + order.id, + order.application_id, + order.stall_id, + order.customer_pubkey, + order.customer_app_user_id || null, + JSON.stringify(order.items), + order.shipping_zone_id, + order.shipping_address, + JSON.stringify(order.contact), + order.subtotal_sats, + order.shipping_sats, + order.total_sats, + order.original_currency, + order.exchange_rate || null, + order.invoice || null, + order.invoice_id || null, + order.status, + order.nostr_event_id || null, + order.created_at + ] + ) + } + + private async getStall(id: string, applicationId: string): Promise { + const rows = await this.db.query( + 'SELECT * FROM stalls WHERE id = ? AND application_id = ?', + [id, applicationId] + ) + if (rows.length === 0) return null + + const row = rows[0] as any + return { + ...row, + shipping_zones: JSON.parse(row.shipping_zones) + } + } + + private async getProduct(id: string, applicationId: string): Promise { + const rows = await this.db.query( + 'SELECT * FROM products WHERE id = ? AND application_id = ?', + [id, applicationId] + ) + if (rows.length === 0) return null + + const row = rows[0] as any + return { + ...row, + images: JSON.parse(row.images), + categories: JSON.parse(row.categories), + specs: row.specs ? JSON.parse(row.specs) : undefined, + active: row.active === 1 + } + } + + private async enrichOrderItems( + items: Array<{ product_id: string; quantity: number }>, + applicationId: string + ): Promise { + const enriched: OrderItem[] = [] + + for (const item of items) { + const product = await this.getProduct(item.product_id, applicationId) + if (!product) { + throw new Error(`Product ${item.product_id} not found`) + } + + if (!product.active) { + throw new Error(`Product ${product.name} is not available`) + } + + // Check stock + if (product.quantity !== -1 && product.quantity < item.quantity) { + throw new Error(`Insufficient stock for ${product.name}`) + } + + enriched.push({ + product_id: product.id, + product_name: product.name, + quantity: item.quantity, + unit_price: product.price + }) + } + + return enriched + } + + private async calculateTotals( + items: OrderItem[], + stall: Stall, + shippingZoneId: string + ): Promise<{ + subtotal: number + shippingCost: number + total: number + exchangeRate?: number + }> { + // Calculate subtotal in original currency + const subtotalOriginal = items.reduce( + (sum, item) => sum + item.unit_price * item.quantity, + 0 + ) + + // Find shipping zone + const shippingZone = stall.shipping_zones.find(z => z.id === shippingZoneId) + if (!shippingZone) { + throw new Error('Invalid shipping zone') + } + + const shippingOriginal = shippingZone.cost + + // Convert to sats if needed + let subtotal: number + let shippingCost: number + let exchangeRate: number | undefined + + if (stall.currency === 'sat') { + subtotal = subtotalOriginal + shippingCost = shippingOriginal + } else { + subtotal = await convertToSats(this.db, subtotalOriginal, stall.currency) + shippingCost = await convertToSats(this.db, shippingOriginal, stall.currency) + exchangeRate = subtotal / subtotalOriginal + } + + return { + subtotal, + shippingCost, + total: subtotal + shippingCost, + exchangeRate + } + } + + private async reserveInventory(order: Order): Promise { + for (const item of order.items) { + await this.db.execute( + `UPDATE products SET + quantity = CASE + WHEN quantity = -1 THEN -1 + ELSE quantity - ? + END + WHERE id = ? AND quantity != -1`, + [item.quantity, item.product_id] + ) + } + } + + private async restoreInventory(order: Order): Promise { + for (const item of order.items) { + await this.db.execute( + `UPDATE products SET + quantity = CASE + WHEN quantity = -1 THEN -1 + ELSE quantity + ? + END + WHERE id = ? AND quantity != -1`, + [item.quantity, item.product_id] + ) + } + } + + private async updateCustomerStats(applicationId: string, pubkey: string): Promise { + const now = Math.floor(Date.now() / 1000) + + await this.db.execute( + `INSERT INTO customers (pubkey, application_id, total_orders, first_seen_at, last_seen_at) + VALUES (?, ?, 1, ?, ?) + ON CONFLICT(pubkey, application_id) DO UPDATE SET + total_orders = total_orders + 1, + last_seen_at = ?`, + [pubkey, applicationId, now, now, now] + ) + } + + private async updateCustomerSpent( + applicationId: string, + pubkey: string, + amount: number + ): Promise { + await this.db.execute( + `UPDATE customers SET + total_spent_sats = total_spent_sats + ? + WHERE pubkey = ? AND application_id = ?`, + [amount, pubkey, applicationId] + ) + } +} diff --git a/src/extensions/marketplace/managers/productManager.ts b/src/extensions/marketplace/managers/productManager.ts new file mode 100644 index 00000000..f5321bb4 --- /dev/null +++ b/src/extensions/marketplace/managers/productManager.ts @@ -0,0 +1,466 @@ +import { + ExtensionContext, ExtensionDatabase, + Product, Stall, CreateProductRequest, UpdateProductRequest +} from '../types.js' +import { generateId, validateProduct } from '../utils/validation.js' +import { buildProductEvent, buildDeleteEvent } from '../nostr/events.js' + +/** + * Database row for product + */ +interface ProductRow { + id: string + stall_id: string + application_id: string + name: string + description: string + images: string // JSON array + price: number + quantity: number + categories: string // JSON array + shipping_cost: number | null + specs: string | null // JSON array + active: number + nostr_event_id: string | null + nostr_event_created_at: number | null + created_at: number + updated_at: number +} + +/** + * Convert database row to Product object + */ +function rowToProduct(row: ProductRow): Product { + return { + id: row.id, + stall_id: row.stall_id, + application_id: row.application_id, + name: row.name, + description: row.description, + images: JSON.parse(row.images), + price: row.price, + quantity: row.quantity, + categories: JSON.parse(row.categories), + shipping_cost: row.shipping_cost ?? undefined, + specs: row.specs ? JSON.parse(row.specs) : undefined, + active: row.active === 1, + nostr_event_id: row.nostr_event_id || undefined, + nostr_event_created_at: row.nostr_event_created_at || undefined, + created_at: row.created_at, + updated_at: row.updated_at + } +} + +/** + * Query options for listing products + */ +interface ListProductsOptions { + stall_id?: string + category?: string + active_only?: boolean + limit?: number + offset?: number +} + +/** + * ProductManager - Handles product CRUD, inventory, and Nostr publishing + */ +export class ProductManager { + constructor( + private db: ExtensionDatabase, + private ctx: ExtensionContext + ) {} + + /** + * Create a new product + */ + async create( + applicationId: string, + stallId: string, + req: CreateProductRequest + ): Promise { + // Validate request + validateProduct(req) + + // Verify stall exists and belongs to application + const stallRows = await this.db.query( + 'SELECT * FROM stalls WHERE id = ? AND application_id = ?', + [stallId, applicationId] + ) + if (stallRows.length === 0) { + throw new Error('Stall not found') + } + + const now = Math.floor(Date.now() / 1000) + const id = generateId() + + const product: Product = { + id, + stall_id: stallId, + application_id: applicationId, + name: req.name.trim(), + description: req.description?.trim() || '', + images: req.images || [], + price: req.price, + quantity: req.quantity ?? -1, // -1 = unlimited + categories: req.categories || [], + shipping_cost: req.shipping_cost, + specs: req.specs, + active: true, + created_at: now, + updated_at: now + } + + // Insert into database + await this.db.execute( + `INSERT INTO products ( + id, stall_id, application_id, name, description, images, + price, quantity, categories, shipping_cost, specs, active, + created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [ + product.id, + product.stall_id, + product.application_id, + product.name, + product.description, + JSON.stringify(product.images), + product.price, + product.quantity, + JSON.stringify(product.categories), + product.shipping_cost ?? null, + product.specs ? JSON.stringify(product.specs) : null, + product.active ? 1 : 0, + product.created_at, + product.updated_at + ] + ) + + // Publish to Nostr if auto-publish enabled + if (req.publish_to_nostr !== false) { + await this.publishToNostr(product) + } + + return product + } + + /** + * Get product by ID + */ + async get(id: string, applicationId: string): Promise { + const rows = await this.db.query( + 'SELECT * FROM products WHERE id = ? AND application_id = ?', + [id, applicationId] + ) + + if (rows.length === 0) return null + return rowToProduct(rows[0]) + } + + /** + * List products with optional filters + */ + async list(applicationId: string, options: ListProductsOptions = {}): Promise { + let sql = 'SELECT * FROM products WHERE application_id = ?' + const params: any[] = [applicationId] + + if (options.stall_id) { + sql += ' AND stall_id = ?' + params.push(options.stall_id) + } + + if (options.active_only !== false) { + sql += ' AND active = 1' + } + + if (options.category) { + // Search in JSON array + sql += ' AND categories LIKE ?' + params.push(`%"${options.category}"%`) + } + + sql += ' ORDER BY created_at DESC' + + if (options.limit) { + sql += ' LIMIT ?' + params.push(options.limit) + if (options.offset) { + sql += ' OFFSET ?' + params.push(options.offset) + } + } + + const rows = await this.db.query(sql, params) + return rows.map(rowToProduct) + } + + /** + * Update a product + */ + async update( + id: string, + applicationId: string, + req: UpdateProductRequest + ): Promise { + const existing = await this.get(id, applicationId) + if (!existing) return null + + // Build updated product + const updated: Product = { + ...existing, + name: req.name?.trim() ?? existing.name, + description: req.description?.trim() ?? existing.description, + images: req.images ?? existing.images, + price: req.price ?? existing.price, + quantity: req.quantity ?? existing.quantity, + categories: req.categories ?? existing.categories, + shipping_cost: req.shipping_cost ?? existing.shipping_cost, + specs: req.specs ?? existing.specs, + active: req.active ?? existing.active, + updated_at: Math.floor(Date.now() / 1000) + } + + // Validate merged product + validateProduct({ + name: updated.name, + price: updated.price, + quantity: updated.quantity, + images: updated.images, + categories: updated.categories + }) + + // Update database + await this.db.execute( + `UPDATE products SET + name = ?, description = ?, images = ?, price = ?, + quantity = ?, categories = ?, shipping_cost = ?, + specs = ?, active = ?, updated_at = ? + WHERE id = ? AND application_id = ?`, + [ + updated.name, + updated.description, + JSON.stringify(updated.images), + updated.price, + updated.quantity, + JSON.stringify(updated.categories), + updated.shipping_cost ?? null, + updated.specs ? JSON.stringify(updated.specs) : null, + updated.active ? 1 : 0, + updated.updated_at, + id, + applicationId + ] + ) + + // Republish to Nostr + if (req.publish_to_nostr !== false && updated.active) { + await this.publishToNostr(updated) + } else if (!updated.active && existing.nostr_event_id) { + // Unpublish if deactivated + await this.unpublishFromNostr(updated) + } + + return updated + } + + /** + * Delete a product + */ + async delete(id: string, applicationId: string): Promise { + const product = await this.get(id, applicationId) + if (!product) return false + + // Check for pending orders + const orders = await this.db.query( + `SELECT COUNT(*) as count FROM orders + WHERE items LIKE ? AND status IN ('pending', 'paid')`, + [`%"${id}"%`] + ) + if ((orders[0] as any).count > 0) { + throw new Error('Cannot delete product with pending orders') + } + + // Unpublish from Nostr + if (product.nostr_event_id) { + await this.unpublishFromNostr(product) + } + + // Delete from database + await this.db.execute( + 'DELETE FROM products WHERE id = ? AND application_id = ?', + [id, applicationId] + ) + + return true + } + + /** + * Update product quantity (for inventory management) + */ + async updateQuantity( + id: string, + applicationId: string, + delta: number + ): Promise { + const product = await this.get(id, applicationId) + if (!product) { + throw new Error('Product not found') + } + + // -1 means unlimited + if (product.quantity === -1) { + return -1 + } + + const newQuantity = Math.max(0, product.quantity + delta) + + await this.db.execute( + 'UPDATE products SET quantity = ?, updated_at = ? WHERE id = ?', + [newQuantity, Math.floor(Date.now() / 1000), id] + ) + + // Republish if quantity changed and product is published + if (product.nostr_event_id) { + const updated = await this.get(id, applicationId) + if (updated) await this.publishToNostr(updated) + } + + return newQuantity + } + + /** + * Check if products are in stock + */ + async checkStock( + items: Array<{ product_id: string; quantity: number }>, + applicationId: string + ): Promise<{ available: boolean; unavailable: string[] }> { + const unavailable: string[] = [] + + for (const item of items) { + const product = await this.get(item.product_id, applicationId) + + if (!product) { + unavailable.push(item.product_id) + continue + } + + if (!product.active) { + unavailable.push(item.product_id) + continue + } + + // -1 means unlimited + if (product.quantity !== -1 && product.quantity < item.quantity) { + unavailable.push(item.product_id) + } + } + + return { + available: unavailable.length === 0, + unavailable + } + } + + /** + * Publish product to Nostr (kind 30018) + */ + async publishToNostr(product: Product): Promise { + try { + // Get stall for currency info + const stallRows = await this.db.query( + 'SELECT * FROM stalls WHERE id = ?', + [product.stall_id] + ) + if (stallRows.length === 0) return null + + const stall = stallRows[0] as any + const stallObj: Stall = { + ...stall, + shipping_zones: JSON.parse(stall.shipping_zones) + } + + // Get application's Nostr pubkey + const app = await this.ctx.getApplication(product.application_id) + if (!app || !app.nostr_public) { + console.warn(`No Nostr pubkey for application ${product.application_id}`) + return null + } + + // Build the event + const event = buildProductEvent(product, stallObj, app.nostr_public) + + // Sign and publish + const eventId = await this.ctx.publishNostrEvent(event) + + // Update database with event info + if (eventId) { + await this.db.execute( + `UPDATE products SET + nostr_event_id = ?, + nostr_event_created_at = ? + WHERE id = ?`, + [eventId, event.created_at, product.id] + ) + } + + return eventId + } catch (e) { + console.error('Failed to publish product to Nostr:', e) + return null + } + } + + /** + * Unpublish product from Nostr (kind 5 deletion) + */ + async unpublishFromNostr(product: Product): Promise { + if (!product.nostr_event_id) return false + + try { + const app = await this.ctx.getApplication(product.application_id) + if (!app || !app.nostr_public) return false + + const deleteEvent = buildDeleteEvent( + product.nostr_event_id, + 30018, // PRODUCT kind + app.nostr_public, + `Product ${product.name} removed` + ) + + await this.ctx.publishNostrEvent(deleteEvent) + + // Clear event info + await this.db.execute( + `UPDATE products SET + nostr_event_id = NULL, + nostr_event_created_at = NULL + WHERE id = ?`, + [product.id] + ) + + return true + } catch (e) { + console.error('Failed to unpublish product from Nostr:', e) + return false + } + } + + /** + * Republish all products for a stall + */ + async republishAllForStall(stallId: string, applicationId: string): Promise { + const products = await this.list(applicationId, { + stall_id: stallId, + active_only: true + }) + + let count = 0 + for (const product of products) { + const eventId = await this.publishToNostr(product) + if (eventId) count++ + } + + return count + } +} diff --git a/src/extensions/marketplace/managers/stallManager.ts b/src/extensions/marketplace/managers/stallManager.ts new file mode 100644 index 00000000..a77809c3 --- /dev/null +++ b/src/extensions/marketplace/managers/stallManager.ts @@ -0,0 +1,305 @@ +import { + ExtensionContext, ExtensionDatabase, + Stall, CreateStallRequest, UpdateStallRequest +} from '../types.js' +import { generateId, validateStall } from '../utils/validation.js' +import { buildStallEvent, buildDeleteEvent } from '../nostr/events.js' + +/** + * Database row for stall + */ +interface StallRow { + id: string + application_id: string + name: string + description: string + currency: string + shipping_zones: string // JSON + image_url: string | null + nostr_event_id: string | null + nostr_event_created_at: number | null + created_at: number + updated_at: number +} + +/** + * Convert database row to Stall object + */ +function rowToStall(row: StallRow): Stall { + return { + id: row.id, + application_id: row.application_id, + name: row.name, + description: row.description, + currency: row.currency as Stall['currency'], + shipping_zones: JSON.parse(row.shipping_zones), + image_url: row.image_url || undefined, + nostr_event_id: row.nostr_event_id || undefined, + nostr_event_created_at: row.nostr_event_created_at || undefined, + created_at: row.created_at, + updated_at: row.updated_at + } +} + +/** + * StallManager - Handles stall CRUD and Nostr publishing + */ +export class StallManager { + constructor( + private db: ExtensionDatabase, + private ctx: ExtensionContext + ) {} + + /** + * Create a new stall + */ + async create(applicationId: string, req: CreateStallRequest): Promise { + // Validate request + validateStall(req) + + const now = Math.floor(Date.now() / 1000) + const id = generateId() + + // Assign IDs to shipping zones if not provided + const shippingZones = req.shipping_zones.map(zone => ({ + id: zone.id || generateId(), + name: zone.name, + cost: zone.cost, + regions: zone.regions + })) + + const stall: Stall = { + id, + application_id: applicationId, + name: req.name.trim(), + description: req.description?.trim() || '', + currency: req.currency, + shipping_zones: shippingZones, + image_url: req.image_url, + created_at: now, + updated_at: now + } + + // Insert into database + await this.db.execute( + `INSERT INTO stalls ( + id, application_id, name, description, currency, + shipping_zones, image_url, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [ + stall.id, + stall.application_id, + stall.name, + stall.description, + stall.currency, + JSON.stringify(stall.shipping_zones), + stall.image_url || null, + stall.created_at, + stall.updated_at + ] + ) + + // Publish to Nostr if auto-publish enabled + if (req.publish_to_nostr !== false) { + await this.publishToNostr(stall) + } + + return stall + } + + /** + * Get stall by ID + */ + async get(id: string, applicationId: string): Promise { + const rows = await this.db.query( + 'SELECT * FROM stalls WHERE id = ? AND application_id = ?', + [id, applicationId] + ) + + if (rows.length === 0) return null + return rowToStall(rows[0]) + } + + /** + * List all stalls for an application + */ + async list(applicationId: string): Promise { + const rows = await this.db.query( + 'SELECT * FROM stalls WHERE application_id = ? ORDER BY created_at DESC', + [applicationId] + ) + + return rows.map(rowToStall) + } + + /** + * Update a stall + */ + async update( + id: string, + applicationId: string, + req: UpdateStallRequest + ): Promise { + const existing = await this.get(id, applicationId) + if (!existing) return null + + // Build updated stall + const updated: Stall = { + ...existing, + name: req.name?.trim() ?? existing.name, + description: req.description?.trim() ?? existing.description, + currency: req.currency ?? existing.currency, + shipping_zones: req.shipping_zones ?? existing.shipping_zones, + image_url: req.image_url ?? existing.image_url, + updated_at: Math.floor(Date.now() / 1000) + } + + // Validate merged stall + validateStall({ + name: updated.name, + currency: updated.currency, + shipping_zones: updated.shipping_zones + }) + + // Update database + await this.db.execute( + `UPDATE stalls SET + name = ?, description = ?, currency = ?, + shipping_zones = ?, image_url = ?, updated_at = ? + WHERE id = ? AND application_id = ?`, + [ + updated.name, + updated.description, + updated.currency, + JSON.stringify(updated.shipping_zones), + updated.image_url || null, + updated.updated_at, + id, + applicationId + ] + ) + + // Republish to Nostr (updates parameterized replaceable event) + if (req.publish_to_nostr !== false) { + await this.publishToNostr(updated) + } + + return updated + } + + /** + * Delete a stall + */ + async delete(id: string, applicationId: string): Promise { + const stall = await this.get(id, applicationId) + if (!stall) return false + + // Check for products + const products = await this.db.query( + 'SELECT COUNT(*) as count FROM products WHERE stall_id = ?', + [id] + ) + if ((products[0] as any).count > 0) { + throw new Error('Cannot delete stall with existing products') + } + + // Publish deletion event to Nostr if it was published + if (stall.nostr_event_id) { + await this.unpublishFromNostr(stall) + } + + // Delete from database + await this.db.execute( + 'DELETE FROM stalls WHERE id = ? AND application_id = ?', + [id, applicationId] + ) + + return true + } + + /** + * Publish stall to Nostr (kind 30017) + */ + async publishToNostr(stall: Stall): Promise { + try { + // Get application's Nostr pubkey + const app = await this.ctx.getApplication(stall.application_id) + if (!app || !app.nostr_public) { + console.warn(`No Nostr pubkey for application ${stall.application_id}`) + return null + } + + // Build the event + const event = buildStallEvent(stall, app.nostr_public) + + // Sign and publish via context + const eventId = await this.ctx.publishNostrEvent(event) + + // Update database with event info + if (eventId) { + await this.db.execute( + `UPDATE stalls SET + nostr_event_id = ?, + nostr_event_created_at = ? + WHERE id = ?`, + [eventId, event.created_at, stall.id] + ) + } + + return eventId + } catch (e) { + console.error('Failed to publish stall to Nostr:', e) + return null + } + } + + /** + * Unpublish stall from Nostr (kind 5 deletion) + */ + async unpublishFromNostr(stall: Stall): Promise { + if (!stall.nostr_event_id) return false + + try { + const app = await this.ctx.getApplication(stall.application_id) + if (!app || !app.nostr_public) return false + + const deleteEvent = buildDeleteEvent( + stall.nostr_event_id, + 30017, // STALL kind + app.nostr_public, + `Stall ${stall.name} removed` + ) + + await this.ctx.publishNostrEvent(deleteEvent) + + // Clear event info from database + await this.db.execute( + `UPDATE stalls SET + nostr_event_id = NULL, + nostr_event_created_at = NULL + WHERE id = ?`, + [stall.id] + ) + + return true + } catch (e) { + console.error('Failed to unpublish stall from Nostr:', e) + return false + } + } + + /** + * Republish all stalls for an application + */ + async republishAll(applicationId: string): Promise { + const stalls = await this.list(applicationId) + let count = 0 + + for (const stall of stalls) { + const eventId = await this.publishToNostr(stall) + if (eventId) count++ + } + + return count + } +} diff --git a/src/extensions/marketplace/migrations.ts b/src/extensions/marketplace/migrations.ts new file mode 100644 index 00000000..5c1ff1c7 --- /dev/null +++ b/src/extensions/marketplace/migrations.ts @@ -0,0 +1,194 @@ +import { ExtensionDatabase } from './types.js' + +export interface ExtensionMigration { + version: number + description: string + up(db: ExtensionDatabase): Promise + down?(db: ExtensionDatabase): Promise +} + +export const migrations: ExtensionMigration[] = [ + { + version: 1, + description: 'Create core marketplace tables', + up: async (db: ExtensionDatabase) => { + // Stalls table + await db.execute(` + CREATE TABLE IF NOT EXISTS stalls ( + id TEXT PRIMARY KEY, + application_id TEXT NOT NULL, + name TEXT NOT NULL, + description TEXT DEFAULT '', + currency TEXT NOT NULL DEFAULT 'sat', + shipping_zones TEXT NOT NULL DEFAULT '[]', + image_url TEXT, + nostr_event_id TEXT, + nostr_event_created_at INTEGER, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + ) + `) + + await db.execute(` + CREATE INDEX IF NOT EXISTS idx_stalls_app + ON stalls(application_id) + `) + + // Products table + await db.execute(` + CREATE TABLE IF NOT EXISTS products ( + id TEXT PRIMARY KEY, + stall_id TEXT NOT NULL, + application_id TEXT NOT NULL, + name TEXT NOT NULL, + description TEXT DEFAULT '', + images TEXT NOT NULL DEFAULT '[]', + price INTEGER NOT NULL, + quantity INTEGER NOT NULL DEFAULT -1, + categories TEXT NOT NULL DEFAULT '[]', + shipping_cost INTEGER, + specs TEXT DEFAULT '[]', + active INTEGER NOT NULL DEFAULT 1, + nostr_event_id TEXT, + nostr_event_created_at INTEGER, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + FOREIGN KEY (stall_id) REFERENCES stalls(id) + ) + `) + + await db.execute(` + CREATE INDEX IF NOT EXISTS idx_products_stall + ON products(stall_id) + `) + + await db.execute(` + CREATE INDEX IF NOT EXISTS idx_products_app + ON products(application_id) + `) + + await db.execute(` + CREATE INDEX IF NOT EXISTS idx_products_active + ON products(active, application_id) + `) + + // Orders table + await db.execute(` + CREATE TABLE IF NOT EXISTS orders ( + id TEXT PRIMARY KEY, + application_id TEXT NOT NULL, + stall_id TEXT NOT NULL, + customer_pubkey TEXT NOT NULL, + customer_app_user_id TEXT, + items TEXT NOT NULL, + shipping_zone_id TEXT NOT NULL, + shipping_address TEXT NOT NULL, + contact TEXT NOT NULL, + subtotal_sats INTEGER NOT NULL, + shipping_sats INTEGER NOT NULL, + total_sats INTEGER NOT NULL, + original_currency TEXT NOT NULL, + exchange_rate REAL, + invoice TEXT, + invoice_id TEXT, + status TEXT NOT NULL DEFAULT 'pending', + nostr_event_id TEXT, + created_at INTEGER NOT NULL, + paid_at INTEGER, + shipped_at INTEGER, + completed_at INTEGER + ) + `) + + await db.execute(` + CREATE INDEX IF NOT EXISTS idx_orders_app + ON orders(application_id) + `) + + await db.execute(` + CREATE INDEX IF NOT EXISTS idx_orders_customer + ON orders(customer_pubkey, application_id) + `) + + await db.execute(` + CREATE INDEX IF NOT EXISTS idx_orders_status + ON orders(status, application_id) + `) + + // Direct messages table + await db.execute(` + CREATE TABLE IF NOT EXISTS direct_messages ( + id TEXT PRIMARY KEY, + application_id TEXT NOT NULL, + order_id TEXT, + customer_pubkey TEXT NOT NULL, + message_type TEXT NOT NULL, + content TEXT NOT NULL, + incoming INTEGER NOT NULL, + nostr_event_id TEXT NOT NULL UNIQUE, + nostr_event_created_at INTEGER NOT NULL, + created_at INTEGER NOT NULL, + read INTEGER NOT NULL DEFAULT 0 + ) + `) + + await db.execute(` + CREATE INDEX IF NOT EXISTS idx_dm_customer + ON direct_messages(customer_pubkey, application_id) + `) + + await db.execute(` + CREATE INDEX IF NOT EXISTS idx_dm_order + ON direct_messages(order_id) + `) + + await db.execute(` + CREATE INDEX IF NOT EXISTS idx_dm_unread + ON direct_messages(read, application_id) + `) + + // Customers table + await db.execute(` + CREATE TABLE IF NOT EXISTS customers ( + pubkey TEXT NOT NULL, + application_id TEXT NOT NULL, + name TEXT, + about TEXT, + picture TEXT, + total_orders INTEGER NOT NULL DEFAULT 0, + total_spent_sats INTEGER NOT NULL DEFAULT 0, + unread_messages INTEGER NOT NULL DEFAULT 0, + first_seen_at INTEGER NOT NULL, + last_seen_at INTEGER NOT NULL, + PRIMARY KEY (pubkey, application_id) + ) + `) + }, + + down: async (db: ExtensionDatabase) => { + await db.execute('DROP TABLE IF EXISTS customers') + await db.execute('DROP TABLE IF EXISTS direct_messages') + await db.execute('DROP TABLE IF EXISTS orders') + await db.execute('DROP TABLE IF EXISTS products') + await db.execute('DROP TABLE IF EXISTS stalls') + } + }, + + { + version: 2, + description: 'Add exchange rates cache table', + up: async (db: ExtensionDatabase) => { + await db.execute(` + CREATE TABLE IF NOT EXISTS exchange_rates ( + currency TEXT PRIMARY KEY, + rate_sats INTEGER NOT NULL, + updated_at INTEGER NOT NULL + ) + `) + }, + + down: async (db: ExtensionDatabase) => { + await db.execute('DROP TABLE IF EXISTS exchange_rates') + } + } +] diff --git a/src/extensions/marketplace/nostr/events.ts b/src/extensions/marketplace/nostr/events.ts new file mode 100644 index 00000000..b4c476b4 --- /dev/null +++ b/src/extensions/marketplace/nostr/events.ts @@ -0,0 +1,164 @@ +import { EVENT_KINDS } from './kinds.js' +import { + Stall, Product, Order, + NIP15Stall, NIP15Product, NIP15PaymentRequest, NIP15OrderStatusUpdate +} from '../types.js' + +/** + * Unsigned Nostr event structure + */ +export interface UnsignedEvent { + kind: number + pubkey: string + created_at: number + tags: string[][] + content: string + id?: string +} + +/** + * Build NIP-15 stall event (kind 30017) + */ +export function buildStallEvent(stall: Stall, pubkey: string): UnsignedEvent { + const nip15Stall: NIP15Stall = { + id: stall.id, + name: stall.name, + description: stall.description, + currency: stall.currency, + shipping: stall.shipping_zones.map(zone => ({ + id: zone.id, + name: zone.name, + cost: zone.cost, + regions: zone.regions + })) + } + + return { + kind: EVENT_KINDS.STALL, + pubkey, + created_at: Math.floor(Date.now() / 1000), + tags: [ + ['d', stall.id], // Unique identifier for parameterized replaceable + ], + content: JSON.stringify(nip15Stall) + } +} + +/** + * Build NIP-15 product event (kind 30018) + */ +export function buildProductEvent(product: Product, stall: Stall, pubkey: string): UnsignedEvent { + const nip15Product: NIP15Product = { + id: product.id, + stall_id: product.stall_id, + name: product.name, + description: product.description, + images: product.images, + currency: stall.currency, + price: product.price, + quantity: product.quantity, + specs: product.specs?.map(s => [s.key, s.value] as [string, string]) + } + + // Add shipping costs if different from stall defaults + if (product.shipping_cost !== undefined) { + nip15Product.shipping = stall.shipping_zones.map(zone => ({ + id: zone.id, + cost: product.shipping_cost! + })) + } + + const tags: string[][] = [ + ['d', product.id], // Unique identifier + ] + + // Add category tags + for (const category of product.categories) { + tags.push(['t', category]) + } + + return { + kind: EVENT_KINDS.PRODUCT, + pubkey, + created_at: Math.floor(Date.now() / 1000), + tags, + content: JSON.stringify(nip15Product) + } +} + +/** + * Build deletion event (kind 5) + */ +export function buildDeleteEvent( + eventId: string, + kind: number, + pubkey: string, + reason?: string +): UnsignedEvent { + const tags: string[][] = [ + ['e', eventId], + ['k', String(kind)] + ] + + return { + kind: EVENT_KINDS.DELETE, + pubkey, + created_at: Math.floor(Date.now() / 1000), + tags, + content: reason || '' + } +} + +/** + * Build payment request DM content + */ +export function buildPaymentRequestContent( + order: Order, + invoice: string, + message?: string +): NIP15PaymentRequest { + return { + id: order.id, + type: 1, + message: message || `Payment request for order ${order.id}. Total: ${order.total_sats} sats`, + payment_options: [ + { type: 'ln', link: invoice } + ] + } +} + +/** + * Build order status update DM content + */ +export function buildOrderStatusContent( + order: Order, + message: string +): NIP15OrderStatusUpdate { + return { + id: order.id, + type: 2, + message, + paid: order.status !== 'pending', + shipped: order.status === 'shipped' || order.status === 'completed' + } +} + +/** + * Build encrypted DM event (kind 4) + * Note: Actual encryption happens in the manager using NIP-44 + */ +export function buildDirectMessageEvent( + content: string, // Already encrypted + fromPubkey: string, + toPubkey: string +): UnsignedEvent { + return { + kind: EVENT_KINDS.DIRECT_MESSAGE, + pubkey: fromPubkey, + created_at: Math.floor(Date.now() / 1000), + tags: [ + ['p', toPubkey] + ], + content + } +} diff --git a/src/extensions/marketplace/nostr/kinds.ts b/src/extensions/marketplace/nostr/kinds.ts new file mode 100644 index 00000000..20d26914 --- /dev/null +++ b/src/extensions/marketplace/nostr/kinds.ts @@ -0,0 +1,31 @@ +/** + * NIP-15 Event Kinds + * https://github.com/nostr-protocol/nips/blob/master/15.md + */ + +export const EVENT_KINDS = { + // Standard kinds + METADATA: 0, // User profile + TEXT_NOTE: 1, // Short text + DIRECT_MESSAGE: 4, // Encrypted DM (NIP-04) + DELETE: 5, // Event deletion + + // NIP-15 Marketplace kinds + STALL: 30017, // Parameterized replaceable: merchant stall + PRODUCT: 30018, // Parameterized replaceable: product listing + + // Lightning.Pub RPC kinds (for native clients) + RPC_REQUEST: 21001, // Encrypted RPC request + RPC_RESPONSE: 21002, // Encrypted RPC response +} as const + +export type EventKind = typeof EVENT_KINDS[keyof typeof EVENT_KINDS] + +// Marketplace message types (in DM content) +export const MESSAGE_TYPES = { + ORDER_REQUEST: 0, + PAYMENT_REQUEST: 1, + ORDER_STATUS: 2, +} as const + +export type MessageTypeNum = typeof MESSAGE_TYPES[keyof typeof MESSAGE_TYPES] diff --git a/src/extensions/marketplace/nostr/parser.ts b/src/extensions/marketplace/nostr/parser.ts new file mode 100644 index 00000000..f90a1aa3 --- /dev/null +++ b/src/extensions/marketplace/nostr/parser.ts @@ -0,0 +1,162 @@ +import { EVENT_KINDS, MESSAGE_TYPES } from './kinds.js' +import { + NIP15Stall, NIP15Product, NIP15OrderRequest, + Stall, Product, MessageType +} from '../types.js' +import { generateId } from '../utils/validation.js' + +/** + * Nostr event structure + */ +export interface NostrEvent { + id: string + pubkey: string + created_at: number + kind: number + tags: string[][] + content: string + sig?: string +} + +/** + * Parse NIP-15 stall event + */ +export function parseStallEvent(event: NostrEvent): Partial | null { + if (event.kind !== EVENT_KINDS.STALL) return null + + try { + const content = JSON.parse(event.content) as NIP15Stall + const dTag = event.tags.find(t => t[0] === 'd') + + return { + id: content.id || dTag?.[1] || generateId(), + name: content.name, + description: content.description || '', + currency: content.currency as any || 'sat', + shipping_zones: (content.shipping || []).map(zone => ({ + id: zone.id || generateId(), + name: zone.name || zone.id, + cost: zone.cost, + regions: zone.regions || [] + })), + nostr_event_id: event.id, + nostr_event_created_at: event.created_at + } + } catch (e) { + return null + } +} + +/** + * Parse NIP-15 product event + */ +export function parseProductEvent(event: NostrEvent): Partial | null { + if (event.kind !== EVENT_KINDS.PRODUCT) return null + + try { + const content = JSON.parse(event.content) as NIP15Product + const dTag = event.tags.find(t => t[0] === 'd') + const categoryTags = event.tags.filter(t => t[0] === 't').map(t => t[1]) + + return { + id: content.id || dTag?.[1] || generateId(), + stall_id: content.stall_id, + name: content.name, + description: content.description || '', + images: content.images || [], + price: content.price, + quantity: content.quantity ?? -1, + categories: categoryTags.length > 0 ? categoryTags : [], + specs: content.specs?.map(([key, value]) => ({ key, value })), + nostr_event_id: event.id, + nostr_event_created_at: event.created_at + } + } catch (e) { + return null + } +} + +/** + * Parse NIP-15 order request from DM + */ +export function parseOrderRequest(content: string): NIP15OrderRequest | null { + try { + const parsed = JSON.parse(content) + if (parsed.type !== MESSAGE_TYPES.ORDER_REQUEST) return null + + return { + id: parsed.id || generateId(), + type: 0, + name: parsed.name, + address: parsed.address, + message: parsed.message, + contact: parsed.contact || {}, + items: parsed.items || [], + shipping_id: parsed.shipping_id + } + } catch (e) { + return null + } +} + +/** + * Determine message type from decrypted content + */ +export function parseMessageType(content: string): { type: MessageType; parsed: any } { + try { + const parsed = JSON.parse(content) + + if (typeof parsed.type === 'number') { + switch (parsed.type) { + case MESSAGE_TYPES.ORDER_REQUEST: + return { type: 'order_request', parsed } + case MESSAGE_TYPES.PAYMENT_REQUEST: + return { type: 'payment_request', parsed } + case MESSAGE_TYPES.ORDER_STATUS: + return { type: 'order_status', parsed } + } + } + + // If it has items and shipping_id, treat as order request + if (parsed.items && parsed.shipping_id) { + return { type: 'order_request', parsed: { ...parsed, type: 0 } } + } + + return { type: 'plain', parsed: content } + } catch { + return { type: 'plain', parsed: content } + } +} + +/** + * Extract pubkey from p tag + */ +export function extractRecipientPubkey(event: NostrEvent): string | null { + const pTag = event.tags.find(t => t[0] === 'p') + return pTag?.[1] || null +} + +/** + * Check if event is a deletion event for a specific kind + */ +export function isDeletionEvent(event: NostrEvent, targetKind?: number): boolean { + if (event.kind !== EVENT_KINDS.DELETE) return false + + if (targetKind !== undefined) { + const kTag = event.tags.find(t => t[0] === 'k') + return kTag?.[1] === String(targetKind) + } + + return true +} + +/** + * Get deleted event IDs from a deletion event + */ +export function getDeletedEventIds(event: NostrEvent): string[] { + if (event.kind !== EVENT_KINDS.DELETE) return [] + + return event.tags + .filter(t => t[0] === 'e') + .map(t => t[1]) +} diff --git a/src/extensions/marketplace/types.ts b/src/extensions/marketplace/types.ts new file mode 100644 index 00000000..7559d947 --- /dev/null +++ b/src/extensions/marketplace/types.ts @@ -0,0 +1,418 @@ +/** + * Marketplace Extension Types + * NIP-15 compliant marketplace for Lightning.Pub + */ + +// Re-export base extension types +export { + Extension, + ExtensionInfo, + ExtensionContext, + ExtensionDatabase, + ApplicationInfo, + NostrEvent, + UnsignedNostrEvent, + PaymentReceivedData, + RpcMethodHandler +} from '../types.js' + +// ============================================================================ +// Core Data Types +// ============================================================================ + +export interface ShippingZone { + id: string + name: string + cost: number // In stall's currency + regions: string[] // ISO country codes or region names +} + +export interface Stall { + id: string + application_id: string + name: string + description: string + currency: Currency + shipping_zones: ShippingZone[] + image_url?: string + + // Nostr sync + nostr_event_id?: string + nostr_event_created_at?: number + + // Metadata + created_at: number + updated_at: number +} + +export interface Product { + id: string + stall_id: string + application_id: string + + name: string + description: string + images: string[] + price: number // In stall's currency + quantity: number // Available stock (-1 = unlimited) + categories: string[] + + // Shipping override (optional, otherwise use stall zones) + shipping_cost?: number + + // Product options/variants (future) + specs?: ProductSpec[] + + // Behavior + active: boolean + + // Nostr sync + nostr_event_id?: string + nostr_event_created_at?: number + + // Metadata + created_at: number + updated_at: number +} + +export interface ProductSpec { + key: string + value: string +} + +export interface OrderItem { + product_id: string + product_name: string // Snapshot at order time + quantity: number + unit_price: number // In stall currency, at order time +} + +export interface ContactInfo { + nostr?: string // Nostr pubkey + email?: string + phone?: string +} + +export type OrderStatus = + | 'pending' // Awaiting payment + | 'paid' // Payment received + | 'processing' // Merchant preparing + | 'shipped' // In transit + | 'completed' // Delivered + | 'cancelled' // Cancelled by merchant or customer + +export interface Order { + id: string + application_id: string + stall_id: string + + // Customer + customer_pubkey: string // Nostr pubkey + customer_app_user_id?: string // If registered user + + // Order details + items: OrderItem[] + shipping_zone_id: string + shipping_address: string + contact: ContactInfo + + // Pricing (all in sats) + subtotal_sats: number + shipping_sats: number + total_sats: number + + // Original currency info (for display) + original_currency: Currency + exchange_rate?: number // Rate at order time + + // Payment + invoice?: string // BOLT11 invoice + invoice_id?: string // Internal invoice reference + + // Status + status: OrderStatus + + // Nostr + nostr_event_id?: string // Order request event + + // Timestamps + created_at: number + paid_at?: number + shipped_at?: number + completed_at?: number +} + +export interface DirectMessage { + id: string + application_id: string + order_id?: string // Optional link to order + + customer_pubkey: string + + // Message content + message_type: MessageType + content: string // Decrypted content + + // Direction + incoming: boolean // true = from customer, false = to customer + + // Nostr + nostr_event_id: string + nostr_event_created_at: number + + // Metadata + created_at: number + read: boolean +} + +export type MessageType = + | 'plain' // Regular text message + | 'order_request' // Customer order (type 0) + | 'payment_request' // Merchant payment request (type 1) + | 'order_status' // Order update (type 2) + +export interface Customer { + pubkey: string + application_id: string + + // Profile (from kind 0) + name?: string + about?: string + picture?: string + + // Stats + total_orders: number + total_spent_sats: number + unread_messages: number + + // Metadata + first_seen_at: number + last_seen_at: number +} + +// ============================================================================ +// Currency Types +// ============================================================================ + +export type Currency = 'sat' | 'btc' | 'usd' | 'eur' | 'gbp' | 'cad' | 'aud' | 'jpy' + +export interface ExchangeRate { + currency: Currency + rate_sats: number // How many sats per 1 unit of currency + timestamp: number +} + +// ============================================================================ +// NIP-15 Event Types +// ============================================================================ + +export interface NIP15Stall { + id: string + name: string + description?: string + currency: string + shipping: NIP15ShippingZone[] +} + +export interface NIP15ShippingZone { + id: string + name?: string + cost: number + regions: string[] +} + +export interface NIP15Product { + id: string + stall_id: string + name: string + description?: string + images?: string[] + currency: string + price: number + quantity: number + specs?: Array<[string, string]> + shipping?: NIP15ProductShipping[] +} + +export interface NIP15ProductShipping { + id: string // Zone ID + cost: number +} + +export interface NIP15OrderRequest { + id: string + type: 0 + name?: string + address?: string + message?: string + contact: { + nostr?: string + phone?: string + email?: string + } + items: Array<{ + product_id: string + quantity: number + }> + shipping_id: string +} + +export interface NIP15PaymentRequest { + id: string + type: 1 + message?: string + payment_options: Array<{ + type: 'ln' | 'url' | 'btc' + link: string + }> +} + +export interface NIP15OrderStatusUpdate { + id: string + type: 2 + message: string + paid?: boolean + shipped?: boolean +} + +// ============================================================================ +// RPC Request/Response Types +// ============================================================================ + +// Stall operations +export interface CreateStallRequest { + name: string + description?: string + currency: Currency + shipping_zones: Array & { id?: string }> + image_url?: string + publish_to_nostr?: boolean // Default: true +} + +export interface UpdateStallRequest { + stall_id: string + name?: string + description?: string + currency?: Currency + shipping_zones?: ShippingZone[] + image_url?: string + publish_to_nostr?: boolean // Default: true +} + +export interface GetStallRequest { + stall_id: string +} + +export interface ListStallsRequest { + limit?: number + offset?: number +} + +// Product operations +export interface CreateProductRequest { + stall_id: string + name: string + description?: string + images?: string[] + price: number + quantity?: number // Default: -1 (unlimited) + categories?: string[] + shipping_cost?: number + specs?: ProductSpec[] + active?: boolean + publish_to_nostr?: boolean // Default: true +} + +export interface UpdateProductRequest { + product_id: string + name?: string + description?: string + images?: string[] + price?: number + quantity?: number + categories?: string[] + shipping_cost?: number + specs?: ProductSpec[] + active?: boolean + publish_to_nostr?: boolean // Default: true +} + +export interface GetProductRequest { + product_id: string +} + +export interface ListProductsRequest { + stall_id?: string + category?: string + active_only?: boolean + limit?: number + offset?: number +} + +// Order operations +export interface CreateOrderRequest { + stall_id: string + items: Array<{ product_id: string; quantity: number }> + shipping_zone_id: string + shipping_address: string + contact: ContactInfo +} + +export interface GetOrderRequest { + order_id: string +} + +export interface ListOrdersRequest { + stall_id?: string + status?: OrderStatus + customer_pubkey?: string + limit?: number + offset?: number +} + +export interface UpdateOrderStatusRequest { + order_id: string + status: OrderStatus + message?: string +} + +// Message operations +export interface SendMessageRequest { + customer_pubkey: string + message: string + order_id?: string +} + +export interface ListMessagesRequest { + customer_pubkey?: string + order_id?: string + unread_only?: boolean + limit?: number + offset?: number +} + +export interface MarkMessagesReadRequest { + customer_pubkey?: string + message_ids?: string[] +} + +// Customer operations +export interface ListCustomersRequest { + limit?: number + offset?: number +} + +export interface GetCustomerRequest { + pubkey: string +} + +// ============================================================================ +// Extension Context Types +// ============================================================================ + +export interface MarketplaceContext { + application_id: string + application_pubkey: string + user_id: string + is_owner: boolean +} diff --git a/src/extensions/marketplace/utils/currency.ts b/src/extensions/marketplace/utils/currency.ts new file mode 100644 index 00000000..7bb2dd14 --- /dev/null +++ b/src/extensions/marketplace/utils/currency.ts @@ -0,0 +1,205 @@ +import { ExtensionDatabase } from '../types.js' + +/** + * Exchange rate data + */ +interface ExchangeRateData { + currency: string + rate_sats: number // How many sats per 1 unit of currency + updated_at: number +} + +// Cache duration: 10 minutes +const CACHE_DURATION_MS = 10 * 60 * 1000 + +// In-memory cache for rates +const rateCache = new Map() + +/** + * Supported fiat currencies + */ +export const SUPPORTED_CURRENCIES = ['sat', 'btc', 'usd', 'eur', 'gbp', 'cad', 'aud', 'jpy'] as const +export type SupportedCurrency = typeof SUPPORTED_CURRENCIES[number] + +/** + * Get exchange rate from cache or database + */ +async function getCachedRate( + db: ExtensionDatabase, + currency: string +): Promise { + // Check memory cache first + const cached = rateCache.get(currency) + if (cached && Date.now() - cached.timestamp < CACHE_DURATION_MS) { + return cached.rate + } + + // Check database cache + const result = await db.query( + 'SELECT * FROM exchange_rates WHERE currency = ?', + [currency] + ) + + if (result.length > 0) { + const row = result[0] + if (Date.now() - row.updated_at * 1000 < CACHE_DURATION_MS) { + rateCache.set(currency, { rate: row.rate_sats, timestamp: row.updated_at * 1000 }) + return row.rate_sats + } + } + + return null +} + +/** + * Save exchange rate to cache and database + */ +async function saveRate( + db: ExtensionDatabase, + currency: string, + rateSats: number +): Promise { + const now = Math.floor(Date.now() / 1000) + + // Save to memory cache + rateCache.set(currency, { rate: rateSats, timestamp: now * 1000 }) + + // Save to database (upsert) + await db.execute( + `INSERT INTO exchange_rates (currency, rate_sats, updated_at) + VALUES (?, ?, ?) + ON CONFLICT(currency) DO UPDATE SET + rate_sats = excluded.rate_sats, + updated_at = excluded.updated_at`, + [currency, rateSats, now] + ) +} + +/** + * Fetch current BTC price from public API + * Returns price in USD per BTC + */ +async function fetchBtcPrice(): Promise { + // Try CoinGecko first (free, no API key) + try { + const response = await fetch( + 'https://api.coingecko.com/api/v3/simple/price?ids=bitcoin&vs_currencies=usd,eur,gbp,cad,aud,jpy' + ) + if (response.ok) { + const data = await response.json() + return data.bitcoin.usd + } + } catch (e) { + // Fall through to backup + } + + // Backup: use a hardcoded fallback (should be updated periodically) + // This is a safety net - in production you'd want multiple API sources + console.warn('Failed to fetch exchange rate, using fallback') + return 100000 // Fallback BTC price in USD +} + +/** + * Get exchange rate: sats per 1 unit of currency + */ +export async function getExchangeRate( + db: ExtensionDatabase, + currency: SupportedCurrency +): Promise { + // Bitcoin denominations are fixed + if (currency === 'sat') return 1 + if (currency === 'btc') return 100_000_000 + + // Check cache + const cached = await getCachedRate(db, currency) + if (cached !== null) return cached + + // Fetch fresh rate + const btcPriceUsd = await fetchBtcPrice() + const satsPerBtc = 100_000_000 + + // Calculate rates for all fiat currencies + // Using approximate cross-rates (in production, fetch all from API) + const usdRates: Record = { + usd: 1, + eur: 0.92, + gbp: 0.79, + cad: 1.36, + aud: 1.53, + jpy: 149.5 + } + + // Calculate sats per 1 unit of each currency + for (const [curr, usdRate] of Object.entries(usdRates)) { + const priceInCurrency = btcPriceUsd * usdRate + const satsPerUnit = Math.round(satsPerBtc / priceInCurrency) + await saveRate(db, curr, satsPerUnit) + } + + // Return requested currency rate + const rate = await getCachedRate(db, currency) + return rate || 1 // Fallback to 1:1 if something went wrong +} + +/** + * Convert amount from a currency to sats + */ +export async function convertToSats( + db: ExtensionDatabase, + amount: number, + currency: SupportedCurrency +): Promise { + if (currency === 'sat') return Math.round(amount) + + const rate = await getExchangeRate(db, currency) + return Math.round(amount * rate) +} + +/** + * Convert amount from sats to a currency + */ +export async function convertFromSats( + db: ExtensionDatabase, + sats: number, + currency: SupportedCurrency +): Promise { + if (currency === 'sat') return sats + + const rate = await getExchangeRate(db, currency) + return sats / rate +} + +/** + * Format amount with currency symbol + */ +export function formatCurrency(amount: number, currency: SupportedCurrency): string { + const symbols: Record = { + sat: ' sats', + btc: ' BTC', + usd: '$', + eur: '€', + gbp: '£', + cad: 'CA$', + aud: 'AU$', + jpy: '¥' + } + + const symbol = symbols[currency] + const isPrefix = ['usd', 'gbp', 'cad', 'aud', 'jpy'].includes(currency) + + if (currency === 'btc') { + return `${amount.toFixed(8)}${symbol}` + } + + if (currency === 'sat') { + return `${amount.toLocaleString()}${symbol}` + } + + // Fiat currencies + const formatted = amount.toLocaleString(undefined, { + minimumFractionDigits: 2, + maximumFractionDigits: 2 + }) + + return isPrefix ? `${symbol}${formatted}` : `${formatted}${symbol}` +} diff --git a/src/extensions/marketplace/utils/validation.ts b/src/extensions/marketplace/utils/validation.ts new file mode 100644 index 00000000..549b59d4 --- /dev/null +++ b/src/extensions/marketplace/utils/validation.ts @@ -0,0 +1,109 @@ +import crypto from 'crypto' +import { CreateStallRequest, CreateProductRequest } from '../types.js' + +/** + * Generate a random ID + */ +export function generateId(): string { + return crypto.randomBytes(16).toString('hex') +} + +/** + * Validate stall creation request + */ +export function validateStall(req: CreateStallRequest): void { + if (!req.name || req.name.trim().length === 0) { + throw new Error('Stall name is required') + } + + if (req.name.length > 100) { + throw new Error('Stall name must be 100 characters or less') + } + + const validCurrencies = ['sat', 'btc', 'usd', 'eur', 'gbp', 'cad', 'aud', 'jpy'] + if (!validCurrencies.includes(req.currency)) { + throw new Error(`Invalid currency. Must be one of: ${validCurrencies.join(', ')}`) + } + + if (!req.shipping_zones || req.shipping_zones.length === 0) { + throw new Error('At least one shipping zone is required') + } + + for (const zone of req.shipping_zones) { + if (!zone.name || zone.name.trim().length === 0) { + throw new Error('Shipping zone name is required') + } + if (zone.cost < 0) { + throw new Error('Shipping cost cannot be negative') + } + if (!zone.regions || zone.regions.length === 0) { + throw new Error('Shipping zone must have at least one region') + } + } +} + +/** + * Validate product creation request + */ +export function validateProduct(req: CreateProductRequest): void { + if (!req.name || req.name.trim().length === 0) { + throw new Error('Product name is required') + } + + if (req.name.length > 200) { + throw new Error('Product name must be 200 characters or less') + } + + if (req.price < 0) { + throw new Error('Price cannot be negative') + } + + if (req.quantity < -1) { + throw new Error('Quantity must be -1 (unlimited) or >= 0') + } + + if (req.images && req.images.length > 10) { + throw new Error('Maximum 10 images allowed') + } + + if (req.categories && req.categories.length > 10) { + throw new Error('Maximum 10 categories allowed') + } +} + +/** + * Validate Nostr pubkey format (64 char hex) + */ +export function validatePubkey(pubkey: string): boolean { + return /^[0-9a-f]{64}$/i.test(pubkey) +} + +/** + * Sanitize string input + */ +export function sanitizeString(input: string, maxLength: number = 1000): string { + if (!input) return '' + return input.trim().slice(0, maxLength) +} + +/** + * Validate order items + */ +export function validateOrderItems(items: Array<{ product_id: string; quantity: number }>): void { + if (!items || items.length === 0) { + throw new Error('Order must have at least one item') + } + + if (items.length > 100) { + throw new Error('Maximum 100 items per order') + } + + for (const item of items) { + if (!item.product_id) { + throw new Error('Product ID is required for each item') + } + if (!Number.isInteger(item.quantity) || item.quantity < 1) { + throw new Error('Quantity must be a positive integer') + } + } +} diff --git a/src/extensions/types.ts b/src/extensions/types.ts new file mode 100644 index 00000000..62abf5df --- /dev/null +++ b/src/extensions/types.ts @@ -0,0 +1,254 @@ +/** + * 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 +} + +/** + * LNURL-pay info response (LUD-06/LUD-16) + * Used for Lightning Address and zap support + */ +export interface LnurlPayInfo { + tag: 'payRequest' + callback: string // URL to call with amount + minSendable: number // Minimum msats + maxSendable: number // Maximum msats + metadata: string // JSON-encoded metadata array + allowsNostr?: boolean // Whether zaps are supported + nostrPubkey?: string // Pubkey for zap receipts (hex) +} + +/** + * 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 + + /** + * Get LNURL-pay info for a user (by pubkey) + * Used to enable Lightning Address support (LUD-16) and zaps (NIP-57) + */ + getLnurlPayInfo(pubkeyHex: string, options?: { + metadata?: string // Custom metadata JSON + description?: string // Human-readable description + }): 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 +} diff --git a/src/nostrMiddleware.ts b/src/nostrMiddleware.ts index 034dbce8..4dd3b281 100644 --- a/src/nostrMiddleware.ts +++ b/src/nostrMiddleware.ts @@ -105,7 +105,7 @@ export default (serverMethods: Types.ServerMethods, mainHandler: Main, nostrSett return { Stop: () => { mainHandler.adminManager.setNostrConnected(false); return nostr.Stop }, - Send: (...args) => nostr.Send(...args), + Send: async (...args) => nostr.Send(...args), Ping: () => nostr.Ping(), Reset: (settings: NostrSettings) => nostr.Reset(settings) } diff --git a/src/services/main/debitManager.ts b/src/services/main/debitManager.ts index 28579d31..53375217 100644 --- a/src/services/main/debitManager.ts +++ b/src/services/main/debitManager.ts @@ -153,13 +153,14 @@ export class DebitManager { } notifyPaymentSuccess = (debitRes: NdebitSuccess, event: { pub: string, id: string, appId: string }) => { + this.logger("✅ [DEBIT REQUEST] Payment successful, sending OK response to", event.pub.slice(0, 16) + "...", "for event", event.id.slice(0, 16) + "...") this.sendDebitResponse(debitRes, event) } sendDebitResponse = (debitRes: NdebitFailure | NdebitSuccess, event: { pub: string, id: string, appId: string }) => { + this.logger("📤 [DEBIT RESPONSE] Sending Kind 21002 response:", JSON.stringify(debitRes), "to", event.pub.slice(0, 16) + "...") const e = newNdebitResponse(JSON.stringify(debitRes), event) this.storage.NostrSender().Send({ type: 'app', appId: event.appId }, { type: 'event', event: e, encrypt: { toPub: event.pub } }) - } payNdebitInvoice = async (event: NostrEvent, pointerdata: NdebitData): Promise => { diff --git a/src/services/nostr/handler.ts b/src/services/nostr/handler.ts index d1fa46ec..b9ecfe70 100644 --- a/src/services/nostr/handler.ts +++ b/src/services/nostr/handler.ts @@ -132,12 +132,12 @@ const handleNostrSettings = (settings: NostrSettings) => { send(event) }) } */ -const sendToNostr: NostrSend = (initiator, data, relays) => { +const sendToNostr: NostrSend = async (initiator, data, relays) => { if (!subProcessHandler) { getLogger({ component: "nostrMiddleware" })(ERROR, "nostr was not initialized") return } - subProcessHandler.Send(initiator, data, relays) + await subProcessHandler.Send(initiator, data, relays) } send({ type: 'ready' }) diff --git a/src/services/nostr/nostrPool.ts b/src/services/nostr/nostrPool.ts index d41da382..d5e47d7f 100644 --- a/src/services/nostr/nostrPool.ts +++ b/src/services/nostr/nostrPool.ts @@ -16,7 +16,7 @@ export type SendDataContent = { type: "content", content: string, pub: string } export type SendDataEvent = { type: "event", event: UnsignedEvent, encrypt?: { toPub: string } } export type SendData = SendDataContent | SendDataEvent export type SendInitiator = { type: 'app', appId: string } | { type: 'client', clientId: string } -export type NostrSend = (initiator: SendInitiator, data: SendData, relays?: string[] | undefined) => void +export type NostrSend = (initiator: SendInitiator, data: SendData, relays?: string[] | undefined) => Promise export type LinkedProviderInfo = { pubkey: string, clientId: string, relayUrl: string } export type AppInfo = { appId: string, publicKey: string, privateKey: string, name: string, provider?: LinkedProviderInfo } @@ -203,21 +203,22 @@ export class NostrPool { const signed = finalizeEvent(event, Buffer.from(keys.privateKey, 'hex')) let sent = false const log = getLogger({ appName: keys.name }) - // const r = relays ? relays : this.getServiceRelays() + this.log(`📤 Publishing Kind ${event.kind} event to ${relays.length} relay(s): ${relays.join(', ')}`) const pool = new SimplePool() await Promise.all(pool.publish(relays, signed).map(async p => { try { await p sent = true } catch (e: any) { - console.log(e) + this.log(ERROR, `Failed to publish Kind ${event.kind} event:`, e.message || e) log(e) } })) if (!sent) { + this.log(ERROR, `Failed to send Kind ${event.kind} event to any relay`) log("failed to send event") } else { - //log("sent event") + this.log(`✅ Kind ${event.kind} event published successfully (id: ${signed.id.slice(0, 16)}...)`) } } diff --git a/src/services/nostr/sender.ts b/src/services/nostr/sender.ts index 1fd336a5..8437b9af 100644 --- a/src/services/nostr/sender.ts +++ b/src/services/nostr/sender.ts @@ -1,7 +1,7 @@ import { NostrSend, SendData, SendInitiator } from "./nostrPool.js" -import { getLogger } from "../helpers/logger.js" +import { ERROR, getLogger } from "../helpers/logger.js" export class NostrSender { - private _nostrSend: NostrSend = () => { throw new Error('nostr send not initialized yet') } + private _nostrSend: NostrSend = async () => { throw new Error('nostr send not initialized yet') } private isReady: boolean = false private onReadyCallbacks: (() => void)[] = [] private pendingSends: { initiator: SendInitiator, data: SendData, relays?: string[] | undefined }[] = [] @@ -12,7 +12,12 @@ export class NostrSender { this.isReady = true this.onReadyCallbacks.forEach(cb => cb()) this.onReadyCallbacks = [] - this.pendingSends.forEach(send => this._nostrSend(send.initiator, send.data, send.relays)) + // Process pending sends with proper error handling + this.pendingSends.forEach(send => { + this._nostrSend(send.initiator, send.data, send.relays).catch(e => { + this.log(ERROR, "failed to send pending event", e.message || e) + }) + }) this.pendingSends = [] } OnReady(callback: () => void) { @@ -22,13 +27,16 @@ export class NostrSender { this.onReadyCallbacks.push(callback) } } - Send(initiator: SendInitiator, data: SendData, relays?: string[] | undefined) { + Send(initiator: SendInitiator, data: SendData, relays?: string[] | undefined): void { if (!this.isReady) { this.log("tried to send before nostr was ready, caching request") this.pendingSends.push({ initiator, data, relays }) return } - this._nostrSend(initiator, data, relays) + // Fire and forget but log errors + this._nostrSend(initiator, data, relays).catch(e => { + this.log(ERROR, "failed to send event", e.message || e) + }) } IsReady() { return this.isReady diff --git a/src/services/storage/tlv/tlvFilesStorageProcessor.ts b/src/services/storage/tlv/tlvFilesStorageProcessor.ts index 2b4189e4..caccb949 100644 --- a/src/services/storage/tlv/tlvFilesStorageProcessor.ts +++ b/src/services/storage/tlv/tlvFilesStorageProcessor.ts @@ -126,7 +126,7 @@ class TlvFilesStorageProcessor { throw new Error('Unknown metric type: ' + t) } }) - this.wrtc.attachNostrSend((initiator: SendInitiator, data: SendData, relays?: string[] | undefined) => { + this.wrtc.attachNostrSend(async (initiator: SendInitiator, data: SendData, relays?: string[] | undefined) => { this.sendResponse({ success: true, type: 'nostrSend', diff --git a/src/services/webRTC/index.ts b/src/services/webRTC/index.ts index 8ee8d884..a2cc90af 100644 --- a/src/services/webRTC/index.ts +++ b/src/services/webRTC/index.ts @@ -27,11 +27,11 @@ export default class webRTC { attachNostrSend(f: NostrSend) { this._nostrSend = f } - private nostrSend: NostrSend = (initiator: SendInitiator, data: SendData, relays?: string[] | undefined) => { + private nostrSend: NostrSend = async (initiator: SendInitiator, data: SendData, relays?: string[] | undefined) => { if (!this._nostrSend) { throw new Error("No nostrSend attached") } - this._nostrSend(initiator, data, relays) + await this._nostrSend(initiator, data, relays) } private sendCandidate = (u: WebRtcUserInfo, candidate: string) => {