Compare commits

...

5 commits

Author SHA1 Message Date
Patrick Mulligan
17727d3e31 fix(nip05): add redirect prevention docs and zap field validation
Some checks are pending
Docker Compose Actions Workflow / test (push) Waiting to run
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) <noreply@anthropic.com>
2026-04-01 13:25:19 -04:00
Patrick Mulligan
53945d7dcc 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) <noreply@anthropic.com>
2026-04-01 13:25:06 -04:00
Patrick Mulligan
a7fb92e26d 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 <noreply@anthropic.com>
2026-04-01 13:24:45 -04:00
Patrick Mulligan
8f38622395 feat(extensions): add NIP-05 identity extension
Implements Nostr NIP-05 for human-readable identity verification:
- Username claiming and management (username@domain)
- /.well-known/nostr.json endpoint per spec
- Optional relay hints in JSON response
- Admin controls for identity management

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-04-01 13:24:45 -04:00
Patrick Mulligan
5cc7f3998c fix(extensions): add HTTP route types and getHttpRoutes to Extension interface
Some checks are pending
Docker Compose Actions Workflow / test (push) Waiting to run
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) <noreply@anthropic.com>
2026-04-01 13:24:22 -04:00
5 changed files with 1025 additions and 0 deletions

View file

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

View file

@ -0,0 +1,460 @@
/**
* 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 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<Nip05Config>): UsernameValidation {
if (!username) {
return { valid: false, error: 'Username is required' }
}
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)` }
}
if (normalized.length > config.max_username_length) {
return { valid: false, error: `Username must be at most ${config.max_username_length} characters` }
}
// 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
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
}

View file

@ -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<string, string>
query: Record<string, string>
headers: Record<string, string>
body?: any
}
export interface HttpResponse {
status: number
body: any
headers?: Record<string, string>
}
export interface HttpRoute {
method: 'GET' | 'POST'
path: string
handler: (req: HttpRequest) => Promise<HttpResponse>
}
/**
* Extension interface - what extensions must implement
*/
@ -217,6 +242,12 @@ export interface Extension {
* Return true if extension is healthy
*/
healthCheck?(): Promise<boolean>
/**
* Get HTTP routes exposed by this extension
* The main HTTP server will mount these routes
*/
getHttpRoutes?(): HttpRoute[]
}
/**