From cf85a2fd7cbb8f47be6a3b83bd02251ea03c8760 Mon Sep 17 00:00:00 2001 From: Patrick Mulligan Date: Fri, 13 Feb 2026 14:27:04 -0500 Subject: [PATCH 1/7] 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 --- src/extensions/nip05/index.ts | 227 +++++++++ src/extensions/nip05/managers/nip05Manager.ts | 452 ++++++++++++++++++ src/extensions/nip05/migrations.ts | 93 ++++ src/extensions/nip05/types.ts | 130 +++++ 4 files changed, 902 insertions(+) create mode 100644 src/extensions/nip05/index.ts create mode 100644 src/extensions/nip05/managers/nip05Manager.ts create mode 100644 src/extensions/nip05/migrations.ts create mode 100644 src/extensions/nip05/types.ts diff --git a/src/extensions/nip05/index.ts b/src/extensions/nip05/index.ts new file mode 100644 index 00000000..a37fe8e0 --- /dev/null +++ b/src/extensions/nip05/index.ts @@ -0,0 +1,227 @@ +/** + * 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 new file mode 100644 index 00000000..3efe85b8 --- /dev/null +++ b/src/extensions/nip05/managers/nip05Manager.ts @@ -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 = { + 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 new file mode 100644 index 00000000..5cf22909 --- /dev/null +++ b/src/extensions/nip05/migrations.ts @@ -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 + 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 new file mode 100644 index 00000000..93967f4c --- /dev/null +++ b/src/extensions/nip05/types.ts @@ -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= + */ +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 +} From a2366c40f2d5a6b3ad43a31d9fcb214a769bdfbb Mon Sep 17 00:00:00 2001 From: Patrick Mulligan Date: Fri, 13 Feb 2026 15:04:59 -0500 Subject: [PATCH 2/7] feat(nip05): add Lightning Address support for zaps 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 --- src/extensions/nip05/index.ts | 73 +++++++++++++++++++++++++++++++++++ 1 file changed, 73 insertions(+) diff --git a/src/extensions/nip05/index.ts b/src/extensions/nip05/index.ts index a37fe8e0..a8c1a7fd 100644 --- a/src/extensions/nip05/index.ts +++ b/src/extensions/nip05/index.ts @@ -94,6 +94,13 @@ export default class Nip05Extension implements Extension { 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) } ] } @@ -220,6 +227,72 @@ export default class Nip05Extension implements Extension { } } } + + /** + * 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 { + 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 From 5cc7f3998c5cf4d29f7e16ab4481520b2fa223d5 Mon Sep 17 00:00:00 2001 From: Patrick Mulligan Date: Wed, 1 Apr 2026 13:24:22 -0400 Subject: [PATCH 3/7] fix(extensions): add HTTP route types and getHttpRoutes to Extension interface HttpRoute, HttpRequest, and HttpResponse types were used by extensions (withdraw, nip05) but not defined in the shared types.ts. Adds them here so extensions import from the shared module instead of defining locally. Also adds getHttpRoutes() as an optional method on the Extension interface for extensions that expose HTTP endpoints. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/extensions/types.ts | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/src/extensions/types.ts b/src/extensions/types.ts index 62abf5df..2027fb09 100644 --- a/src/extensions/types.ts +++ b/src/extensions/types.ts @@ -191,6 +191,31 @@ export interface ExtensionContext { log(level: 'debug' | 'info' | 'warn' | 'error', message: string, ...args: any[]): void } +/** + * HTTP route handler types + * Used by extensions that expose HTTP endpoints (e.g. LNURL, .well-known) + */ +export interface HttpRequest { + method: string + path: string + params: Record + query: Record + headers: Record + body?: any +} + +export interface HttpResponse { + status: number + body: any + headers?: Record +} + +export interface HttpRoute { + method: 'GET' | 'POST' + path: string + handler: (req: HttpRequest) => Promise +} + /** * Extension interface - what extensions must implement */ @@ -217,6 +242,12 @@ export interface Extension { * Return true if extension is healthy */ healthCheck?(): Promise + + /** + * Get HTTP routes exposed by this extension + * The main HTTP server will mount these routes + */ + getHttpRoutes?(): HttpRoute[] } /** From 8f38622395f4c0d4cd2caba1465608f6520b87ef Mon Sep 17 00:00:00 2001 From: Patrick Mulligan Date: Fri, 13 Feb 2026 14:27:04 -0500 Subject: [PATCH 4/7] 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 --- src/extensions/nip05/index.ts | 227 +++++++++ src/extensions/nip05/managers/nip05Manager.ts | 452 ++++++++++++++++++ src/extensions/nip05/migrations.ts | 93 ++++ src/extensions/nip05/types.ts | 130 +++++ 4 files changed, 902 insertions(+) create mode 100644 src/extensions/nip05/index.ts create mode 100644 src/extensions/nip05/managers/nip05Manager.ts create mode 100644 src/extensions/nip05/migrations.ts create mode 100644 src/extensions/nip05/types.ts diff --git a/src/extensions/nip05/index.ts b/src/extensions/nip05/index.ts new file mode 100644 index 00000000..a37fe8e0 --- /dev/null +++ b/src/extensions/nip05/index.ts @@ -0,0 +1,227 @@ +/** + * 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 new file mode 100644 index 00000000..3efe85b8 --- /dev/null +++ b/src/extensions/nip05/managers/nip05Manager.ts @@ -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 = { + 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 new file mode 100644 index 00000000..5cf22909 --- /dev/null +++ b/src/extensions/nip05/migrations.ts @@ -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 + 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 new file mode 100644 index 00000000..93967f4c --- /dev/null +++ b/src/extensions/nip05/types.ts @@ -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= + */ +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 +} From a7fb92e26d32faccca4ff932c0a49c372e01b61d Mon Sep 17 00:00:00 2001 From: Patrick Mulligan Date: Fri, 13 Feb 2026 15:04:59 -0500 Subject: [PATCH 5/7] feat(nip05): add Lightning Address support for zaps 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 --- src/extensions/nip05/index.ts | 73 +++++++++++++++++++++++++++++++++++ 1 file changed, 73 insertions(+) diff --git a/src/extensions/nip05/index.ts b/src/extensions/nip05/index.ts index a37fe8e0..a8c1a7fd 100644 --- a/src/extensions/nip05/index.ts +++ b/src/extensions/nip05/index.ts @@ -94,6 +94,13 @@ export default class Nip05Extension implements Extension { 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) } ] } @@ -220,6 +227,72 @@ export default class Nip05Extension implements Extension { } } } + + /** + * 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 { + 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 From 53945d7dccfc8a9e83ce074cb750d252db81f397 Mon Sep 17 00:00:00 2001 From: Patrick Mulligan Date: Wed, 1 Apr 2026 13:25:06 -0400 Subject: [PATCH 6/7] fix(nip05): allow hyphens and periods in usernames per NIP-05 spec NIP-05 spec states local-part MUST only use characters a-z0-9-_. The previous regex /^[a-z][a-z0-9_]*$/ rejected hyphens and periods. Updated to /^[a-z][a-z0-9._-]*[a-z0-9]$/ and added support for the root identifier "_" (_@domain) as described in the spec. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/extensions/nip05/managers/nip05Manager.ts | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/src/extensions/nip05/managers/nip05Manager.ts b/src/extensions/nip05/managers/nip05Manager.ts index 3efe85b8..d83fef1c 100644 --- a/src/extensions/nip05/managers/nip05Manager.ts +++ b/src/extensions/nip05/managers/nip05Manager.ts @@ -55,10 +55,12 @@ function generateId(): string { } /** - * Validate username format - * - Lowercase alphanumeric and underscore only + * Validate username format per NIP-05 spec + * - Characters allowed: a-z, 0-9, hyphen (-), underscore (_), period (.) * - Must start with a letter + * - Must not end with a hyphen, underscore, or period * - Length within bounds + * - Special case: "_" alone is the root identifier (_@domain) */ function validateUsername(username: string, config: Required): UsernameValidation { if (!username) { @@ -67,6 +69,11 @@ function validateUsername(username: string, config: Required): User const normalized = username.toLowerCase().trim() + // Special case: root identifier "_" per NIP-05 + if (normalized === '_') { + return { valid: true } + } + if (normalized.length < config.min_username_length) { return { valid: false, error: `Username must be at least ${config.min_username_length} character(s)` } } @@ -75,9 +82,10 @@ function validateUsername(username: string, config: Required): User 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' } + // NIP-05 spec: local-part MUST only use characters a-z0-9-_. + // Must start with a letter, must not end with separator + if (!/^[a-z][a-z0-9._-]*[a-z0-9]$/.test(normalized) && !/^[a-z]$/.test(normalized)) { + return { valid: false, error: 'Username must start with a letter, end with a letter or number, and contain only a-z, 0-9, hyphens, underscores, and periods' } } // Check reserved usernames From 17727d3e316fcf7a181ac568626b6a136f1ed253 Mon Sep 17 00:00:00 2001 From: Patrick Mulligan Date: Wed, 1 Apr 2026 13:25:19 -0400 Subject: [PATCH 7/7] fix(nip05): add redirect prevention docs and zap field validation Gap #5: Document NIP-05 spec requirement that /.well-known/nostr.json MUST NOT return HTTP redirects. The extension already complies (always returns direct responses), but reverse proxy deployments need awareness. Gap #7: Log a warning when getLnurlPayInfo() response is missing allowsNostr or nostrPubkey fields required by NIP-57 for zap support. This surfaces misconfiguration early instead of silently breaking zaps. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/extensions/nip05/index.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/extensions/nip05/index.ts b/src/extensions/nip05/index.ts index a8c1a7fd..c74c0bf5 100644 --- a/src/extensions/nip05/index.ts +++ b/src/extensions/nip05/index.ts @@ -189,6 +189,12 @@ export default class Nip05Extension implements Extension { * "relays": { "": ["wss://..."] } * } */ + /** + * NIP-05 spec: "The /.well-known/nostr.json endpoint MUST NOT return any + * HTTP redirects." This extension always returns direct 200/4xx/5xx responses. + * Deployment note: ensure reverse proxies do not add 3xx redirects on this path + * (e.g. HTTP→HTTPS or trailing-slash redirects). + */ private async handleNostrJson(req: HttpRequest): Promise { try { // Get application ID from request context @@ -272,6 +278,11 @@ export default class Nip05Extension implements Extension { description: `Pay to ${username}` }) + // NIP-57: ensure zap support fields are present for wallet compatibility + if (!lnurlPayInfo.allowsNostr || !lnurlPayInfo.nostrPubkey) { + this.ctx.log('warn', `LNURL-pay response for ${username} missing zap fields (allowsNostr=${lnurlPayInfo.allowsNostr}, nostrPubkey=${!!lnurlPayInfo.nostrPubkey}). Zaps will not work.`) + } + return { status: 200, body: lnurlPayInfo,