Revert "feat(extensions): add NIP-05 identity extension"
Some checks are pending
Docker Compose Actions Workflow / test (push) Waiting to run
Some checks are pending
Docker Compose Actions Workflow / test (push) Waiting to run
This reverts commit e0bf3ba8ff.
This commit is contained in:
parent
e0bf3ba8ff
commit
2b14b06fd8
4 changed files with 0 additions and 902 deletions
|
|
@ -1,227 +0,0 @@
|
||||||
/**
|
|
||||||
* NIP-05 Extension for Lightning.Pub
|
|
||||||
*
|
|
||||||
* Implements Nostr NIP-05: Mapping Nostr keys to DNS-based internet identifiers
|
|
||||||
* Allows users to claim human-readable addresses like alice@domain.com
|
|
||||||
*
|
|
||||||
* Features:
|
|
||||||
* - Username claiming and management
|
|
||||||
* - .well-known/nostr.json endpoint
|
|
||||||
* - Optional relay hints
|
|
||||||
* - Admin controls for identity management
|
|
||||||
*/
|
|
||||||
|
|
||||||
import {
|
|
||||||
Extension,
|
|
||||||
ExtensionInfo,
|
|
||||||
ExtensionContext,
|
|
||||||
ExtensionDatabase,
|
|
||||||
HttpRoute,
|
|
||||||
HttpRequest,
|
|
||||||
HttpResponse
|
|
||||||
} from '../types.js'
|
|
||||||
import { runMigrations } from './migrations.js'
|
|
||||||
import { Nip05Manager } from './managers/nip05Manager.js'
|
|
||||||
import {
|
|
||||||
ClaimUsernameRequest,
|
|
||||||
UpdateRelaysRequest,
|
|
||||||
Nip05Config
|
|
||||||
} from './types.js'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* NIP-05 Extension
|
|
||||||
*/
|
|
||||||
export default class Nip05Extension implements Extension {
|
|
||||||
readonly info: ExtensionInfo = {
|
|
||||||
id: 'nip05',
|
|
||||||
name: 'NIP-05 Identity',
|
|
||||||
version: '1.0.0',
|
|
||||||
description: 'Human-readable Nostr identities (username@domain)',
|
|
||||||
author: 'Lightning.Pub',
|
|
||||||
minPubVersion: '1.0.0'
|
|
||||||
}
|
|
||||||
|
|
||||||
private manager!: Nip05Manager
|
|
||||||
private ctx!: ExtensionContext
|
|
||||||
private config: Nip05Config = {}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Initialize the extension
|
|
||||||
*/
|
|
||||||
async initialize(ctx: ExtensionContext, db: ExtensionDatabase): Promise<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'
|
|
||||||
|
|
@ -1,452 +0,0 @@
|
||||||
/**
|
|
||||||
* NIP-05 Identity Manager
|
|
||||||
*
|
|
||||||
* Handles username claiming, lookup, and .well-known/nostr.json responses
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { ExtensionContext, ExtensionDatabase } from '../../types.js'
|
|
||||||
import {
|
|
||||||
Nip05Identity,
|
|
||||||
Nip05IdentityRow,
|
|
||||||
Nip05JsonResponse,
|
|
||||||
Nip05Config,
|
|
||||||
UsernameValidation,
|
|
||||||
ClaimUsernameRequest,
|
|
||||||
ClaimUsernameResponse,
|
|
||||||
UpdateRelaysRequest,
|
|
||||||
LookupUsernameResponse,
|
|
||||||
GetMyIdentityResponse
|
|
||||||
} from '../types.js'
|
|
||||||
import crypto from 'crypto'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Default configuration
|
|
||||||
*/
|
|
||||||
const DEFAULT_CONFIG: Required<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]
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,93 +0,0 @@
|
||||||
/**
|
|
||||||
* NIP-05 Extension Database Migrations
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { ExtensionDatabase } from '../types.js'
|
|
||||||
|
|
||||||
export interface Migration {
|
|
||||||
version: number
|
|
||||||
name: string
|
|
||||||
up: (db: ExtensionDatabase) => Promise<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)]
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,130 +0,0 @@
|
||||||
/**
|
|
||||||
* NIP-05 Extension Types
|
|
||||||
*
|
|
||||||
* Implements Nostr NIP-05: Mapping Nostr keys to DNS-based internet identifiers
|
|
||||||
* Allows users to have human-readable addresses like alice@domain.com
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A NIP-05 identity mapping a username to a Nostr public key
|
|
||||||
*/
|
|
||||||
export interface Nip05Identity {
|
|
||||||
id: string
|
|
||||||
application_id: string
|
|
||||||
user_id: string
|
|
||||||
|
|
||||||
/** The human-readable username (lowercase, alphanumeric + underscore) */
|
|
||||||
username: string
|
|
||||||
|
|
||||||
/** The Nostr public key in hex format */
|
|
||||||
pubkey_hex: string
|
|
||||||
|
|
||||||
/** Optional list of relay URLs for this user */
|
|
||||||
relays: string[]
|
|
||||||
|
|
||||||
/** Whether this identity is active */
|
|
||||||
is_active: boolean
|
|
||||||
|
|
||||||
created_at: number
|
|
||||||
updated_at: number
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* NIP-05 JSON response format per the spec
|
|
||||||
* GET /.well-known/nostr.json?name=<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
|
|
||||||
}
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue