Compare commits
6 commits
7ea027c5ce
...
b5d81bcf30
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b5d81bcf30 | ||
|
|
e366fc607a | ||
|
|
af31f12cb9 | ||
|
|
70096ccfa5 | ||
|
|
56e024cd93 | ||
|
|
44f8d4192f |
25 changed files with 5478 additions and 16 deletions
731
src/extensions/README.md
Normal file
731
src/extensions/README.md
Normal file
|
|
@ -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<void> {
|
||||
// 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<void> {
|
||||
// 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<void>
|
||||
|
||||
// Optional: Called when Lightning.Pub shuts down
|
||||
shutdown?(): Promise<void>
|
||||
|
||||
// Optional: Health check for monitoring
|
||||
healthCheck?(): Promise<boolean>
|
||||
}
|
||||
|
||||
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<T>(sql: string, params?: any[]): Promise<T[]>
|
||||
|
||||
// Run multiple statements in a transaction
|
||||
transaction<T>(fn: () => Promise<T>): Promise<T>
|
||||
}
|
||||
```
|
||||
|
||||
### Migration Pattern
|
||||
|
||||
```typescript
|
||||
// migrations.ts
|
||||
|
||||
export interface Migration {
|
||||
version: number
|
||||
name: string
|
||||
up: (db: ExtensionDatabase) => Promise<void>
|
||||
}
|
||||
|
||||
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<void> {
|
||||
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<any>
|
||||
```
|
||||
|
||||
### 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<HttpResponse>
|
||||
}
|
||||
|
||||
interface HttpRequest {
|
||||
params: Record<string, string> // URL path params
|
||||
query: Record<string, string> // Query string params
|
||||
body?: any // POST body
|
||||
headers: Record<string, string>
|
||||
}
|
||||
|
||||
interface HttpResponse {
|
||||
status: number
|
||||
body: any
|
||||
headers?: Record<string, string>
|
||||
}
|
||||
```
|
||||
|
||||
### 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<void> {
|
||||
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<void> {
|
||||
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
|
||||
309
src/extensions/context.ts
Normal file
309
src/extensions/context.ts
Normal file
|
|
@ -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<any>
|
||||
}
|
||||
|
||||
// Payment operations
|
||||
paymentManager: {
|
||||
createInvoice(params: {
|
||||
applicationId: string
|
||||
amountSats: number
|
||||
memo?: string
|
||||
expiry?: number
|
||||
metadata?: Record<string, any>
|
||||
}): 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<LnurlPayInfo>
|
||||
}
|
||||
|
||||
// Nostr operations
|
||||
sendNostrEvent(event: any): Promise<string | null>
|
||||
sendEncryptedDM(applicationId: string, recipientPubkey: string, content: string): Promise<string>
|
||||
}
|
||||
|
||||
/**
|
||||
* Callback registries for extension events
|
||||
*/
|
||||
interface CallbackRegistries {
|
||||
paymentReceived: Array<(payment: PaymentReceivedData) => Promise<void>>
|
||||
nostrEvent: Array<(event: NostrEvent, applicationId: string) => Promise<void>>
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<string, RegisteredMethod>
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Get information about an application
|
||||
*/
|
||||
async getApplication(applicationId: string): Promise<ApplicationInfo | null> {
|
||||
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<CreatedInvoice> {
|
||||
// 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<CreatedInvoice> {
|
||||
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<string> {
|
||||
return this.mainHandler.sendEncryptedDM(applicationId, recipientPubkey, content)
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish a Nostr event
|
||||
*/
|
||||
async publishNostrEvent(event: UnsignedNostrEvent): Promise<string | null> {
|
||||
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<LnurlPayInfo> {
|
||||
return this.mainHandler.paymentManager.getLnurlPayInfoByPubkey(pubkeyHex, options)
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to payment received callbacks
|
||||
*/
|
||||
onPaymentReceived(callback: (payment: PaymentReceivedData) => Promise<void>): void {
|
||||
this.callbacks.paymentReceived.push(callback)
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to incoming Nostr events
|
||||
*/
|
||||
onNostrEvent(callback: (event: NostrEvent, applicationId: string) => Promise<void>): 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<void> {
|
||||
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<void> {
|
||||
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<string, RegisteredMethod>
|
||||
): ExtensionContextImpl {
|
||||
return new ExtensionContextImpl(extensionInfo, database, mainHandler, methodRegistry)
|
||||
}
|
||||
148
src/extensions/database.ts
Normal file
148
src/extensions/database.ts
Normal file
|
|
@ -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<T = any>(sql: string, params: any[] = []): Promise<T[]> {
|
||||
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<T>(fn: () => Promise<T>): Promise<T> {
|
||||
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<string | null> {
|
||||
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<void> {
|
||||
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<number> {
|
||||
const version = await this.getMeta('migration_version')
|
||||
return version ? parseInt(version, 10) : 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Set migration version
|
||||
*/
|
||||
async setMigrationVersion(version: number): Promise<void> {
|
||||
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)
|
||||
}
|
||||
56
src/extensions/index.ts
Normal file
56
src/extensions/index.ts
Normal file
|
|
@ -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'
|
||||
406
src/extensions/loader.ts
Normal file
406
src/extensions/loader.ts
Normal file
|
|
@ -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<string, LoadedExtension> = new Map()
|
||||
private contexts: Map<string, ExtensionContextImpl> = new Map()
|
||||
private methodRegistry: Map<string, RegisteredMethod> = new Map()
|
||||
private initialized = false
|
||||
|
||||
constructor(config: ExtensionLoaderConfig, mainHandler: MainHandlerInterface) {
|
||||
this.config = config
|
||||
this.mainHandler = mainHandler
|
||||
}
|
||||
|
||||
/**
|
||||
* Discover and load all extensions
|
||||
*/
|
||||
async loadAll(): Promise<void> {
|
||||
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<string[]> {
|
||||
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<string[]> {
|
||||
// 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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<string, RegisteredMethod> {
|
||||
return this.methodRegistry
|
||||
}
|
||||
|
||||
/**
|
||||
* Call an extension RPC method
|
||||
*/
|
||||
async callMethod(
|
||||
methodName: string,
|
||||
request: any,
|
||||
applicationId: string,
|
||||
userPubkey?: string
|
||||
): Promise<any> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<Map<string, boolean>> {
|
||||
const results = new Map<string, boolean>()
|
||||
|
||||
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)
|
||||
}
|
||||
390
src/extensions/marketplace/index.ts
Normal file
390
src/extensions/marketplace/index.ts
Normal file
|
|
@ -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<void> {
|
||||
// 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<void> {
|
||||
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<void> {
|
||||
// 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'
|
||||
497
src/extensions/marketplace/managers/messageManager.ts
Normal file
497
src/extensions/marketplace/managers/messageManager.ts
Normal file
|
|
@ -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<DirectMessage> {
|
||||
// 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<DirectMessage> {
|
||||
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<DirectMessage | null> {
|
||||
const rows = await this.db.query<MessageRow>(
|
||||
'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<DirectMessage[]> {
|
||||
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<MessageRow>(sql, params)
|
||||
return rows.map(rowToMessage)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get conversation thread with a customer
|
||||
*/
|
||||
async getConversation(
|
||||
applicationId: string,
|
||||
customerPubkey: string,
|
||||
limit: number = 50
|
||||
): Promise<DirectMessage[]> {
|
||||
const rows = await this.db.query<MessageRow>(
|
||||
`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<number> {
|
||||
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<number> {
|
||||
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<DirectMessage> {
|
||||
// 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<Customer | null> {
|
||||
const rows = await this.db.query<CustomerRow>(
|
||||
'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<Customer[]> {
|
||||
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<CustomerRow>(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<Customer | null> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
await this.db.execute(
|
||||
`UPDATE customers SET unread_messages = unread_messages + 1
|
||||
WHERE pubkey = ? AND application_id = ?`,
|
||||
[pubkey, applicationId]
|
||||
)
|
||||
}
|
||||
}
|
||||
607
src/extensions/marketplace/managers/orderManager.ts
Normal file
607
src/extensions/marketplace/managers/orderManager.ts
Normal file
|
|
@ -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<Order> {
|
||||
// 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<Order> {
|
||||
// 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<Order | null> {
|
||||
const rows = await this.db.query<OrderRow>(
|
||||
'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<Order[]> {
|
||||
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<OrderRow>(sql, params)
|
||||
return rows.map(rowToOrder)
|
||||
}
|
||||
|
||||
/**
|
||||
* Create invoice for an order
|
||||
*/
|
||||
async createInvoice(id: string, applicationId: string): Promise<Order> {
|
||||
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<Order | null> {
|
||||
// Find order by invoice ID
|
||||
const rows = await this.db.query<OrderRow>(
|
||||
'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<Order | null> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<Stall | null> {
|
||||
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<Product | null> {
|
||||
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<OrderItem[]> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
await this.db.execute(
|
||||
`UPDATE customers SET
|
||||
total_spent_sats = total_spent_sats + ?
|
||||
WHERE pubkey = ? AND application_id = ?`,
|
||||
[amount, pubkey, applicationId]
|
||||
)
|
||||
}
|
||||
}
|
||||
466
src/extensions/marketplace/managers/productManager.ts
Normal file
466
src/extensions/marketplace/managers/productManager.ts
Normal file
|
|
@ -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<Product> {
|
||||
// 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<Product | null> {
|
||||
const rows = await this.db.query<ProductRow>(
|
||||
'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<Product[]> {
|
||||
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<ProductRow>(sql, params)
|
||||
return rows.map(rowToProduct)
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a product
|
||||
*/
|
||||
async update(
|
||||
id: string,
|
||||
applicationId: string,
|
||||
req: UpdateProductRequest
|
||||
): Promise<Product | null> {
|
||||
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<boolean> {
|
||||
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<number> {
|
||||
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<string | null> {
|
||||
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<boolean> {
|
||||
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<number> {
|
||||
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
|
||||
}
|
||||
}
|
||||
305
src/extensions/marketplace/managers/stallManager.ts
Normal file
305
src/extensions/marketplace/managers/stallManager.ts
Normal file
|
|
@ -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<Stall> {
|
||||
// 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<Stall | null> {
|
||||
const rows = await this.db.query<StallRow>(
|
||||
'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<Stall[]> {
|
||||
const rows = await this.db.query<StallRow>(
|
||||
'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<Stall | null> {
|
||||
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<boolean> {
|
||||
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<string | null> {
|
||||
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<boolean> {
|
||||
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<number> {
|
||||
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
|
||||
}
|
||||
}
|
||||
194
src/extensions/marketplace/migrations.ts
Normal file
194
src/extensions/marketplace/migrations.ts
Normal file
|
|
@ -0,0 +1,194 @@
|
|||
import { ExtensionDatabase } from './types.js'
|
||||
|
||||
export interface ExtensionMigration {
|
||||
version: number
|
||||
description: string
|
||||
up(db: ExtensionDatabase): Promise<void>
|
||||
down?(db: ExtensionDatabase): Promise<void>
|
||||
}
|
||||
|
||||
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')
|
||||
}
|
||||
}
|
||||
]
|
||||
164
src/extensions/marketplace/nostr/events.ts
Normal file
164
src/extensions/marketplace/nostr/events.ts
Normal file
|
|
@ -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
|
||||
}
|
||||
}
|
||||
31
src/extensions/marketplace/nostr/kinds.ts
Normal file
31
src/extensions/marketplace/nostr/kinds.ts
Normal file
|
|
@ -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]
|
||||
162
src/extensions/marketplace/nostr/parser.ts
Normal file
162
src/extensions/marketplace/nostr/parser.ts
Normal file
|
|
@ -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<Stall> | 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<Product> | 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])
|
||||
}
|
||||
418
src/extensions/marketplace/types.ts
Normal file
418
src/extensions/marketplace/types.ts
Normal file
|
|
@ -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<Omit<ShippingZone, 'id'> & { 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
|
||||
}
|
||||
205
src/extensions/marketplace/utils/currency.ts
Normal file
205
src/extensions/marketplace/utils/currency.ts
Normal file
|
|
@ -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<string, { rate: number; timestamp: number }>()
|
||||
|
||||
/**
|
||||
* 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<number | null> {
|
||||
// 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<ExchangeRateData>(
|
||||
'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<void> {
|
||||
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<number> {
|
||||
// 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<number> {
|
||||
// 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<string, number> = {
|
||||
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<number> {
|
||||
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<number> {
|
||||
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<SupportedCurrency, string> = {
|
||||
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}`
|
||||
}
|
||||
109
src/extensions/marketplace/utils/validation.ts
Normal file
109
src/extensions/marketplace/utils/validation.ts
Normal file
|
|
@ -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')
|
||||
}
|
||||
}
|
||||
}
|
||||
254
src/extensions/types.ts
Normal file
254
src/extensions/types.ts
Normal file
|
|
@ -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<T = any>(sql: string, params?: any[]): Promise<T[]>
|
||||
|
||||
/**
|
||||
* Execute multiple statements in a transaction
|
||||
*/
|
||||
transaction<T>(fn: () => Promise<T>): Promise<T>
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<string, any> // 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<string, any>
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<any>
|
||||
|
||||
/**
|
||||
* Extension context - interface provided to extensions for interacting with Lightning.Pub
|
||||
*/
|
||||
export interface ExtensionContext {
|
||||
/**
|
||||
* Get information about an application
|
||||
*/
|
||||
getApplication(applicationId: string): Promise<ApplicationInfo | null>
|
||||
|
||||
/**
|
||||
* Create a Lightning invoice
|
||||
*/
|
||||
createInvoice(amountSats: number, options?: CreateInvoiceOptions): Promise<CreatedInvoice>
|
||||
|
||||
/**
|
||||
* 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<string>
|
||||
|
||||
/**
|
||||
* Publish a Nostr event (signed by application's key)
|
||||
*/
|
||||
publishNostrEvent(event: UnsignedNostrEvent): Promise<string | null>
|
||||
|
||||
/**
|
||||
* 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<LnurlPayInfo>
|
||||
|
||||
/**
|
||||
* Subscribe to payment received callbacks
|
||||
*/
|
||||
onPaymentReceived(callback: (payment: PaymentReceivedData) => Promise<void>): void
|
||||
|
||||
/**
|
||||
* Subscribe to incoming Nostr events for the application
|
||||
*/
|
||||
onNostrEvent(callback: (event: NostrEvent, applicationId: string) => Promise<void>): 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<void>
|
||||
|
||||
/**
|
||||
* Shutdown the extension
|
||||
* Called when Lightning.Pub is shutting down
|
||||
*/
|
||||
shutdown?(): Promise<void>
|
||||
|
||||
/**
|
||||
* Health check
|
||||
* Return true if extension is healthy
|
||||
*/
|
||||
healthCheck?(): Promise<boolean>
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<HandleNdebitRes> => {
|
||||
|
|
|
|||
|
|
@ -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' })
|
||||
|
|
|
|||
|
|
@ -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<void>
|
||||
|
||||
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)}...)`)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue