diff --git a/src/extensions/nip05/index.ts b/src/extensions/nip05/index.ts deleted file mode 100644 index a37fe8e0..00000000 --- a/src/extensions/nip05/index.ts +++ /dev/null @@ -1,227 +0,0 @@ -/** - * 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 { - 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 { - // 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) - } - ] - } - - /** - * 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= - * - * Per NIP-05 spec, returns: - * { - * "names": { "": "" }, - * "relays": { "": ["wss://..."] } - * } - */ - private async handleNostrJson(req: HttpRequest): Promise { - 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': '*' - } - } - } - } -} - -// Export types for external use -export * from './types.js' -export { Nip05Manager } from './managers/nip05Manager.js' diff --git a/src/extensions/nip05/managers/nip05Manager.ts b/src/extensions/nip05/managers/nip05Manager.ts deleted file mode 100644 index 3efe85b8..00000000 --- a/src/extensions/nip05/managers/nip05Manager.ts +++ /dev/null @@ -1,452 +0,0 @@ -/** - * 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 = { - 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): 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 - 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 { - 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( - `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( - `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 { - 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 { - // 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( - `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 { - const rows = await this.db.query( - `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 { - const normalizedUsername = username.toLowerCase().trim() - - const rows = await this.db.query( - `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 { - const rows = await this.db.query( - `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 { - 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( - `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( - `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( - `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 { - 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 { - const now = Math.floor(Date.now() / 1000) - - // Check if username is taken by an active identity - const identity = await this.db.query( - `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( - `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] - ) - } -} diff --git a/src/extensions/nip05/migrations.ts b/src/extensions/nip05/migrations.ts deleted file mode 100644 index 5cf22909..00000000 --- a/src/extensions/nip05/migrations.ts +++ /dev/null @@ -1,93 +0,0 @@ -/** - * NIP-05 Extension Database Migrations - */ - -import { ExtensionDatabase } from '../types.js' - -export interface Migration { - version: number - name: string - up: (db: ExtensionDatabase) => Promise - down?: (db: ExtensionDatabase) => Promise -} - -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 { - // 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)] - ) - } - } -} diff --git a/src/extensions/nip05/types.ts b/src/extensions/nip05/types.ts deleted file mode 100644 index 93967f4c..00000000 --- a/src/extensions/nip05/types.ts +++ /dev/null @@ -1,130 +0,0 @@ -/** - * 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= - */ -export interface Nip05JsonResponse { - names: Record - relays?: Record -} - -/** - * 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 -}