From 63723122328c7361374b2752509685723b5aaaff Mon Sep 17 00:00:00 2001 From: archief2910 Date: Sat, 11 Apr 2026 21:06:17 +0530 Subject: [PATCH 1/6] feat(nip05): add types and database migration for NIP-05 verification (#261) --- ...120000_create_nip05_verifications_table.js | 21 ++++++++++++++++ src/@types/nip05.ts | 25 +++++++++++++++++++ src/@types/repositories.ts | 12 +++++++++ src/@types/settings.ts | 12 +++++++++ 4 files changed, 70 insertions(+) create mode 100644 migrations/20260409_120000_create_nip05_verifications_table.js create mode 100644 src/@types/nip05.ts diff --git a/migrations/20260409_120000_create_nip05_verifications_table.js b/migrations/20260409_120000_create_nip05_verifications_table.js new file mode 100644 index 00000000..ec808c0c --- /dev/null +++ b/migrations/20260409_120000_create_nip05_verifications_table.js @@ -0,0 +1,21 @@ +exports.up = function (knex) { + return knex.schema.createTable('nip05_verifications', function (table) { + table.binary('pubkey').notNullable().primary() + table.text('nip05').notNullable() + table.text('domain').notNullable() + table.boolean('is_verified').notNullable().defaultTo(false) + table.timestamp('last_verified_at', { useTz: true }).nullable() + table.timestamp('last_checked_at', { useTz: true }).notNullable().defaultTo(knex.fn.now()) + table.integer('failure_count').notNullable().defaultTo(0) + table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(knex.fn.now()) + table.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(knex.fn.now()) + + table.index(['domain'], 'idx_nip05_verifications_domain') + table.index(['is_verified'], 'idx_nip05_verifications_is_verified') + table.index(['last_checked_at'], 'idx_nip05_verifications_last_checked_at') + }) +} + +exports.down = function (knex) { + return knex.schema.dropTable('nip05_verifications') +} diff --git a/src/@types/nip05.ts b/src/@types/nip05.ts new file mode 100644 index 00000000..56b6fbde --- /dev/null +++ b/src/@types/nip05.ts @@ -0,0 +1,25 @@ +import { Pubkey } from './base' + +export interface Nip05Verification { + pubkey: Pubkey + nip05: string + domain: string + isVerified: boolean + lastVerifiedAt: Date | null + lastCheckedAt: Date + failureCount: number + createdAt: Date + updatedAt: Date +} + +export interface DBNip05Verification { + pubkey: Buffer + nip05: string + domain: string + is_verified: boolean + last_verified_at: Date | null + last_checked_at: Date + failure_count: number + created_at: Date + updated_at: Date +} diff --git a/src/@types/repositories.ts b/src/@types/repositories.ts index 671dfaee..a2b10e1b 100644 --- a/src/@types/repositories.ts +++ b/src/@types/repositories.ts @@ -3,6 +3,7 @@ import { PassThrough } from 'stream' import { DatabaseClient, EventId, Pubkey } from './base' import { DBEvent, Event } from './event' import { Invoice } from './invoice' +import { Nip05Verification } from './nip05' import { SubscriptionFilter } from './subscription' import { User } from './user' @@ -44,3 +45,14 @@ export interface IUserRepository { upsert(user: Partial, client?: DatabaseClient): Promise getBalanceByPubkey(pubkey: Pubkey, client?: DatabaseClient): Promise } + +export interface INip05VerificationRepository { + findByPubkey(pubkey: Pubkey): Promise + upsert(verification: Nip05Verification): Promise + findPendingVerifications( + updateFrequencyMs: number, + maxFailures: number, + limit: number, + ): Promise + deleteByPubkey(pubkey: Pubkey): Promise +} diff --git a/src/@types/settings.ts b/src/@types/settings.ts index a8121f7b..bfd00133 100644 --- a/src/@types/settings.ts +++ b/src/@types/settings.ts @@ -213,6 +213,17 @@ export interface Mirroring { static?: Mirror[] } +export type Nip05Mode = 'enabled' | 'passive' | 'disabled' + +export interface Nip05Settings { + mode: Nip05Mode + verifyExpiration: number + verifyUpdateFrequency: number + maxConsecutiveFailures: number + domainWhitelist?: string[] + domainBlacklist?: string[] +} + export interface Settings { info: Info payments?: Payments @@ -221,4 +232,5 @@ export interface Settings { workers?: Worker limits?: Limits mirroring?: Mirroring + nip05?: Nip05Settings } From a3bfbca49f0cea45c4bef3f86a30fa61acf51360 Mon Sep 17 00:00:00 2001 From: archief2910 Date: Sat, 11 Apr 2026 21:08:06 +0530 Subject: [PATCH 2/6] feat(nip05): add NIP-05 parsing, verification, and domain filtering utils (#261) --- src/utils/nip05.ts | 122 +++++++++++++++++++++++ test/unit/utils/nip05.spec.ts | 178 ++++++++++++++++++++++++++++++++++ 2 files changed, 300 insertions(+) create mode 100644 src/utils/nip05.ts create mode 100644 test/unit/utils/nip05.spec.ts diff --git a/src/utils/nip05.ts b/src/utils/nip05.ts new file mode 100644 index 00000000..02706973 --- /dev/null +++ b/src/utils/nip05.ts @@ -0,0 +1,122 @@ +import axios from 'axios' + +import { createLogger } from '../factories/logger-factory' +import { Event } from '../@types/event' +import { EventKinds } from '../constants/base' + +const debug = createLogger('nip05') + +const VERIFICATION_TIMEOUT_MS = 10000 +const DOMAIN_REGEX = /^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?)*\.[a-zA-Z]{2,}$/ + +interface Nip05ParsedIdentifier { + localPart: string + domain: string +} + +export function parseNip05Identifier(nip05: string): Nip05ParsedIdentifier | undefined { + if (!nip05 || typeof nip05 !== 'string') { + return undefined + } + + const atIndex = nip05.lastIndexOf('@') + if (atIndex <= 0 || atIndex === nip05.length - 1) { + return undefined + } + + const localPart = nip05.substring(0, atIndex) + const domain = nip05.substring(atIndex + 1) + + if (!localPart || !domain || !DOMAIN_REGEX.test(domain)) { + return undefined + } + + return { + localPart: localPart.toLowerCase(), + domain: domain.toLowerCase(), + } +} + +export function extractNip05FromEvent(event: Event): string | undefined { + if (event.kind !== EventKinds.SET_METADATA) { + return undefined + } + + try { + const metadata = JSON.parse(event.content) + if (metadata && typeof metadata.nip05 === 'string' && metadata.nip05.length > 0) { + return metadata.nip05 + } + } catch { + debug('failed to parse metadata content for event %s', event.id) + } + + return undefined +} + +export async function verifyNip05Identifier( + nip05: string, + pubkey: string, +): Promise { + const parsed = parseNip05Identifier(nip05) + if (!parsed) { + return false + } + + const { localPart, domain } = parsed + const url = `https://${domain}/.well-known/nostr.json?name=${encodeURIComponent(localPart)}` + + try { + debug('verifying %s for pubkey %s via %s', nip05, pubkey, url) + + const response = await axios.get(url, { + timeout: VERIFICATION_TIMEOUT_MS, + headers: { 'Accept': 'application/json' }, + validateStatus: (status) => status === 200, + }) + + const { data } = response + + if (!data || typeof data !== 'object' || !data.names || typeof data.names !== 'object') { + debug('malformed response from %s', url) + return false + } + + const registeredPubkey = data.names[localPart] + if (typeof registeredPubkey !== 'string') { + debug('name %s not found in response from %s', localPart, domain) + return false + } + + const verified = registeredPubkey.toLowerCase() === pubkey.toLowerCase() + debug('verification result for %s: %s', nip05, verified ? 'verified' : 'pubkey mismatch') + + return verified + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error) + debug('verification request failed for %s: %s', nip05, message) + return false + } +} + +export function isDomainAllowed( + domain: string, + whitelist?: string[], + blacklist?: string[], +): boolean { + const lowerDomain = domain.toLowerCase() + + if (Array.isArray(blacklist) && blacklist.length > 0) { + if (blacklist.some((d) => lowerDomain === d.toLowerCase())) { + return false + } + } + + if (Array.isArray(whitelist) && whitelist.length > 0) { + if (!whitelist.some((d) => lowerDomain === d.toLowerCase())) { + return false + } + } + + return true +} diff --git a/test/unit/utils/nip05.spec.ts b/test/unit/utils/nip05.spec.ts new file mode 100644 index 00000000..132578d6 --- /dev/null +++ b/test/unit/utils/nip05.spec.ts @@ -0,0 +1,178 @@ +import chai from 'chai' + +import { extractNip05FromEvent, isDomainAllowed, parseNip05Identifier } from '../../../src/utils/nip05' +import { Event } from '../../../src/@types/event' +import { EventKinds } from '../../../src/constants/base' + +const { expect } = chai + +describe('NIP-05 utils', () => { + describe('parseNip05Identifier', () => { + it('returns parsed identifier for valid input', () => { + const result = parseNip05Identifier('user@example.com') + expect(result).to.deep.equal({ localPart: 'user', domain: 'example.com' }) + }) + + it('handles underscores in local part', () => { + const result = parseNip05Identifier('_@example.com') + expect(result).to.deep.equal({ localPart: '_', domain: 'example.com' }) + }) + + it('lowercases domain and local part', () => { + const result = parseNip05Identifier('User@Example.COM') + expect(result).to.deep.equal({ localPart: 'user', domain: 'example.com' }) + }) + + it('handles subdomains', () => { + const result = parseNip05Identifier('alice@relay.example.co.uk') + expect(result).to.deep.equal({ localPart: 'alice', domain: 'relay.example.co.uk' }) + }) + + it('returns undefined for empty string', () => { + expect(parseNip05Identifier('')).to.be.undefined + }) + + it('returns undefined for null input', () => { + expect(parseNip05Identifier(null as any)).to.be.undefined + }) + + it('returns undefined for non-string input', () => { + expect(parseNip05Identifier(123 as any)).to.be.undefined + }) + + it('returns undefined for missing @', () => { + expect(parseNip05Identifier('userexample.com')).to.be.undefined + }) + + it('returns undefined for missing local part', () => { + expect(parseNip05Identifier('@example.com')).to.be.undefined + }) + + it('returns undefined for missing domain', () => { + expect(parseNip05Identifier('user@')).to.be.undefined + }) + + it('returns undefined for invalid domain', () => { + expect(parseNip05Identifier('user@.com')).to.be.undefined + }) + + it('returns undefined for domain without TLD', () => { + expect(parseNip05Identifier('user@localhost')).to.be.undefined + }) + }) + + describe('extractNip05FromEvent', () => { + it('extracts nip05 from kind 0 event', () => { + const event: Event = { + id: 'a'.repeat(64), + pubkey: 'b'.repeat(64), + created_at: 1234567890, + kind: EventKinds.SET_METADATA, + tags: [], + content: JSON.stringify({ name: 'alice', nip05: 'alice@example.com' }), + sig: 'c'.repeat(128), + } + expect(extractNip05FromEvent(event)).to.equal('alice@example.com') + }) + + it('returns undefined for non-kind-0 event', () => { + const event: Event = { + id: 'a'.repeat(64), + pubkey: 'b'.repeat(64), + created_at: 1234567890, + kind: EventKinds.TEXT_NOTE, + tags: [], + content: JSON.stringify({ nip05: 'alice@example.com' }), + sig: 'c'.repeat(128), + } + expect(extractNip05FromEvent(event)).to.be.undefined + }) + + it('returns undefined when nip05 is not in content', () => { + const event: Event = { + id: 'a'.repeat(64), + pubkey: 'b'.repeat(64), + created_at: 1234567890, + kind: EventKinds.SET_METADATA, + tags: [], + content: JSON.stringify({ name: 'alice' }), + sig: 'c'.repeat(128), + } + expect(extractNip05FromEvent(event)).to.be.undefined + }) + + it('returns undefined for invalid JSON content', () => { + const event: Event = { + id: 'a'.repeat(64), + pubkey: 'b'.repeat(64), + created_at: 1234567890, + kind: EventKinds.SET_METADATA, + tags: [], + content: 'not json', + sig: 'c'.repeat(128), + } + expect(extractNip05FromEvent(event)).to.be.undefined + }) + + it('returns undefined when nip05 is empty string', () => { + const event: Event = { + id: 'a'.repeat(64), + pubkey: 'b'.repeat(64), + created_at: 1234567890, + kind: EventKinds.SET_METADATA, + tags: [], + content: JSON.stringify({ nip05: '' }), + sig: 'c'.repeat(128), + } + expect(extractNip05FromEvent(event)).to.be.undefined + }) + + it('returns undefined when nip05 is not a string', () => { + const event: Event = { + id: 'a'.repeat(64), + pubkey: 'b'.repeat(64), + created_at: 1234567890, + kind: EventKinds.SET_METADATA, + tags: [], + content: JSON.stringify({ nip05: 42 }), + sig: 'c'.repeat(128), + } + expect(extractNip05FromEvent(event)).to.be.undefined + }) + }) + + describe('isDomainAllowed', () => { + it('returns true with no whitelist or blacklist', () => { + expect(isDomainAllowed('example.com')).to.be.true + }) + + it('returns true with empty whitelist and blacklist', () => { + expect(isDomainAllowed('example.com', [], [])).to.be.true + }) + + it('returns true if domain is in whitelist', () => { + expect(isDomainAllowed('example.com', ['example.com'])).to.be.true + }) + + it('returns false if domain is not in whitelist', () => { + expect(isDomainAllowed('other.com', ['example.com'])).to.be.false + }) + + it('returns false if domain is in blacklist', () => { + expect(isDomainAllowed('spam.com', undefined, ['spam.com'])).to.be.false + }) + + it('returns true if domain is not in blacklist', () => { + expect(isDomainAllowed('example.com', undefined, ['spam.com'])).to.be.true + }) + + it('is case-insensitive', () => { + expect(isDomainAllowed('Example.COM', ['example.com'])).to.be.true + expect(isDomainAllowed('SPAM.com', undefined, ['spam.COM'])).to.be.false + }) + + it('blacklist takes precedence over whitelist', () => { + expect(isDomainAllowed('example.com', ['example.com'], ['example.com'])).to.be.false + }) + }) +}) From be5f3a883c85f391b9416effbb102c8069578bc1 Mon Sep 17 00:00:00 2001 From: archief2910 Date: Sat, 11 Apr 2026 21:09:46 +0530 Subject: [PATCH 3/6] feat(nip05): add NIP-05 verification repository (#261) --- .../nip05-verification-repository.ts | 107 ++++++++++++++++++ 1 file changed, 107 insertions(+) create mode 100644 src/repositories/nip05-verification-repository.ts diff --git a/src/repositories/nip05-verification-repository.ts b/src/repositories/nip05-verification-repository.ts new file mode 100644 index 00000000..46ee2911 --- /dev/null +++ b/src/repositories/nip05-verification-repository.ts @@ -0,0 +1,107 @@ +import { applySpec, pipe, prop } from 'ramda' + +import { DatabaseClient, Pubkey } from '../@types/base' +import { DBNip05Verification, Nip05Verification } from '../@types/nip05' +import { fromBuffer, toBuffer } from '../utils/transform' +import { createLogger } from '../factories/logger-factory' +import { INip05VerificationRepository } from '../@types/repositories' + +const debug = createLogger('nip05-verification-repository') + +const fromDBNip05Verification = applySpec({ + pubkey: pipe(prop('pubkey') as () => Buffer, fromBuffer), + nip05: prop('nip05') as () => string, + domain: prop('domain') as () => string, + isVerified: prop('is_verified') as () => boolean, + lastVerifiedAt: prop('last_verified_at') as () => Date | null, + lastCheckedAt: prop('last_checked_at') as () => Date, + failureCount: prop('failure_count') as () => number, + createdAt: prop('created_at') as () => Date, + updatedAt: prop('updated_at') as () => Date, +}) + +export class Nip05VerificationRepository implements INip05VerificationRepository { + public constructor( + private readonly dbClient: DatabaseClient, + ) {} + + public async findByPubkey(pubkey: Pubkey): Promise { + debug('find by pubkey: %s', pubkey) + + const [row] = await this.dbClient('nip05_verifications') + .where('pubkey', toBuffer(pubkey)) + .select() + + if (!row) { + return undefined + } + + return fromDBNip05Verification(row) + } + + public async upsert(verification: Nip05Verification): Promise { + debug('upsert: %s (%s)', verification.pubkey, verification.nip05) + + const now = new Date() + + const row: DBNip05Verification = { + pubkey: toBuffer(verification.pubkey), + nip05: verification.nip05, + domain: verification.domain, + is_verified: verification.isVerified, + last_verified_at: verification.lastVerifiedAt, + last_checked_at: verification.lastCheckedAt || now, + failure_count: verification.failureCount, + created_at: now, + updated_at: now, + } + + const query = this.dbClient('nip05_verifications') + .insert(row) + .onConflict('pubkey') + .merge({ + nip05: row.nip05, + domain: row.domain, + is_verified: row.is_verified, + last_verified_at: row.last_verified_at, + last_checked_at: row.last_checked_at, + failure_count: row.failure_count, + updated_at: now, + }) + + return { + then: ( + onfulfilled: (value: number) => T1 | PromiseLike, + onrejected: (reason: any) => T2 | PromiseLike, + ) => query.then(prop('rowCount') as () => number).then(onfulfilled, onrejected), + catch: (onrejected: (reason: any) => T | PromiseLike) => query.catch(onrejected), + toString: (): string => query.toString(), + } as Promise + } + + public async findPendingVerifications( + updateFrequencyMs: number, + maxFailures: number, + limit: number, + ): Promise { + debug('find pending verifications (frequency: %dms, maxFailures: %d)', updateFrequencyMs, maxFailures) + + const cutoff = new Date(Date.now() - updateFrequencyMs) + + const rows = await this.dbClient('nip05_verifications') + .where('last_checked_at', '<', cutoff) + .andWhere('failure_count', '<', maxFailures) + .orderBy('last_checked_at', 'asc') + .limit(limit) + + return rows.map(fromDBNip05Verification) + } + + public async deleteByPubkey(pubkey: Pubkey): Promise { + debug('delete by pubkey: %s', pubkey) + + return this.dbClient('nip05_verifications') + .where('pubkey', toBuffer(pubkey)) + .delete() + } +} From 64991c7140f23db860c1bac31a4076ec0750ca98 Mon Sep 17 00:00:00 2001 From: archief2910 Date: Sat, 11 Apr 2026 21:12:38 +0530 Subject: [PATCH 4/6] feat(nip05): integrate NIP-05 verification into event handler (#261) --- src/handlers/event-message-handler.ts | 102 +++++- .../handlers/event-message-handler.spec.ts | 331 +++++++++++++++++- 2 files changed, 426 insertions(+), 7 deletions(-) diff --git a/src/handlers/event-message-handler.ts b/src/handlers/event-message-handler.ts index b3ecf7fc..dceb3bc7 100644 --- a/src/handlers/event-message-handler.ts +++ b/src/handlers/event-message-handler.ts @@ -1,16 +1,17 @@ +import { ContextMetadataKey, EventExpirationTimeMetadataKey, EventKinds } from '../constants/base' import { Event, ExpiringEvent } from '../@types/event' import { EventRateLimit, FeeSchedule, Settings } from '../@types/settings' +import { extractNip05FromEvent, isDomainAllowed, parseNip05Identifier, verifyNip05Identifier } from '../utils/nip05' import { getEventExpiration, getEventProofOfWork, getPubkeyProofOfWork, getPublicKey, getRelayPrivateKey, isEventIdValid, isEventKindOrRangeMatch, isEventSignatureValid, isExpiredEvent } from '../utils/event' import { IEventStrategy, IMessageHandler } from '../@types/message-handlers' -import { ContextMetadataKey } from '../constants/base' +import { INip05VerificationRepository, IUserRepository } from '../@types/repositories' import { createCommandResult } from '../utils/messages' import { createLogger } from '../factories/logger-factory' -import { EventExpirationTimeMetadataKey } from '../constants/base' import { Factory } from '../@types/base' import { IncomingEventMessage } from '../@types/messages' import { IRateLimiter } from '../@types/utils' -import { IUserRepository } from '../@types/repositories' import { IWebSocketAdapter } from '../@types/adapters' +import { Nip05Verification } from '../@types/nip05' import { WebSocketAdapterEvent } from '../constants/adapter' const debug = createLogger('event-message-handler') @@ -22,6 +23,7 @@ export class EventMessageHandler implements IMessageHandler { protected readonly userRepository: IUserRepository, private readonly settings: () => Settings, private readonly slidingWindowRateLimiter: Factory, + private readonly nip05VerificationRepository: INip05VerificationRepository, ) {} public async handleMessage(message: IncomingEventMessage): Promise { @@ -64,6 +66,13 @@ export class EventMessageHandler implements IMessageHandler { return } + reason = await this.checkNip05Verification(event) + if (reason) { + debug('event %s rejected: %s', event.id, reason) + this.webSocket.emit(WebSocketAdapterEvent.Message, createCommandResult(event.id, false, reason)) + return + } + const strategy = this.strategyFactory([event, this.webSocket]) if (typeof strategy?.execute !== 'function') { @@ -73,6 +82,7 @@ export class EventMessageHandler implements IMessageHandler { try { await strategy.execute(event) + this.processNip05Metadata(event) } catch (error) { console.error('error handling message', message, error) this.webSocket.emit(WebSocketAdapterEvent.Message, createCommandResult(event.id, false, 'error: unable to process event')) @@ -301,4 +311,90 @@ export class EventMessageHandler implements IMessageHandler { return expiringEvent } + + protected async checkNip05Verification(event: Event): Promise { + const nip05Settings = this.settings().nip05 + if (!nip05Settings || nip05Settings.mode === 'disabled') { + return + } + + if (this.getRelayPublicKey() === event.pubkey) { + return + } + + if (event.kind === EventKinds.SET_METADATA) { + return + } + + if (nip05Settings.mode !== 'enabled') { + return + } + + const verification = await this.nip05VerificationRepository.findByPubkey(event.pubkey) + + if (!verification || !verification.isVerified) { + return 'blocked: NIP-05 verification required' + } + + const expirationMs = nip05Settings.verifyExpiration ?? 604800000 + if (verification.lastVerifiedAt) { + const elapsed = Date.now() - verification.lastVerifiedAt.getTime() + if (elapsed > expirationMs) { + return 'blocked: NIP-05 verification expired' + } + } + + if (!isDomainAllowed(verification.domain, nip05Settings.domainWhitelist, nip05Settings.domainBlacklist)) { + return 'blocked: NIP-05 domain not allowed' + } + } + + protected processNip05Metadata(event: Event): void { + const nip05Settings = this.settings().nip05 + if (!nip05Settings || nip05Settings.mode === 'disabled') { + return + } + + if (event.kind !== EventKinds.SET_METADATA) { + return + } + + const nip05Identifier = extractNip05FromEvent(event) + if (!nip05Identifier) { + this.nip05VerificationRepository.deleteByPubkey(event.pubkey).catch((error) => { + debug('failed to remove NIP-05 verification for %s: %o', event.pubkey, error) + }) + return + } + + const parsed = parseNip05Identifier(nip05Identifier) + if (!parsed) { + return + } + + if (!isDomainAllowed(parsed.domain, nip05Settings.domainWhitelist, nip05Settings.domainBlacklist)) { + debug('NIP-05 domain %s not allowed for %s', parsed.domain, event.pubkey) + return + } + + verifyNip05Identifier(nip05Identifier, event.pubkey) + .then((verified) => { + const now = new Date() + const verification: Nip05Verification = { + pubkey: event.pubkey, + nip05: nip05Identifier, + domain: parsed.domain, + isVerified: verified, + lastVerifiedAt: verified ? now : null, + lastCheckedAt: now, + failureCount: verified ? 0 : 1, + createdAt: now, + updatedAt: now, + } + return this.nip05VerificationRepository.upsert(verification) + }) + .catch((error) => { + debug('NIP-05 verification failed for %s: %o', event.pubkey, error) + }) + } } diff --git a/test/unit/handlers/event-message-handler.spec.ts b/test/unit/handlers/event-message-handler.spec.ts index c28d5e0a..3a1ee738 100644 --- a/test/unit/handlers/event-message-handler.spec.ts +++ b/test/unit/handlers/event-message-handler.spec.ts @@ -17,6 +17,8 @@ import { IUserRepository } from '../../../src/@types/repositories' import { IWebSocketAdapter } from '../../../src/@types/adapters' import { WebSocketAdapterEvent } from '../../../src/constants/adapter' +import * as nip05Utils from '../../../src/utils/nip05' + const { expect } = chai describe('EventMessageHandler', () => { @@ -85,7 +87,8 @@ describe('EventMessageHandler', () => { () => ({ info: { relay_url: 'relay_url' }, }) as any, - () => ({ hit: async () => false }) + () => ({ hit: async () => false }), + {} as any, ) }) @@ -244,7 +247,8 @@ describe('EventMessageHandler', () => { () => null, userRepository, () => settings, - () => ({ hit: async () => false }) + () => ({ hit: async () => false }), + {} as any, ) }) @@ -719,7 +723,8 @@ describe('EventMessageHandler', () => { () => null, userRepository, () => settings, - () => ({ hit: rateLimiterHitStub }) + () => ({ hit: rateLimiterHitStub }), + {} as any, ) }) @@ -986,7 +991,8 @@ describe('EventMessageHandler', () => { () => null, userRepository, () => settings, - () => ({ hit: async () => false }) + () => ({ hit: async () => false }), + {} as any, ) }) @@ -1089,4 +1095,321 @@ describe('EventMessageHandler', () => { return expect((handler as any).isUserAdmitted(event)).to.eventually.be.undefined }) }) + + describe('checkNip05Verification', () => { + let settings: Settings + let nip05VerificationRepository: any + let getRelayPublicKeyStub: Sinon.SinonStub + + beforeEach(() => { + settings = { + info: { + relay_url: 'relay_url', + }, + nip05: { + mode: 'enabled', + verifyExpiration: 86400000, + verifyUpdateFrequency: 3600000, + maxConsecutiveFailures: 10, + domainWhitelist: [], + domainBlacklist: [], + }, + } as any + event = { + content: 'hello', + created_at: 1665546189, + id: 'f'.repeat(64), + kind: 1, + pubkey: 'f'.repeat(64), + sig: 'f'.repeat(128), + tags: [], + } + nip05VerificationRepository = { + findByPubkey: sandbox.stub(), + upsert: sandbox.stub(), + deleteByPubkey: sandbox.stub(), + findPendingVerifications: sandbox.stub(), + } + getRelayPublicKeyStub = sandbox.stub(EventMessageHandler.prototype, 'getRelayPublicKey' as any) + handler = new EventMessageHandler( + {} as any, + () => null, + userRepository, + () => settings, + () => ({ hit: async () => false }), + nip05VerificationRepository, + ) + }) + + it('returns undefined if nip05 settings are not set', async () => { + settings.nip05 = undefined + + return expect((handler as any).checkNip05Verification(event)).to.eventually.be.undefined + }) + + it('returns undefined if nip05 mode is disabled', async () => { + settings.nip05.mode = 'disabled' + + return expect((handler as any).checkNip05Verification(event)).to.eventually.be.undefined + }) + + it('returns undefined if nip05 mode is passive', async () => { + settings.nip05.mode = 'passive' + + return expect((handler as any).checkNip05Verification(event)).to.eventually.be.undefined + }) + + it('returns undefined for kind 0 events (SET_METADATA)', async () => { + event.kind = 0 + + return expect((handler as any).checkNip05Verification(event)).to.eventually.be.undefined + }) + + it('returns undefined if event pubkey equals relay public key', async () => { + getRelayPublicKeyStub.returns(event.pubkey) + + return expect((handler as any).checkNip05Verification(event)).to.eventually.be.undefined + }) + + it('returns reason if no verification found for pubkey', async () => { + nip05VerificationRepository.findByPubkey.resolves(undefined) + + return expect((handler as any).checkNip05Verification(event)) + .to.eventually.equal('blocked: NIP-05 verification required') + }) + + it('returns reason if verification is not verified', async () => { + nip05VerificationRepository.findByPubkey.resolves({ + isVerified: false, + domain: 'example.com', + }) + + return expect((handler as any).checkNip05Verification(event)) + .to.eventually.equal('blocked: NIP-05 verification required') + }) + + it('returns reason if verification is expired', async () => { + const expired = new Date(Date.now() - 86400001) + nip05VerificationRepository.findByPubkey.resolves({ + isVerified: true, + lastVerifiedAt: expired, + domain: 'example.com', + }) + + return expect((handler as any).checkNip05Verification(event)) + .to.eventually.equal('blocked: NIP-05 verification expired') + }) + + it('returns undefined if verification is valid and not expired', async () => { + const recent = new Date(Date.now() - 1000) + nip05VerificationRepository.findByPubkey.resolves({ + isVerified: true, + lastVerifiedAt: recent, + domain: 'example.com', + }) + + return expect((handler as any).checkNip05Verification(event)).to.eventually.be.undefined + }) + + it('returns reason if domain is blacklisted', async () => { + settings.nip05.domainBlacklist = ['spam.com'] + const recent = new Date(Date.now() - 1000) + nip05VerificationRepository.findByPubkey.resolves({ + isVerified: true, + lastVerifiedAt: recent, + domain: 'spam.com', + }) + + return expect((handler as any).checkNip05Verification(event)) + .to.eventually.equal('blocked: NIP-05 domain not allowed') + }) + + it('returns reason if domain is not in whitelist', async () => { + settings.nip05.domainWhitelist = ['allowed.com'] + const recent = new Date(Date.now() - 1000) + nip05VerificationRepository.findByPubkey.resolves({ + isVerified: true, + lastVerifiedAt: recent, + domain: 'other.com', + }) + + return expect((handler as any).checkNip05Verification(event)) + .to.eventually.equal('blocked: NIP-05 domain not allowed') + }) + + it('returns undefined if domain is in whitelist', async () => { + settings.nip05.domainWhitelist = ['allowed.com'] + const recent = new Date(Date.now() - 1000) + nip05VerificationRepository.findByPubkey.resolves({ + isVerified: true, + lastVerifiedAt: recent, + domain: 'allowed.com', + }) + + return expect((handler as any).checkNip05Verification(event)).to.eventually.be.undefined + }) + }) + + describe('processNip05Metadata', () => { + let settings: Settings + let nip05VerificationRepository: any + let verifyStub: Sinon.SinonStub + + beforeEach(() => { + settings = { + info: { + relay_url: 'relay_url', + }, + nip05: { + mode: 'enabled', + verifyExpiration: 86400000, + verifyUpdateFrequency: 3600000, + maxConsecutiveFailures: 10, + domainWhitelist: [], + domainBlacklist: [], + }, + } as any + nip05VerificationRepository = { + findByPubkey: sandbox.stub(), + upsert: sandbox.stub().resolves(1), + deleteByPubkey: sandbox.stub().resolves(1), + findPendingVerifications: sandbox.stub(), + } + verifyStub = sandbox.stub(nip05Utils, 'verifyNip05Identifier') + handler = new EventMessageHandler( + {} as any, + () => null, + userRepository, + () => settings, + () => ({ hit: async () => false }), + nip05VerificationRepository, + ) + }) + + it('does nothing when nip05 settings are undefined', async () => { + settings.nip05 = undefined + event.kind = EventKinds.SET_METADATA + event.content = JSON.stringify({ nip05: 'alice@example.com' }) + + ;(handler as any).processNip05Metadata(event) + await new Promise((resolve) => setTimeout(resolve, 10)) + + expect(verifyStub).not.to.have.been.called + }) + + it('does nothing when nip05 mode is disabled', async () => { + settings.nip05.mode = 'disabled' + event.kind = EventKinds.SET_METADATA + event.content = JSON.stringify({ nip05: 'alice@example.com' }) + + ;(handler as any).processNip05Metadata(event) + await new Promise((resolve) => setTimeout(resolve, 10)) + + expect(verifyStub).not.to.have.been.called + }) + + it('does nothing for non-kind-0 events', async () => { + event.kind = EventKinds.TEXT_NOTE + event.content = JSON.stringify({ nip05: 'alice@example.com' }) + + ;(handler as any).processNip05Metadata(event) + await new Promise((resolve) => setTimeout(resolve, 10)) + + expect(verifyStub).not.to.have.been.called + }) + + it('deletes verification when kind-0 has no nip05 in content', async () => { + event.kind = EventKinds.SET_METADATA + event.content = JSON.stringify({ name: 'alice' }) + + ;(handler as any).processNip05Metadata(event) + await new Promise((resolve) => setTimeout(resolve, 10)) + + expect(nip05VerificationRepository.deleteByPubkey).to.have.been.calledOnceWithExactly(event.pubkey) + expect(verifyStub).not.to.have.been.called + }) + + it('does nothing when nip05 identifier is unparseable', async () => { + event.kind = EventKinds.SET_METADATA + event.content = JSON.stringify({ nip05: 'invalid-no-at-sign' }) + + ;(handler as any).processNip05Metadata(event) + await new Promise((resolve) => setTimeout(resolve, 10)) + + expect(verifyStub).not.to.have.been.called + expect(nip05VerificationRepository.deleteByPubkey).not.to.have.been.called + }) + + it('does nothing when domain is not allowed', async () => { + settings.nip05.domainBlacklist = ['blocked.com'] + event.kind = EventKinds.SET_METADATA + event.content = JSON.stringify({ nip05: 'alice@blocked.com' }) + + ;(handler as any).processNip05Metadata(event) + await new Promise((resolve) => setTimeout(resolve, 10)) + + expect(verifyStub).not.to.have.been.called + }) + + it('verifies and upserts on successful verification', async () => { + verifyStub.resolves(true) + event.kind = EventKinds.SET_METADATA + event.content = JSON.stringify({ nip05: 'alice@example.com' }) + + ;(handler as any).processNip05Metadata(event) + await new Promise((resolve) => setTimeout(resolve, 10)) + + expect(verifyStub).to.have.been.calledOnceWithExactly('alice@example.com', event.pubkey) + expect(nip05VerificationRepository.upsert).to.have.been.calledOnce + + const upsertArg = nip05VerificationRepository.upsert.firstCall.args[0] + expect(upsertArg.pubkey).to.equal(event.pubkey) + expect(upsertArg.nip05).to.equal('alice@example.com') + expect(upsertArg.domain).to.equal('example.com') + expect(upsertArg.isVerified).to.be.true + expect(upsertArg.failureCount).to.equal(0) + expect(upsertArg.lastVerifiedAt).to.be.an.instanceOf(Date) + }) + + it('upserts with failure state on failed verification', async () => { + verifyStub.resolves(false) + event.kind = EventKinds.SET_METADATA + event.content = JSON.stringify({ nip05: 'alice@example.com' }) + + ;(handler as any).processNip05Metadata(event) + await new Promise((resolve) => setTimeout(resolve, 10)) + + expect(verifyStub).to.have.been.calledOnce + expect(nip05VerificationRepository.upsert).to.have.been.calledOnce + + const upsertArg = nip05VerificationRepository.upsert.firstCall.args[0] + expect(upsertArg.isVerified).to.be.false + expect(upsertArg.failureCount).to.equal(1) + expect(upsertArg.lastVerifiedAt).to.be.null + }) + + it('handles verification errors gracefully', async () => { + verifyStub.rejects(new Error('network error')) + event.kind = EventKinds.SET_METADATA + event.content = JSON.stringify({ nip05: 'alice@example.com' }) + + ;(handler as any).processNip05Metadata(event) + await new Promise((resolve) => setTimeout(resolve, 10)) + + expect(nip05VerificationRepository.upsert).not.to.have.been.called + }) + + it('works correctly in passive mode', async () => { + settings.nip05.mode = 'passive' + verifyStub.resolves(true) + event.kind = EventKinds.SET_METADATA + event.content = JSON.stringify({ nip05: 'alice@example.com' }) + + ;(handler as any).processNip05Metadata(event) + await new Promise((resolve) => setTimeout(resolve, 10)) + + expect(verifyStub).to.have.been.calledOnce + expect(nip05VerificationRepository.upsert).to.have.been.calledOnce + }) + }) }) From 8695b069bfbd1f480515620ca4194e53ce73ee34 Mon Sep 17 00:00:00 2001 From: archief2910 Date: Sat, 11 Apr 2026 21:16:35 +0530 Subject: [PATCH 5/6] feat(nip05): wire NIP-05 through factories and add background re-verification (#261) --- src/app/maintenance-worker.ts | 54 ++++ src/factories/maintenance-worker-factory.ts | 6 +- src/factories/message-handler-factory.ts | 4 +- src/factories/websocket-adapter-factory.ts | 5 +- src/factories/worker-factory.ts | 4 +- test/unit/app/maintenance-worker.spec.ts | 244 ++++++++++++++++++ .../factories/message-handler-factory.spec.ts | 6 +- .../websocket-adapter-factory.spec.ts | 8 +- 8 files changed, 322 insertions(+), 9 deletions(-) create mode 100644 test/unit/app/maintenance-worker.spec.ts diff --git a/src/app/maintenance-worker.ts b/src/app/maintenance-worker.ts index a4c5ce7b..99a8fbcd 100644 --- a/src/app/maintenance-worker.ts +++ b/src/app/maintenance-worker.ts @@ -3,11 +3,15 @@ import { IRunnable } from '../@types/base' import { createLogger } from '../factories/logger-factory' import { delayMs } from '../utils/misc' +import { INip05VerificationRepository } from '../@types/repositories' import { InvoiceStatus } from '../@types/invoice' import { IPaymentsService } from '../@types/services' +import { Nip05Verification } from '../@types/nip05' import { Settings } from '../@types/settings' +import { verifyNip05Identifier } from '../utils/nip05' const UPDATE_INVOICE_INTERVAL = 60000 +const NIP05_REVERIFICATION_BATCH_SIZE = 50 const debug = createLogger('maintenance-worker') @@ -18,6 +22,7 @@ export class MaintenanceWorker implements IRunnable { private readonly process: NodeJS.Process, private readonly paymentsService: IPaymentsService, private readonly settings: () => Settings, + private readonly nip05VerificationRepository: INip05VerificationRepository, ) { this.process .on('SIGINT', this.onExit.bind(this)) @@ -34,6 +39,8 @@ export class MaintenanceWorker implements IRunnable { private async onSchedule(): Promise { const currentSettings = this.settings() + await this.processNip05Reverifications(currentSettings) + if (!path(['payments','enabled'], currentSettings)) { return } @@ -86,6 +93,53 @@ export class MaintenanceWorker implements IRunnable { } } + private async processNip05Reverifications(currentSettings: Settings): Promise { + const nip05Settings = currentSettings.nip05 + if (!nip05Settings || nip05Settings.mode === 'disabled') { + return + } + + try { + const updateFrequency = nip05Settings.verifyUpdateFrequency ?? 86400000 + const maxFailures = nip05Settings.maxConsecutiveFailures ?? 20 + + const pendingVerifications = await this.nip05VerificationRepository.findPendingVerifications( + updateFrequency, + maxFailures, + NIP05_REVERIFICATION_BATCH_SIZE, + ) + + if (!pendingVerifications.length) { + return + } + + debug('found %d NIP-05 verifications to re-check', pendingVerifications.length) + + for (const verification of pendingVerifications) { + try { + const verified = await verifyNip05Identifier(verification.nip05, verification.pubkey) + const now = new Date() + + const updated: Nip05Verification = { + ...verification, + isVerified: verified, + lastVerifiedAt: verified ? now : verification.lastVerifiedAt, + lastCheckedAt: now, + failureCount: verified ? 0 : verification.failureCount + 1, + updatedAt: now, + } + + await this.nip05VerificationRepository.upsert(updated) + await delayMs(200 + Math.floor(Math.random() * 100)) + } catch (error) { + debug('failed to re-verify NIP-05 for %s: %o', verification.pubkey, error) + } + } + } catch (error) { + debug('NIP-05 re-verification batch failed: %o', error) + } + } + private onError(error: Error) { debug('error: %o', error) throw error diff --git a/src/factories/maintenance-worker-factory.ts b/src/factories/maintenance-worker-factory.ts index 5f0f7abc..d5c14222 100644 --- a/src/factories/maintenance-worker-factory.ts +++ b/src/factories/maintenance-worker-factory.ts @@ -1,7 +1,11 @@ import { createPaymentsService } from './payments-service-factory' import { createSettings } from './settings-factory' +import { getMasterDbClient } from '../database/client' import { MaintenanceWorker } from '../app/maintenance-worker' +import { Nip05VerificationRepository } from '../repositories/nip05-verification-repository' export const maintenanceWorkerFactory = () => { - return new MaintenanceWorker(process, createPaymentsService(), createSettings) + const dbClient = getMasterDbClient() + const nip05VerificationRepository = new Nip05VerificationRepository(dbClient) + return new MaintenanceWorker(process, createPaymentsService(), createSettings, nip05VerificationRepository) } diff --git a/src/factories/message-handler-factory.ts b/src/factories/message-handler-factory.ts index b1c11e9c..b47b9a0d 100644 --- a/src/factories/message-handler-factory.ts +++ b/src/factories/message-handler-factory.ts @@ -1,4 +1,4 @@ -import { IEventRepository, IUserRepository } from '../@types/repositories' +import { IEventRepository, INip05VerificationRepository, IUserRepository } from '../@types/repositories' import { IncomingMessage, MessageType } from '../@types/messages' import { createSettings } from './settings-factory' import { EventMessageHandler } from '../handlers/event-message-handler' @@ -11,6 +11,7 @@ import { UnsubscribeMessageHandler } from '../handlers/unsubscribe-message-handl export const messageHandlerFactory = ( eventRepository: IEventRepository, userRepository: IUserRepository, + nip05VerificationRepository: INip05VerificationRepository, ) => ([message, adapter]: [IncomingMessage, IWebSocketAdapter]) => { switch (message[0]) { case MessageType.EVENT: @@ -21,6 +22,7 @@ export const messageHandlerFactory = ( userRepository, createSettings, slidingWindowRateLimiterFactory, + nip05VerificationRepository, ) } case MessageType.REQ: diff --git a/src/factories/websocket-adapter-factory.ts b/src/factories/websocket-adapter-factory.ts index 8d92fa6f..7b3f83df 100644 --- a/src/factories/websocket-adapter-factory.ts +++ b/src/factories/websocket-adapter-factory.ts @@ -1,7 +1,7 @@ import { IncomingMessage } from 'http' import { WebSocket } from 'ws' -import { IEventRepository, IUserRepository } from '../@types/repositories' +import { IEventRepository, INip05VerificationRepository, IUserRepository } from '../@types/repositories' import { createSettings } from './settings-factory' import { IWebSocketServerAdapter } from '../@types/adapters' import { messageHandlerFactory } from './message-handler-factory' @@ -12,12 +12,13 @@ import { WebSocketAdapter } from '../adapters/web-socket-adapter' export const webSocketAdapterFactory = ( eventRepository: IEventRepository, userRepository: IUserRepository, + nip05VerificationRepository: INip05VerificationRepository, ) => ([client, request, webSocketServerAdapter]: [WebSocket, IncomingMessage, IWebSocketServerAdapter]) => new WebSocketAdapter( client, request, webSocketServerAdapter, - messageHandlerFactory(eventRepository, userRepository), + messageHandlerFactory(eventRepository, userRepository, nip05VerificationRepository), slidingWindowRateLimiterFactory, createSettings, ) diff --git a/src/factories/worker-factory.ts b/src/factories/worker-factory.ts index 123e59d2..bd758989 100644 --- a/src/factories/worker-factory.ts +++ b/src/factories/worker-factory.ts @@ -8,6 +8,7 @@ import { AppWorker } from '../app/worker' import { createSettings } from '../factories/settings-factory' import { createWebApp } from './web-app-factory' import { EventRepository } from '../repositories/event-repository' +import { Nip05VerificationRepository } from '../repositories/nip05-verification-repository' import { UserRepository } from '../repositories/user-repository' import { webSocketAdapterFactory } from './websocket-adapter-factory' import { WebSocketServerAdapter } from '../adapters/web-socket-server-adapter' @@ -17,6 +18,7 @@ export const workerFactory = (): AppWorker => { const readReplicaDbClient = getReadReplicaDbClient() const eventRepository = new EventRepository(dbClient, readReplicaDbClient) const userRepository = new UserRepository(dbClient) + const nip05VerificationRepository = new Nip05VerificationRepository(dbClient) const settings = createSettings() @@ -58,7 +60,7 @@ export const workerFactory = (): AppWorker => { const adapter = new WebSocketServerAdapter( server, webSocketServer, - webSocketAdapterFactory(eventRepository, userRepository), + webSocketAdapterFactory(eventRepository, userRepository, nip05VerificationRepository), createSettings, ) diff --git a/test/unit/app/maintenance-worker.spec.ts b/test/unit/app/maintenance-worker.spec.ts new file mode 100644 index 00000000..9c48752f --- /dev/null +++ b/test/unit/app/maintenance-worker.spec.ts @@ -0,0 +1,244 @@ +import chai from 'chai' +import chaiAsPromised from 'chai-as-promised' +import Sinon from 'sinon' +import sinonChai from 'sinon-chai' + +chai.use(sinonChai) +chai.use(chaiAsPromised) + +import { MaintenanceWorker } from '../../../src/app/maintenance-worker' +import { Nip05Verification } from '../../../src/@types/nip05' +import { Settings } from '../../../src/@types/settings' + +import * as nip05Utils from '../../../src/utils/nip05' + +const { expect } = chai + +describe('MaintenanceWorker', () => { + let sandbox: Sinon.SinonSandbox + let worker: MaintenanceWorker + let nip05VerificationRepository: any + let verifyStub: Sinon.SinonStub + let settings: Settings + let mockProcess: any + let paymentsService: any + + beforeEach(() => { + sandbox = Sinon.createSandbox() + + nip05VerificationRepository = { + findByPubkey: sandbox.stub(), + upsert: sandbox.stub().resolves(1), + deleteByPubkey: sandbox.stub(), + findPendingVerifications: sandbox.stub().resolves([]), + } + + verifyStub = sandbox.stub(nip05Utils, 'verifyNip05Identifier') + + settings = { + info: { + relay_url: 'relay_url', + }, + nip05: { + mode: 'enabled', + verifyExpiration: 604800000, + verifyUpdateFrequency: 86400000, + maxConsecutiveFailures: 20, + domainWhitelist: [], + domainBlacklist: [], + }, + } as any + + mockProcess = { + on: sandbox.stub().returnsThis(), + exit: sandbox.stub(), + } + + paymentsService = { + getPendingInvoices: sandbox.stub().resolves([]), + getInvoiceFromPaymentsProcessor: sandbox.stub(), + updateInvoiceStatus: sandbox.stub(), + confirmInvoice: sandbox.stub(), + sendInvoiceUpdateNotification: sandbox.stub(), + } + + worker = new MaintenanceWorker( + mockProcess, + paymentsService, + () => settings, + nip05VerificationRepository, + ) + }) + + afterEach(() => { + sandbox.restore() + }) + + describe('processNip05Reverifications', () => { + it('returns early when nip05 settings are undefined', async () => { + settings.nip05 = undefined + + await (worker as any).processNip05Reverifications(settings) + + expect(nip05VerificationRepository.findPendingVerifications).not.to.have.been.called + }) + + it('returns early when mode is disabled', async () => { + settings.nip05.mode = 'disabled' + + await (worker as any).processNip05Reverifications(settings) + + expect(nip05VerificationRepository.findPendingVerifications).not.to.have.been.called + }) + + it('does nothing when no pending verifications', async () => { + nip05VerificationRepository.findPendingVerifications.resolves([]) + + await (worker as any).processNip05Reverifications(settings) + + expect(nip05VerificationRepository.findPendingVerifications).to.have.been.calledOnceWithExactly( + 86400000, + 20, + 50, + ) + expect(verifyStub).not.to.have.been.called + }) + + it('re-verifies and updates successful verifications', async () => { + const verification: Nip05Verification = { + pubkey: 'a'.repeat(64), + nip05: 'alice@example.com', + domain: 'example.com', + isVerified: true, + lastVerifiedAt: new Date(Date.now() - 100000000), + lastCheckedAt: new Date(Date.now() - 100000000), + failureCount: 0, + createdAt: new Date(), + updatedAt: new Date(), + } + + nip05VerificationRepository.findPendingVerifications.resolves([verification]) + verifyStub.resolves(true) + + await (worker as any).processNip05Reverifications(settings) + + expect(verifyStub).to.have.been.calledOnceWithExactly('alice@example.com', 'a'.repeat(64)) + expect(nip05VerificationRepository.upsert).to.have.been.calledOnce + + const upsertArg = nip05VerificationRepository.upsert.firstCall.args[0] + expect(upsertArg.isVerified).to.be.true + expect(upsertArg.failureCount).to.equal(0) + expect(upsertArg.lastVerifiedAt).to.be.an.instanceOf(Date) + }) + + it('increments failure count on failed verification', async () => { + const verification: Nip05Verification = { + pubkey: 'b'.repeat(64), + nip05: 'bob@example.com', + domain: 'example.com', + isVerified: true, + lastVerifiedAt: new Date(Date.now() - 100000000), + lastCheckedAt: new Date(Date.now() - 100000000), + failureCount: 3, + createdAt: new Date(), + updatedAt: new Date(), + } + + nip05VerificationRepository.findPendingVerifications.resolves([verification]) + verifyStub.resolves(false) + + await (worker as any).processNip05Reverifications(settings) + + expect(nip05VerificationRepository.upsert).to.have.been.calledOnce + + const upsertArg = nip05VerificationRepository.upsert.firstCall.args[0] + expect(upsertArg.isVerified).to.be.false + expect(upsertArg.failureCount).to.equal(4) + expect(upsertArg.lastVerifiedAt).to.deep.equal(verification.lastVerifiedAt) + }) + + it('handles individual verification errors gracefully', async () => { + const v1: Nip05Verification = { + pubkey: 'a'.repeat(64), + nip05: 'alice@example.com', + domain: 'example.com', + isVerified: true, + lastVerifiedAt: new Date(), + lastCheckedAt: new Date(Date.now() - 100000000), + failureCount: 0, + createdAt: new Date(), + updatedAt: new Date(), + } + const v2: Nip05Verification = { + pubkey: 'b'.repeat(64), + nip05: 'bob@example.com', + domain: 'example.com', + isVerified: true, + lastVerifiedAt: new Date(), + lastCheckedAt: new Date(Date.now() - 100000000), + failureCount: 0, + createdAt: new Date(), + updatedAt: new Date(), + } + + nip05VerificationRepository.findPendingVerifications.resolves([v1, v2]) + verifyStub.onFirstCall().rejects(new Error('network error')) + verifyStub.onSecondCall().resolves(true) + + await (worker as any).processNip05Reverifications(settings) + + expect(nip05VerificationRepository.upsert).to.have.been.calledOnce + const upsertArg = nip05VerificationRepository.upsert.firstCall.args[0] + expect(upsertArg.pubkey).to.equal('b'.repeat(64)) + }) + + it('uses configured updateFrequency and maxFailures', async () => { + settings.nip05.verifyUpdateFrequency = 3600000 + settings.nip05.maxConsecutiveFailures = 5 + + await (worker as any).processNip05Reverifications(settings) + + expect(nip05VerificationRepository.findPendingVerifications).to.have.been.calledOnceWithExactly( + 3600000, + 5, + 50, + ) + }) + + it('uses defaults when settings values are undefined', async () => { + settings.nip05.verifyUpdateFrequency = undefined + settings.nip05.maxConsecutiveFailures = undefined + + await (worker as any).processNip05Reverifications(settings) + + expect(nip05VerificationRepository.findPendingVerifications).to.have.been.calledOnceWithExactly( + 86400000, + 20, + 50, + ) + }) + + it('processes in passive mode', async () => { + settings.nip05.mode = 'passive' + const verification: Nip05Verification = { + pubkey: 'c'.repeat(64), + nip05: 'charlie@example.com', + domain: 'example.com', + isVerified: true, + lastVerifiedAt: new Date(), + lastCheckedAt: new Date(Date.now() - 100000000), + failureCount: 0, + createdAt: new Date(), + updatedAt: new Date(), + } + + nip05VerificationRepository.findPendingVerifications.resolves([verification]) + verifyStub.resolves(true) + + await (worker as any).processNip05Reverifications(settings) + + expect(verifyStub).to.have.been.calledOnce + expect(nip05VerificationRepository.upsert).to.have.been.calledOnce + }) + }) +}) diff --git a/test/unit/factories/message-handler-factory.spec.ts b/test/unit/factories/message-handler-factory.spec.ts index 4c5299ff..ea19038f 100644 --- a/test/unit/factories/message-handler-factory.spec.ts +++ b/test/unit/factories/message-handler-factory.spec.ts @@ -1,6 +1,6 @@ import { expect } from 'chai' -import { IEventRepository, IUserRepository } from '../../../src/@types/repositories' +import { IEventRepository, INip05VerificationRepository, IUserRepository } from '../../../src/@types/repositories' import { IncomingMessage, MessageType } from '../../../src/@types/messages' import { Event } from '../../../src/@types/event' import { EventMessageHandler } from '../../../src/handlers/event-message-handler' @@ -13,6 +13,7 @@ describe('messageHandlerFactory', () => { let event: Event let eventRepository: IEventRepository let userRepository: IUserRepository + let nip05VerificationRepository: INip05VerificationRepository let message: IncomingMessage let adapter: IWebSocketAdapter let factory @@ -20,11 +21,12 @@ describe('messageHandlerFactory', () => { beforeEach(() => { eventRepository = {} as any userRepository = {} as any + nip05VerificationRepository = {} as any adapter = {} as any event = { tags: [], } as any - factory = messageHandlerFactory(eventRepository, userRepository) + factory = messageHandlerFactory(eventRepository, userRepository, nip05VerificationRepository) }) it('returns EventMessageHandler when given an EVENT message', () => { diff --git a/test/unit/factories/websocket-adapter-factory.spec.ts b/test/unit/factories/websocket-adapter-factory.spec.ts index 1a5002dc..35b38f90 100644 --- a/test/unit/factories/websocket-adapter-factory.spec.ts +++ b/test/unit/factories/websocket-adapter-factory.spec.ts @@ -3,7 +3,7 @@ import { IncomingMessage } from 'http' import Sinon from 'sinon' import WebSocket from 'ws' -import { IEventRepository, IUserRepository } from '../../../src/@types/repositories' +import { IEventRepository, INip05VerificationRepository, IUserRepository } from '../../../src/@types/repositories' import { IWebSocketServerAdapter } from '../../../src/@types/adapters' import { SettingsStatic } from '../../../src/utils/settings' import { WebSocketAdapter } from '../../../src/adapters/web-socket-adapter' @@ -31,6 +31,7 @@ describe('webSocketAdapterFactory', () => { }) const eventRepository: IEventRepository = {} as any const userRepository: IUserRepository = {} as any + const nip05VerificationRepository: INip05VerificationRepository = {} as any const client: WebSocket = { on: onStub, @@ -46,8 +47,11 @@ describe('webSocketAdapterFactory', () => { } as any const webSocketServerAdapter: IWebSocketServerAdapter = {} as any + const factory = webSocketAdapterFactory( + eventRepository, userRepository, nip05VerificationRepository, + ) expect( - webSocketAdapterFactory(eventRepository, userRepository)([client, request, webSocketServerAdapter]) + factory([client, request, webSocketServerAdapter]) ).to.be.an.instanceOf(WebSocketAdapter) }) }) From baabf8ea7b40fde551918ea4d6a465130c4953ce Mon Sep 17 00:00:00 2001 From: archief2910 Date: Sat, 11 Apr 2026 21:19:03 +0530 Subject: [PATCH 6/6] docs(nip05): add NIP-05 default settings and configuration docs (#261) --- CONFIGURATION.md | 8 +++++++- resources/default-settings.yaml | 18 ++++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/CONFIGURATION.md b/CONFIGURATION.md index cd06011a..da9b3966 100644 --- a/CONFIGURATION.md +++ b/CONFIGURATION.md @@ -116,4 +116,10 @@ Running `nostream` for the first time creates the settings file in `