Compare commits

...

11 commits

Author SHA1 Message Date
Patrick Mulligan
a2366c40f2 feat(nip05): add Lightning Address support for zaps
Some checks are pending
Docker Compose Actions Workflow / test (push) Waiting to run
Adds /.well-known/lnurlp/:username endpoint that:
1. Looks up username in NIP-05 database
2. Gets LNURL-pay info from Lightning.Pub for that user
3. Returns standard LUD-16 response for wallet compatibility

This makes NIP-05 addresses (alice@domain) work seamlessly as
Lightning Addresses for receiving payments and NIP-57 zaps.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-04-01 13:15:43 -04:00
Patrick Mulligan
cf85a2fd7c feat(extensions): add NIP-05 identity extension
Implements Nostr NIP-05 for human-readable identity verification:
- Username claiming and management (username@domain)
- /.well-known/nostr.json endpoint per spec
- Optional relay hints in JSON response
- Admin controls for identity management

RPC methods:
- nip05.claim - Claim a username
- nip05.release - Release your username
- nip05.updateRelays - Update relay hints
- nip05.getMyIdentity - Get your identity
- nip05.lookup - Look up by username
- nip05.lookupByPubkey - Look up by pubkey
- nip05.listIdentities - List all (admin)
- nip05.deactivate/reactivate - Admin controls

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-04-01 13:15:43 -04:00
Patrick Mulligan
c308d4be78 fix: use fresh balance in PayAppUserInvoice notification
Some checks failed
Docker Compose Actions Workflow / test (push) Has been cancelled
notifyAppUserPayment was sending the stale cached balance from the
entity loaded before PayInvoice decremented it. Update the entity's
balance_sats from the PayInvoice response so LiveUserOperation events
contain the correct post-payment balance.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-26 18:45:27 -04:00
Patrick Mulligan
f1aa5d7139 chore: update Docker build and dependencies
- Add .dockerignore for runtime state files (sqlite, logs, secrets)
- Bump Node.js base image from 18 to 20
- Add @types/better-sqlite3 dev dependency

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-26 18:45:27 -04:00
Patrick Mulligan
9981e2628e fix: correct nip44v1 secp256k1 getSharedSecret argument types
The @noble/curves secp256k1.getSharedSecret expects Uint8Array arguments,
not hex strings. Use hex.decode() to convert the private and public keys.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-03-26 18:45:27 -04:00
Patrick Mulligan
ece75df22d feat(extensions): add getLnurlPayInfo to ExtensionContext
Enables extensions to get LNURL-pay info for users by pubkey,
supporting Lightning Address (LUD-16) and zap (NIP-57) functionality.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-03-26 18:45:27 -04:00
Patrick Mulligan
c909cd660a docs(extensions): add comprehensive extension loader documentation
Covers architecture, API reference, lifecycle, database isolation,
RPC methods, HTTP routes, event handling, and complete examples.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-03-26 18:45:27 -04:00
Patrick Mulligan
5b88982fed feat(extensions): add extension loader infrastructure
Adds a modular extension system for Lightning.Pub that allows
third-party functionality to be added without modifying core code.

Features:
- ExtensionLoader: discovers and loads extensions from directory
- ExtensionContext: provides extensions with access to Lightning.Pub APIs
- ExtensionDatabase: isolated SQLite database per extension
- Lifecycle management: initialize, shutdown, health checks
- RPC method registration: extensions can add new RPC methods
- Event dispatching: routes payments and Nostr events to extensions

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-03-26 18:45:27 -04:00
Patrick Mulligan
4d19b55c57 fix(nostr): close SimplePool after publishing to prevent connection leak
Each sendEvent() call created a new SimplePool() but never closed it,
causing relay WebSocket connections to accumulate indefinitely (~20/min).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-26 18:45:27 -04:00
Patrick Mulligan
071a27ad2d fix(handlers): await NostrSend calls throughout codebase
Update all NostrSend call sites to properly handle the async nature
of the function now that it returns Promise<void>.

Changes:
- handler.ts: Add async to sendResponse, await nostrSend calls
- debitManager.ts: Add logging for Kind 21002 response sending
- nostrMiddleware.ts: Update nostrSend signature
- tlvFilesStorageProcessor.ts: Update nostrSend signature
- webRTC/index.ts: Add async/await for nostrSend calls

This ensures Kind 21002 (ndebit) responses are properly sent to
wallet clients, fixing the "Debit request failed" issue in ShockWallet.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-03-26 18:45:27 -04:00
Patrick Mulligan
fee87e2741 fix(nostr): update NostrSend type to Promise<void> with error handling
The NostrSend type was incorrectly typed as returning void when it actually
returns Promise<void>. This caused async errors to be silently swallowed.

Changes:
- Update NostrSend type signature to return Promise<void>
- Make NostrSender._nostrSend default to async function
- Add .catch() error handling in NostrSender.Send() to log failures
- Add logging to track event publishing status

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-03-26 18:45:27 -04:00
23 changed files with 4024 additions and 1878 deletions

View file

@ -2,3 +2,21 @@
.github
build
node_modules
# Runtime state files (should not be baked into image)
*.sqlite
*.sqlite-journal
*.sqlite-wal
*.sqlite-shm
*.db
admin.connect
admin.enroll
admin.npub
app.nprofile
.jwt_secret
# Runtime data directories
metric_cache/
metric_events/
bundler_events/
logs/

View file

@ -1,4 +1,4 @@
FROM node:18
FROM node:20
WORKDIR /app

2926
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -77,6 +77,7 @@
"zip-a-folder": "^3.1.9"
},
"devDependencies": {
"@types/better-sqlite3": "^7.6.13",
"@types/chai": "^4.3.4",
"@types/chai-string": "^1.4.5",
"@types/cors": "^2.8.17",
@ -93,4 +94,4 @@
"typescript": "5.5.4"
},
"overrides": {}
}
}

731
src/extensions/README.md Normal file
View 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
View 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
View 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
View 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
View 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)
}

View file

@ -0,0 +1,300 @@
/**
* NIP-05 Extension for Lightning.Pub
*
* Implements Nostr NIP-05: Mapping Nostr keys to DNS-based internet identifiers
* Allows users to claim human-readable addresses like alice@domain.com
*
* Features:
* - Username claiming and management
* - .well-known/nostr.json endpoint
* - Optional relay hints
* - Admin controls for identity management
*/
import {
Extension,
ExtensionInfo,
ExtensionContext,
ExtensionDatabase,
HttpRoute,
HttpRequest,
HttpResponse
} from '../types.js'
import { runMigrations } from './migrations.js'
import { Nip05Manager } from './managers/nip05Manager.js'
import {
ClaimUsernameRequest,
UpdateRelaysRequest,
Nip05Config
} from './types.js'
/**
* NIP-05 Extension
*/
export default class Nip05Extension implements Extension {
readonly info: ExtensionInfo = {
id: 'nip05',
name: 'NIP-05 Identity',
version: '1.0.0',
description: 'Human-readable Nostr identities (username@domain)',
author: 'Lightning.Pub',
minPubVersion: '1.0.0'
}
private manager!: Nip05Manager
private ctx!: ExtensionContext
private config: Nip05Config = {}
/**
* Initialize the extension
*/
async initialize(ctx: ExtensionContext, db: ExtensionDatabase): Promise<void> {
this.ctx = ctx
// Run migrations
await runMigrations(db)
// Initialize manager
this.manager = new Nip05Manager(ctx, db, this.config)
// Register RPC methods
this.registerRpcMethods(ctx)
ctx.log('info', 'Extension initialized')
}
/**
* Shutdown the extension
*/
async shutdown(): Promise<void> {
// Cleanup if needed
}
/**
* Configure the extension
*/
configure(config: Nip05Config): void {
this.config = config
}
/**
* Get HTTP routes for this extension
* These need to be mounted by the main HTTP server
*/
getHttpRoutes(): HttpRoute[] {
return [
// NIP-05 well-known endpoint
{
method: 'GET',
path: '/.well-known/nostr.json',
handler: this.handleNostrJson.bind(this)
},
// Alternative path for proxied setups
{
method: 'GET',
path: '/api/v1/nip05/nostr.json',
handler: this.handleNostrJson.bind(this)
},
// Lightning Address endpoint (LUD-16)
// Makes NIP-05 usernames work as Lightning Addresses for zaps
{
method: 'GET',
path: '/.well-known/lnurlp/:username',
handler: this.handleLnurlPay.bind(this)
}
]
}
/**
* Register RPC methods with the extension context
*/
private registerRpcMethods(ctx: ExtensionContext): void {
// Claim a username
ctx.registerMethod('nip05.claim', async (req, appId, userId, pubkey) => {
if (!userId || !pubkey) {
throw new Error('Authentication required')
}
return this.manager.claimUsername(userId, pubkey, appId, req as ClaimUsernameRequest)
})
// Release your username
ctx.registerMethod('nip05.release', async (req, appId, userId) => {
if (!userId) {
throw new Error('Authentication required')
}
await this.manager.releaseUsername(userId, appId)
return { success: true }
})
// Update your relays
ctx.registerMethod('nip05.updateRelays', async (req, appId, userId) => {
if (!userId) {
throw new Error('Authentication required')
}
const identity = await this.manager.updateRelays(userId, appId, req as UpdateRelaysRequest)
return { identity }
})
// Get your identity
ctx.registerMethod('nip05.getMyIdentity', async (req, appId, userId) => {
if (!userId) {
throw new Error('Authentication required')
}
return this.manager.getMyIdentity(userId, appId)
})
// Look up a username (public)
ctx.registerMethod('nip05.lookup', async (req, appId) => {
return this.manager.lookupUsername(appId, req.username)
})
// Look up by pubkey (public)
ctx.registerMethod('nip05.lookupByPubkey', async (req, appId) => {
return this.manager.lookupByPubkey(appId, req.pubkey)
})
// List all identities (admin)
ctx.registerMethod('nip05.listIdentities', async (req, appId) => {
return this.manager.listIdentities(appId, {
limit: req.limit,
offset: req.offset,
activeOnly: req.active_only
})
})
// Deactivate an identity (admin)
ctx.registerMethod('nip05.deactivate', async (req, appId) => {
await this.manager.deactivateIdentity(appId, req.identity_id)
return { success: true }
})
// Reactivate an identity (admin)
ctx.registerMethod('nip05.reactivate', async (req, appId) => {
await this.manager.reactivateIdentity(appId, req.identity_id)
return { success: true }
})
}
// =========================================================================
// HTTP Route Handlers
// =========================================================================
/**
* Handle /.well-known/nostr.json request
* GET /.well-known/nostr.json?name=<username>
*
* Per NIP-05 spec, returns:
* {
* "names": { "<username>": "<pubkey hex>" },
* "relays": { "<pubkey hex>": ["wss://..."] }
* }
*/
private async handleNostrJson(req: HttpRequest): Promise<HttpResponse> {
try {
// Get application ID from request context
// In a multi-tenant setup, this would come from the host or path
const appId = req.headers['x-application-id'] || 'default'
// Set domain from request host for NIP-05 address formatting
if (req.headers['host']) {
this.manager.setDomain(req.headers['host'].split(':')[0])
}
// Get the name parameter
const name = req.query.name
// Get the JSON response
const response = await this.manager.handleNostrJson(appId, name)
return {
status: 200,
body: response,
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*',
'Cache-Control': 'max-age=300' // Cache for 5 minutes
}
}
} catch (error) {
this.ctx.log('error', `Error handling nostr.json: ${error}`)
return {
status: 500,
body: { error: 'Internal server error' },
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*'
}
}
}
}
/**
* Handle /.well-known/lnurlp/:username request (Lightning Address / LUD-16)
*
* This enables NIP-05 usernames to work as Lightning Addresses for receiving
* payments and zaps. When someone sends to alice@domain.com:
* 1. Wallet resolves /.well-known/lnurlp/alice
* 2. We look up alice -> pubkey in our NIP-05 database
* 3. We return LNURL-pay info from Lightning.Pub for that user
*/
private async handleLnurlPay(req: HttpRequest): Promise<HttpResponse> {
try {
const { username } = req.params
const appId = req.headers['x-application-id'] || 'default'
if (!username) {
return {
status: 400,
body: { status: 'ERROR', reason: 'Username required' },
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*'
}
}
}
// Look up the username in our NIP-05 database
const lookup = await this.manager.lookupUsername(appId, username)
if (!lookup.found || !lookup.identity) {
return {
status: 404,
body: { status: 'ERROR', reason: 'User not found' },
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*'
}
}
}
// Get LNURL-pay info from Lightning.Pub for this user's pubkey
const lnurlPayInfo = await this.ctx.getLnurlPayInfo(lookup.identity.pubkey_hex, {
description: `Pay to ${username}`
})
return {
status: 200,
body: lnurlPayInfo,
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*',
'Cache-Control': 'max-age=60' // Cache for 1 minute
}
}
} catch (error) {
this.ctx.log('error', `Error handling lnurlp: ${error}`)
return {
status: 500,
body: { status: 'ERROR', reason: 'Internal server error' },
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*'
}
}
}
}
}
// Export types for external use
export * from './types.js'
export { Nip05Manager } from './managers/nip05Manager.js'

View file

@ -0,0 +1,452 @@
/**
* NIP-05 Identity Manager
*
* Handles username claiming, lookup, and .well-known/nostr.json responses
*/
import { ExtensionContext, ExtensionDatabase } from '../../types.js'
import {
Nip05Identity,
Nip05IdentityRow,
Nip05JsonResponse,
Nip05Config,
UsernameValidation,
ClaimUsernameRequest,
ClaimUsernameResponse,
UpdateRelaysRequest,
LookupUsernameResponse,
GetMyIdentityResponse
} from '../types.js'
import crypto from 'crypto'
/**
* Default configuration
*/
const DEFAULT_CONFIG: Required<Nip05Config> = {
max_username_length: 30,
min_username_length: 1,
reserved_usernames: ['admin', 'root', 'system', 'support', 'help', 'info', 'contact', 'abuse', 'postmaster', 'webmaster', 'hostmaster', 'noreply', 'no-reply', 'null', 'undefined', 'api', 'www', 'mail', 'ftp', 'ssh', 'test', 'demo'],
include_relays: true,
default_relays: []
}
/**
* Convert database row to Nip05Identity
*/
function rowToIdentity(row: Nip05IdentityRow): Nip05Identity {
return {
id: row.id,
application_id: row.application_id,
user_id: row.user_id,
username: row.username,
pubkey_hex: row.pubkey_hex,
relays: JSON.parse(row.relays_json),
is_active: row.is_active === 1,
created_at: row.created_at,
updated_at: row.updated_at
}
}
/**
* Generate a unique ID
*/
function generateId(): string {
return crypto.randomBytes(16).toString('hex')
}
/**
* Validate username format
* - Lowercase alphanumeric and underscore only
* - Must start with a letter
* - Length within bounds
*/
function validateUsername(username: string, config: Required<Nip05Config>): UsernameValidation {
if (!username) {
return { valid: false, error: 'Username is required' }
}
const normalized = username.toLowerCase().trim()
if (normalized.length < config.min_username_length) {
return { valid: false, error: `Username must be at least ${config.min_username_length} character(s)` }
}
if (normalized.length > config.max_username_length) {
return { valid: false, error: `Username must be at most ${config.max_username_length} characters` }
}
// Only lowercase letters, numbers, and underscores
if (!/^[a-z][a-z0-9_]*$/.test(normalized)) {
return { valid: false, error: 'Username must start with a letter and contain only lowercase letters, numbers, and underscores' }
}
// Check reserved usernames
if (config.reserved_usernames.includes(normalized)) {
return { valid: false, error: 'This username is reserved' }
}
return { valid: true }
}
/**
* Validate relay URLs
*/
function validateRelays(relays: string[]): UsernameValidation {
if (!Array.isArray(relays)) {
return { valid: false, error: 'Relays must be an array' }
}
for (const relay of relays) {
if (typeof relay !== 'string') {
return { valid: false, error: 'Each relay must be a string' }
}
if (!relay.startsWith('wss://') && !relay.startsWith('ws://')) {
return { valid: false, error: `Invalid relay URL: ${relay}` }
}
}
return { valid: true }
}
export class Nip05Manager {
private ctx: ExtensionContext
private db: ExtensionDatabase
private config: Required<Nip05Config>
private domain: string
constructor(ctx: ExtensionContext, db: ExtensionDatabase, config?: Nip05Config) {
this.ctx = ctx
this.db = db
this.config = { ...DEFAULT_CONFIG, ...config }
// Extract domain from the service URL
this.domain = this.extractDomain()
}
/**
* Extract domain from service URL for NIP-05 addresses
*/
private extractDomain(): string {
// This would come from Lightning.Pub's configuration
// For now, we'll derive it when needed from the request host
return 'localhost'
}
/**
* Set the domain (called from HTTP request context)
*/
setDomain(domain: string): void {
this.domain = domain
}
/**
* Claim a username for the current user
*/
async claimUsername(
userId: string,
pubkeyHex: string,
applicationId: string,
request: ClaimUsernameRequest
): Promise<ClaimUsernameResponse> {
const normalizedUsername = request.username.toLowerCase().trim()
// Validate username format
const validation = validateUsername(normalizedUsername, this.config)
if (!validation.valid) {
throw new Error(validation.error)
}
// Validate relays if provided
const relays = request.relays || this.config.default_relays
if (relays.length > 0) {
const relayValidation = validateRelays(relays)
if (!relayValidation.valid) {
throw new Error(relayValidation.error)
}
}
// Check if user already has an identity in this application
const existingByUser = await this.db.query<Nip05IdentityRow>(
`SELECT * FROM identities WHERE application_id = ? AND user_id = ?`,
[applicationId, userId]
)
if (existingByUser.length > 0) {
throw new Error('You already have a username. Release it first to claim a new one.')
}
// Check if username is already taken
const existingByUsername = await this.db.query<Nip05IdentityRow>(
`SELECT * FROM identities WHERE application_id = ? AND username = ?`,
[applicationId, normalizedUsername]
)
if (existingByUsername.length > 0) {
throw new Error('This username is already taken')
}
// Create the identity
const now = Math.floor(Date.now() / 1000)
const id = generateId()
await this.db.execute(
`INSERT INTO identities (id, application_id, user_id, username, pubkey_hex, relays_json, is_active, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, 1, ?, ?)`,
[id, applicationId, userId, normalizedUsername, pubkeyHex, JSON.stringify(relays), now, now]
)
const identity: Nip05Identity = {
id,
application_id: applicationId,
user_id: userId,
username: normalizedUsername,
pubkey_hex: pubkeyHex,
relays,
is_active: true,
created_at: now,
updated_at: now
}
return {
identity,
nip05_address: `${normalizedUsername}@${this.domain}`
}
}
/**
* Release (delete) the current user's username
*/
async releaseUsername(userId: string, applicationId: string): Promise<void> {
const result = await this.db.execute(
`DELETE FROM identities WHERE application_id = ? AND user_id = ?`,
[applicationId, userId]
)
if (result.changes === 0) {
throw new Error('You do not have a username to release')
}
}
/**
* Update relays for the current user's identity
*/
async updateRelays(
userId: string,
applicationId: string,
request: UpdateRelaysRequest
): Promise<Nip05Identity> {
// Validate relays
const validation = validateRelays(request.relays)
if (!validation.valid) {
throw new Error(validation.error)
}
const now = Math.floor(Date.now() / 1000)
const result = await this.db.execute(
`UPDATE identities SET relays_json = ?, updated_at = ? WHERE application_id = ? AND user_id = ?`,
[JSON.stringify(request.relays), now, applicationId, userId]
)
if (result.changes === 0) {
throw new Error('You do not have a username')
}
// Fetch and return the updated identity
const rows = await this.db.query<Nip05IdentityRow>(
`SELECT * FROM identities WHERE application_id = ? AND user_id = ?`,
[applicationId, userId]
)
return rowToIdentity(rows[0])
}
/**
* Get the current user's identity
*/
async getMyIdentity(userId: string, applicationId: string): Promise<GetMyIdentityResponse> {
const rows = await this.db.query<Nip05IdentityRow>(
`SELECT * FROM identities WHERE application_id = ? AND user_id = ?`,
[applicationId, userId]
)
if (rows.length === 0) {
return { has_identity: false }
}
const identity = rowToIdentity(rows[0])
return {
has_identity: true,
identity,
nip05_address: `${identity.username}@${this.domain}`
}
}
/**
* Look up a username (public, no auth required)
*/
async lookupUsername(applicationId: string, username: string): Promise<LookupUsernameResponse> {
const normalizedUsername = username.toLowerCase().trim()
const rows = await this.db.query<Nip05IdentityRow>(
`SELECT * FROM identities WHERE application_id = ? AND username = ? AND is_active = 1`,
[applicationId, normalizedUsername]
)
if (rows.length === 0) {
return { found: false }
}
const identity = rowToIdentity(rows[0])
return {
found: true,
identity,
nip05_address: `${identity.username}@${this.domain}`
}
}
/**
* Look up by pubkey
*/
async lookupByPubkey(applicationId: string, pubkeyHex: string): Promise<LookupUsernameResponse> {
const rows = await this.db.query<Nip05IdentityRow>(
`SELECT * FROM identities WHERE application_id = ? AND pubkey_hex = ? AND is_active = 1`,
[applicationId, pubkeyHex]
)
if (rows.length === 0) {
return { found: false }
}
const identity = rowToIdentity(rows[0])
return {
found: true,
identity,
nip05_address: `${identity.username}@${this.domain}`
}
}
/**
* Handle /.well-known/nostr.json request
* This is the core NIP-05 endpoint
*/
async handleNostrJson(applicationId: string, name?: string): Promise<Nip05JsonResponse> {
const response: Nip05JsonResponse = {
names: {}
}
if (this.config.include_relays) {
response.relays = {}
}
if (name) {
// Look up specific username
const normalizedName = name.toLowerCase().trim()
const rows = await this.db.query<Nip05IdentityRow>(
`SELECT * FROM identities WHERE application_id = ? AND username = ? AND is_active = 1`,
[applicationId, normalizedName]
)
if (rows.length > 0) {
const identity = rowToIdentity(rows[0])
response.names[identity.username] = identity.pubkey_hex
if (this.config.include_relays && identity.relays.length > 0) {
response.relays![identity.pubkey_hex] = identity.relays
}
}
} else {
// Return all active identities (with reasonable limit)
const rows = await this.db.query<Nip05IdentityRow>(
`SELECT * FROM identities WHERE application_id = ? AND is_active = 1 LIMIT 1000`,
[applicationId]
)
for (const row of rows) {
const identity = rowToIdentity(row)
response.names[identity.username] = identity.pubkey_hex
if (this.config.include_relays && identity.relays.length > 0) {
response.relays![identity.pubkey_hex] = identity.relays
}
}
}
return response
}
/**
* List all identities for an application (admin)
*/
async listIdentities(
applicationId: string,
options?: { limit?: number; offset?: number; activeOnly?: boolean }
): Promise<{ identities: Nip05Identity[]; total: number }> {
const limit = options?.limit || 50
const offset = options?.offset || 0
const activeClause = options?.activeOnly !== false ? 'AND is_active = 1' : ''
// Get total count
const countResult = await this.db.query<{ count: number }>(
`SELECT COUNT(*) as count FROM identities WHERE application_id = ? ${activeClause}`,
[applicationId]
)
const total = countResult[0]?.count || 0
// Get page of results
const rows = await this.db.query<Nip05IdentityRow>(
`SELECT * FROM identities WHERE application_id = ? ${activeClause}
ORDER BY created_at DESC LIMIT ? OFFSET ?`,
[applicationId, limit, offset]
)
return {
identities: rows.map(rowToIdentity),
total
}
}
/**
* Deactivate an identity (admin)
*/
async deactivateIdentity(applicationId: string, identityId: string): Promise<void> {
const now = Math.floor(Date.now() / 1000)
const result = await this.db.execute(
`UPDATE identities SET is_active = 0, updated_at = ? WHERE application_id = ? AND id = ?`,
[now, applicationId, identityId]
)
if (result.changes === 0) {
throw new Error('Identity not found')
}
}
/**
* Reactivate an identity (admin)
*/
async reactivateIdentity(applicationId: string, identityId: string): Promise<void> {
const now = Math.floor(Date.now() / 1000)
// Check if username is taken by an active identity
const identity = await this.db.query<Nip05IdentityRow>(
`SELECT * FROM identities WHERE id = ? AND application_id = ?`,
[identityId, applicationId]
)
if (identity.length === 0) {
throw new Error('Identity not found')
}
const conflicting = await this.db.query<Nip05IdentityRow>(
`SELECT * FROM identities WHERE application_id = ? AND username = ? AND is_active = 1 AND id != ?`,
[applicationId, identity[0].username, identityId]
)
if (conflicting.length > 0) {
throw new Error('Username is already taken by another active identity')
}
await this.db.execute(
`UPDATE identities SET is_active = 1, updated_at = ? WHERE application_id = ? AND id = ?`,
[now, applicationId, identityId]
)
}
}

View file

@ -0,0 +1,93 @@
/**
* NIP-05 Extension Database Migrations
*/
import { ExtensionDatabase } from '../types.js'
export interface Migration {
version: number
name: string
up: (db: ExtensionDatabase) => Promise<void>
down?: (db: ExtensionDatabase) => Promise<void>
}
export const migrations: Migration[] = [
{
version: 1,
name: 'create_identities_table',
up: async (db: ExtensionDatabase) => {
await db.execute(`
CREATE TABLE IF NOT EXISTS identities (
id TEXT PRIMARY KEY,
application_id TEXT NOT NULL,
user_id TEXT NOT NULL,
-- Identity mapping
username TEXT NOT NULL,
pubkey_hex TEXT NOT NULL,
-- Optional relays (JSON array)
relays_json TEXT NOT NULL DEFAULT '[]',
-- Status
is_active INTEGER NOT NULL DEFAULT 1,
-- Timestamps
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
)
`)
// Unique username per application (case-insensitive via lowercase storage)
await db.execute(`
CREATE UNIQUE INDEX IF NOT EXISTS idx_identities_username_app
ON identities(application_id, username)
`)
// One identity per user per application
await db.execute(`
CREATE UNIQUE INDEX IF NOT EXISTS idx_identities_user_app
ON identities(application_id, user_id)
`)
// Look up by pubkey
await db.execute(`
CREATE INDEX IF NOT EXISTS idx_identities_pubkey
ON identities(pubkey_hex)
`)
// Look up active identities for .well-known endpoint
await db.execute(`
CREATE INDEX IF NOT EXISTS idx_identities_active
ON identities(application_id, is_active, username)
`)
}
}
]
/**
* Run all pending migrations
*/
export async function runMigrations(db: ExtensionDatabase): Promise<void> {
// Get current version
const versionResult = await db.query<{ value: string }>(
`SELECT value FROM _extension_meta WHERE key = 'migration_version'`
).catch(() => [])
const currentVersion = versionResult.length > 0 ? parseInt(versionResult[0].value, 10) : 0
// Run pending migrations
for (const migration of migrations) {
if (migration.version > currentVersion) {
console.log(`[NIP-05] Running migration ${migration.version}: ${migration.name}`)
await migration.up(db)
// Update version
await db.execute(
`INSERT INTO _extension_meta (key, value) VALUES ('migration_version', ?)
ON CONFLICT(key) DO UPDATE SET value = excluded.value`,
[String(migration.version)]
)
}
}
}

View file

@ -0,0 +1,130 @@
/**
* NIP-05 Extension Types
*
* Implements Nostr NIP-05: Mapping Nostr keys to DNS-based internet identifiers
* Allows users to have human-readable addresses like alice@domain.com
*/
/**
* A NIP-05 identity mapping a username to a Nostr public key
*/
export interface Nip05Identity {
id: string
application_id: string
user_id: string
/** The human-readable username (lowercase, alphanumeric + underscore) */
username: string
/** The Nostr public key in hex format */
pubkey_hex: string
/** Optional list of relay URLs for this user */
relays: string[]
/** Whether this identity is active */
is_active: boolean
created_at: number
updated_at: number
}
/**
* NIP-05 JSON response format per the spec
* GET /.well-known/nostr.json?name=<username>
*/
export interface Nip05JsonResponse {
names: Record<string, string>
relays?: Record<string, string[]>
}
/**
* Request to claim a username
*/
export interface ClaimUsernameRequest {
username: string
relays?: string[]
}
/**
* Response after claiming a username
*/
export interface ClaimUsernameResponse {
identity: Nip05Identity
nip05_address: string
}
/**
* Request to update relays for a username
*/
export interface UpdateRelaysRequest {
relays: string[]
}
/**
* Request to look up a username
*/
export interface LookupUsernameRequest {
username: string
}
/**
* Response for username lookup
*/
export interface LookupUsernameResponse {
found: boolean
identity?: Nip05Identity
nip05_address?: string
}
/**
* Response for getting current user's identity
*/
export interface GetMyIdentityResponse {
has_identity: boolean
identity?: Nip05Identity
nip05_address?: string
}
/**
* Database row for NIP-05 identity
*/
export interface Nip05IdentityRow {
id: string
application_id: string
user_id: string
username: string
pubkey_hex: string
relays_json: string
is_active: number
created_at: number
updated_at: number
}
/**
* Extension configuration
*/
export interface Nip05Config {
/** Maximum username length (default: 30) */
max_username_length?: number
/** Minimum username length (default: 1) */
min_username_length?: number
/** Reserved usernames that cannot be claimed */
reserved_usernames?: string[]
/** Whether to include relays in the JSON response (default: true) */
include_relays?: boolean
/** Default relays to suggest for new users */
default_relays?: string[]
}
/**
* Validation result for username
*/
export interface UsernameValidation {
valid: boolean
error?: string
}

254
src/extensions/types.ts Normal file
View 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
}

View file

@ -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)
}

View file

@ -241,6 +241,8 @@ export default class {
const paid = await this.paymentManager.PayInvoice(appUser.user.user_id, req, app, {
ack: pendingOp => { this.notifyAppUserPayment(appUser, pendingOp) }
})
// Refresh appUser balance from DB so notification has accurate latest_balance
appUser.user.balance_sats = paid.latest_balance
this.notifyAppUserPayment(appUser, paid.operation)
getLogger({ appName: app.name })(appUser.identifier, "invoice paid", paid.amount_paid, "sats")
return paid

View file

@ -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> => {

View file

@ -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' })

View file

@ -1,14 +1,14 @@
import { base64 } from "@scure/base";
import { base64, hex } from "@scure/base";
import { randomBytes } from "@noble/hashes/utils";
import { streamXOR as xchacha20 } from "@stablelib/xchacha20";
import { secp256k1 } from "@noble/curves/secp256k1";
import { secp256k1 } from "@noble/curves/secp256k1.js";
import { sha256 } from "@noble/hashes/sha256";
export type EncryptedData = {
ciphertext: Uint8Array;
nonce: Uint8Array;
}
export const getSharedSecret = (privateKey: string, publicKey: string) => {
const key = secp256k1.getSharedSecret(privateKey, "02" + publicKey);
const key = secp256k1.getSharedSecret(hex.decode(privateKey), hex.decode("02" + publicKey));
return sha256(key.slice(1, 33));
}

View file

@ -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,26 @@ 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)
log(e)
try {
await Promise.all(pool.publish(relays, signed).map(async p => {
try {
await p
sent = true
} catch (e: any) {
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 {
this.log(`✅ Kind ${event.kind} event published successfully (id: ${signed.id.slice(0, 16)}...)`)
}
}))
if (!sent) {
log("failed to send event")
} else {
//log("sent event")
} finally {
pool.close(relays)
}
}

View file

@ -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

View file

@ -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',

View file

@ -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) => {