Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion CONFIGURATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,4 +119,10 @@ Running `nostream` for the first time creates the settings file in `<project_roo
| limits.message.ipWhitelist | List of IPs (IPv4 or IPv6) to ignore rate limits. |
| limits.admissionCheck.rateLimits[].period | Rate limit period in milliseconds. |
| limits.admissionCheck.rateLimits[].rate | Maximum number of admission checks during period. |
| limits.admissionCheck.ipWhitelist | List of IPs (IPv4 or IPv6) to ignore rate limits. |
| limits.admissionCheck.ipWhitelist | List of IPs (IPv4 or IPv6) to ignore rate limits. |
| nip05.mode | NIP-05 verification mode: `enabled` requires verification, `passive` verifies without blocking, `disabled` does nothing. Defaults to `disabled`. |
| nip05.verifyExpiration | Time in milliseconds before a successful NIP-05 verification expires and needs re-checking. Defaults to 604800000 (1 week). |
| nip05.verifyUpdateFrequency | Minimum interval in milliseconds between re-verification attempts for a given author. Defaults to 86400000 (24 hours). |
| nip05.maxConsecutiveFailures | Number of consecutive verification failures before giving up on an author. Defaults to 20. |
| nip05.domainWhitelist | List of domains allowed for NIP-05 verification. If set, only authors verified at these domains can publish. |
| nip05.domainBlacklist | List of domains blocked from NIP-05 verification. Authors with NIP-05 at these domains will be rejected. |
21 changes: 21 additions & 0 deletions migrations/20260409_120000_create_nip05_verifications_table.js
Original file line number Diff line number Diff line change
@@ -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')
}
26 changes: 26 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

11 changes: 6 additions & 5 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,8 @@
"prestart": "npm run build",
"start": "cd dist && node src/index.js",
"build:check": "npm run build -- --noEmit",
"lint": "ESLINT_USE_FLAT_CONFIG=false eslint --ext .ts ./src ./test",
"lint:report": "ESLINT_USE_FLAT_CONFIG=false eslint -o .lint-reports/eslint.json -f json --ext .ts ./src ./test",
"lint": "cross-env ESLINT_USE_FLAT_CONFIG=false eslint --ext .ts ./src ./test",
"lint:report": "cross-env ESLINT_USE_FLAT_CONFIG=false eslint -o .lint-reports/eslint.json -f json --ext .ts ./src ./test",
"lint:fix": "npm run lint -- --fix",
"knip": "knip --config .knip.json --production --no-progress --reporter compact",
"check:all": "npm run lint && npm run knip",
Expand Down Expand Up @@ -80,6 +80,8 @@
"@commitlint/config-conventional": "17.2.0",
"@cucumber/cucumber": "10.2.1",
"@cucumber/pretty-formatter": "1.0.0",
"@eslint/eslintrc": "^3.3.1",
"@eslint/js": "^9.39.1",
"@semantic-release/commit-analyzer": "9.0.2",
"@semantic-release/git": "10.0.1",
"@semantic-release/github": "8.1.0",
Expand All @@ -97,13 +99,12 @@
"@types/sinon": "^10.0.11",
"@types/sinon-chai": "^3.2.8",
"@types/ws": "^8.5.12",
"@eslint/eslintrc": "^3.3.1",
"@eslint/js": "^9.39.1",
"@typescript-eslint/eslint-plugin": "^8.58.1",
"@typescript-eslint/parser": "^8.58.1",
"chai": "^4.3.6",
"chai-as-promised": "^7.1.1",
"conventional-changelog-conventionalcommits": "5.0.0",
"cross-env": "^10.1.0",
"cz-conventional-changelog": "3.3.0",
"eslint": "^9.39.4",
"husky": "8.0.2",
Expand All @@ -125,11 +126,11 @@
},
"dependencies": {
"@noble/secp256k1": "1.7.1",
"accepts": "^1.3.8",
"axios": "^1.15.0",
"bech32": "2.0.0",
"debug": "4.3.4",
"dotenv": "16.0.3",
"accepts": "^1.3.8",
"express": "4.22.1",
"helmet": "6.0.1",
"js-yaml": "4.1.1",
Expand Down
18 changes: 18 additions & 0 deletions resources/default-settings.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,24 @@ paymentsProcessors:
opennode:
baseURL: api.opennode.com
callbackBaseURL: https://nostream.your-domain.com/callbacks/opennode
nip05:
# NIP-05 verification of event authors as a spam reduction measure.
# mode: 'enabled' requires NIP-05 for publishing (except kind 0),
# 'passive' verifies but never blocks, 'disabled' does nothing.
mode: disabled
# How long (ms) a successful verification remains valid before re-check.
# Matches nostr-rs-relay default of 1 week.
verifyExpiration: 604800000
# Minimum interval (ms) between re-verification attempts for a given author.
# Matches nostr-rs-relay default of 24 hours.
verifyUpdateFrequency: 86400000
# How many consecutive failed checks before giving up on verifying an author.
# Matches nostr-rs-relay default of 20.
maxConsecutiveFailures: 20
# Only allow authors with NIP-05 at these domains (empty = allow all)
domainWhitelist: []
# Block authors with NIP-05 at these domains
domainBlacklist: []
network:
maxPayloadSize: 524288
# Comment the next line if using CloudFlare proxy
Expand Down
25 changes: 25 additions & 0 deletions src/@types/nip05.ts
Original file line number Diff line number Diff line change
@@ -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
}
12 changes: 12 additions & 0 deletions src/@types/repositories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { DBEvent, Event } from './event'
import { EventKinds } from '../constants/base'
import { EventKindsRange } from './settings'
import { Invoice } from './invoice'
import { Nip05Verification } from './nip05'
import { SubscriptionFilter } from './subscription'
import { User } from './user'

Expand Down Expand Up @@ -64,3 +65,14 @@ export interface IUserRepository {
getBalanceByPubkey(pubkey: Pubkey, client?: DatabaseClient): Promise<bigint>
admitUser(pubkey: Pubkey, admittedAt: Date, client?: DatabaseClient): Promise<void>
}

export interface INip05VerificationRepository {
findByPubkey(pubkey: Pubkey): Promise<Nip05Verification | undefined>
upsert(verification: Nip05Verification): Promise<number>
findPendingVerifications(
updateFrequencyMs: number,
maxFailures: number,
limit: number,
): Promise<Nip05Verification[]>
deleteByPubkey(pubkey: Pubkey): Promise<number>
}
24 changes: 24 additions & 0 deletions src/@types/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,29 @@ export interface Mirroring {
static?: Mirror[]
}

export type Nip05Mode = 'enabled' | 'passive' | 'disabled'

export interface Nip05Settings {
mode: Nip05Mode
/**
* Maximum age (in ms) of a successful verification before an author is blocked.
* Defaults to 604800000 (7 days) when unset.
*/
verifyExpiration?: number
/**
* Minimum interval (in ms) between background re-verifications per author.
* Defaults to 86400000 (24 hours) when unset.
*/
verifyUpdateFrequency?: number
/**
* Number of consecutive verification failures after which an author is no longer
* re-checked. Defaults to 20 when unset.
*/
maxConsecutiveFailures?: number
domainWhitelist?: string[]
domainBlacklist?: string[]
}

export interface Settings {
info: Info
payments?: Payments
Expand All @@ -236,4 +259,5 @@ export interface Settings {
workers?: Worker
limits?: Limits
mirroring?: Mirroring
nip05?: Nip05Settings
}
94 changes: 94 additions & 0 deletions src/app/maintenance-worker.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,71 @@
import {
DEFAULT_NIP05_MAX_CONSECUTIVE_FAILURES,
DEFAULT_NIP05_VERIFY_UPDATE_FREQUENCY_MS,
Nip05VerificationOutcome,
verifyNip05Identifier,
} from '../utils/nip05'
import { IMaintenanceService, IPaymentsService } from '../@types/services'
import { mergeDeepLeft, path, pipe } from 'ramda'
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 { Nip05Verification } from '../@types/nip05'
import { Settings } from '../@types/settings'

const UPDATE_INVOICE_INTERVAL = 60000
const NIP05_REVERIFICATION_BATCH_SIZE = 50
const CLEAR_OLD_EVENTS_TIMEOUT_MS = 5000

const debug = createLogger('maintenance-worker')

/**
* Merge a re-verification outcome onto an existing verification row.
*
* Definitive outcomes (`verified`, `mismatch`, `invalid`) update `isVerified`
* and `lastVerifiedAt`. Transient `error` outcomes only bump `failureCount` /
* `lastCheckedAt` so a previously-verified author keeps their grace period
* until `verifyExpiration` elapses. This prevents a single network blip from
* immediately blocking publishing.
*/
export function applyReverificationOutcome(
existing: Nip05Verification,
outcome: Nip05VerificationOutcome,
): Nip05Verification {
const now = new Date()
const base: Nip05Verification = {
...existing,
lastCheckedAt: now,
updatedAt: now,
}

switch (outcome.status) {
case 'verified':
return {
...base,
isVerified: true,
lastVerifiedAt: now,
failureCount: 0,
}
case 'mismatch':
case 'invalid':
return {
...base,
isVerified: false,
lastVerifiedAt: null,
failureCount: existing.failureCount + 1,
}
case 'error':
default:
return {
...base,
failureCount: existing.failureCount + 1,
}
}
}

export class MaintenanceWorker implements IRunnable {
private interval: NodeJS.Timeout | undefined
private isRunning = false
Expand All @@ -21,6 +75,7 @@ export class MaintenanceWorker implements IRunnable {
private readonly paymentsService: IPaymentsService,
private readonly maintenanceService: IMaintenanceService,
private readonly settings: () => Settings,
private readonly nip05VerificationRepository: INip05VerificationRepository,
) {
this.process
.on('SIGINT', this.onExit.bind(this))
Expand Down Expand Up @@ -65,6 +120,8 @@ export class MaintenanceWorker implements IRunnable {
const currentSettings = this.settings()
const clearOldEventsPromise = this.clearOldEventsSafely()

await this.processNip05Reverifications(currentSettings)

if (!path(['payments','enabled'], currentSettings)) {
await clearOldEventsPromise
return
Expand Down Expand Up @@ -120,6 +177,43 @@ export class MaintenanceWorker implements IRunnable {
await clearOldEventsPromise
}

private async processNip05Reverifications(currentSettings: Settings): Promise<void> {
const nip05Settings = currentSettings.nip05
if (!nip05Settings || nip05Settings.mode === 'disabled') {
return
}

try {
const updateFrequency = nip05Settings.verifyUpdateFrequency ?? DEFAULT_NIP05_VERIFY_UPDATE_FREQUENCY_MS
const maxFailures = nip05Settings.maxConsecutiveFailures ?? DEFAULT_NIP05_MAX_CONSECUTIVE_FAILURES

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 outcome = await verifyNip05Identifier(verification.nip05, verification.pubkey)
const updated = applyReverificationOutcome(verification, outcome)
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
Expand Down
7 changes: 6 additions & 1 deletion src/factories/maintenance-worker-factory.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,18 @@
import { createMaintenanceService } from './maintenance-service-factory'
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 = () => {
const dbClient = getMasterDbClient()
const nip05VerificationRepository = new Nip05VerificationRepository(dbClient)
return new MaintenanceWorker(
process,
createPaymentsService(),
createMaintenanceService(),
createSettings
createSettings,
nip05VerificationRepository,
)
}
Loading
Loading