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>
This commit is contained in:
Patrick Mulligan 2026-02-13 14:27:04 -05:00
parent 68c71599f8
commit e18fe9f83a
4 changed files with 902 additions and 0 deletions

View file

@ -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<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)
}
]
}
/**
* 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': '*'
}
}
}
}
}
// 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
}