From 283ae6f3ad7e7a6175faada864a470df0776484a Mon Sep 17 00:00:00 2001 From: ABHAY PANDEY Date: Mon, 26 Oct 2026 16:59:09 +0530 Subject: [PATCH 1/3] feat(peering): advertise terms and scopes in NIP-11 (#932) --- CONFIGURATION.md | 42 ++++ resources/default-settings.yaml | 28 +++ src/@types/settings.ts | 64 ++++++ .../request-handlers/root-request-handler.ts | 29 ++- src/utils/settings-config.ts | 185 ++++++++++++++++++ .../root-request-handler.spec.ts | 72 +++++++ test/unit/utils/settings-config.spec.ts | 137 +++++++++++++ 7 files changed, 556 insertions(+), 1 deletion(-) diff --git a/CONFIGURATION.md b/CONFIGURATION.md index c5490a2a0..8424aa4ec 100644 --- a/CONFIGURATION.md +++ b/CONFIGURATION.md @@ -282,7 +282,49 @@ The settings below are listed in alphabetical order by name. Please keep this ta | payments.feeSchedules.admission[].whitelists.event_kinds | List of event kinds to waive admission fee. Use `[min, max]` for ranges. | | payments.feeSchedules.admission[].whitelists.pubkeys | List of pubkeys to waive admission fee. | | payments.processor | Either `zebedee`, `lnbits`, `lnurl`, `nodeless`, `opennode`, `nwc`. | +| peering.contact | Contact URI (e.g. `mailto:peering@your-domain.com`) advertised to other relays for establishing peering. At least one of `contact` or `endpoint` is required when peering is enabled. | +| peering.enabled | Advertise a `peering` section in the NIP-11 document. This only expresses feeds and terms; it does not accept peers. Defaults to false. | +| peering.endpoint | WebSocket endpoint (`ws://` or `wss://`) peers should connect to, advertised to other relays. At least one of `contact` or `endpoint` is required when peering is enabled. | +| peering.minRequestPowDifficulty | NIP-13 proof-of-work bits required on peering requests, advertised as `peering.min_pow_difficulty`. `0` (default) means none. Applies to peering requests only, never to operational traffic. | +| peering.scopes[].kinds | Event kinds this feed covers, as kinds or `[min, max]` ranges. Required for every scope. At least one scope is required when peering is enabled. | +| peering.scopes[].since | Inclusive lower bound of the feed's time range as a unix timestamp in seconds. Omit for no lower bound. Mutually exclusive with `windowSeconds`. | +| peering.scopes[].until | Inclusive upper bound of the feed's time range as a unix timestamp in seconds. Omit for a feed that stays current. Mutually exclusive with `windowSeconds`. | +| peering.scopes[].windowSeconds | Rolling window in seconds: the feed covers the most recent `windowSeconds` of events. Mutually exclusive with `since` and `until`. | +| peering.terms[].amount | Fee amount charged per peering period, denominated in `unit`. | +| peering.terms[].period | Peering period in seconds. | +| peering.terms[].unit | Fee unit (for example `msats`). An empty `terms` list means settlement-free peering. | | workers.count | Number of workers to spin up to handle incoming connections. Spin workers as many CPUs are available when set to zero. Defaults to zero. | + +## Peering advertisement (NIP-11) + +When `peering.enabled` is true, the relay information document served with +`Accept: application/nostr+json` includes a `peering` section built from the +settings above: + +```json +{ + "peering": { + "enabled": true, + "contact": "mailto:peering@your-domain.com", + "endpoint": "wss://relay.example.com", + "min_pow_difficulty": 0, + "scopes": [ + { "kinds": [1, 6], "window_seconds": 3600 }, + { "kinds": [3, 10002], "since": 1700000000 } + ], + "terms": [ + { "amount": 1000, "unit": "msats", "period": 2592000 } + ] + } +} +``` + +`terms` uses the NIP-11 fee-object shape (`amount`, `unit`, `period`). An empty +`terms` list is settlement-free peering: the absence of a fee entry is the +signal, not a zero amount. `min_pow_difficulty` is the NIP-13 work a peering +request must carry; `0` means there is no requirement. When peering is +disabled, the section is published as `{ "enabled": false }` and no scopes, +terms, or contact details are exposed. | wot.enabled | Enables the Web of Trust graph, rooted at `wot.seedPubkey`, built from NIP-02 contact lists. Defaults to false. | | wot.maxDepth | How many hops out from `wot.seedPubkey` the trust graph extends. Direct follows are distance 1. Defaults to 2. | | wot.minimumFollowers | Minimum number of already-trusted accounts that must follow a pubkey before it enters the graph at 2+ hops. Direct follows are always trusted. Defaults to 1. | diff --git a/resources/default-settings.yaml b/resources/default-settings.yaml index 9b33f3489..54c7f4bce 100755 --- a/resources/default-settings.yaml +++ b/resources/default-settings.yaml @@ -168,6 +168,34 @@ nip77: maxSessionsPerConnection: 3 # Close a session with no NEG-MSG for this long. sessionIdleTimeoutMs: 60000 +peering: + # Relay peering advertisement, served as the `peering` section of the NIP-11 + # document. This is a discovery surface: it states the feeds and terms other + # relays can read. Enabling it does not accept peers by itself. + enabled: false + # How to ask for a peering relationship: a contact URI (mailto:, https:) and/or + # the WebSocket endpoint peers should connect to. At least one is required when + # peering is enabled. + contact: "" + endpoint: "" + # NIP-13 proof-of-work bits a peering request must carry. 0 means none. + # Advertised as peering.min_pow_difficulty. + minRequestPowDifficulty: 0 + # Feeds this relay is willing to serve. Each scope lists event kinds and, + # optionally, an absolute time range (unix seconds) or a rolling window. + # At least one scope is required when peering is enabled. + # Example: + # scopes: + # - kinds: [1, 3, 6, 7, 30023] + # - kinds: [[0, 9999]] + # since: 1700000000 + # - kinds: [1, 6] + # windowSeconds: 3600 + scopes: [] + # Terms for peering, reusing the NIP-11 fee-object shape. `period` is in + # seconds. An empty list means settlement-free peering. + # Example: [{ amount: 1000, unit: msats, period: 2592000 }] + terms: [] network: maxPayloadSize: 524288 # Uncomment only when using a trusted reverse proxy and configuring trustedProxies. diff --git a/src/@types/settings.ts b/src/@types/settings.ts index 26430aea8..e19daca1f 100644 --- a/src/@types/settings.ts +++ b/src/@types/settings.ts @@ -507,6 +507,69 @@ export interface Nip56Settings { hideActionableReports: boolean } +export interface PeeringScope { + /** + * Event kinds this feed covers, as kinds or `[min, max]` ranges. + */ + kinds: (EventKinds | EventKindsRange)[] + /** + * Inclusive lower bound of the feed's time range as a unix timestamp in + * seconds. Omit for no lower bound. + */ + since?: number + /** + * Inclusive upper bound of the feed's time range as a unix timestamp in + * seconds. Omit for a feed that stays current. + */ + until?: number + /** + * Rolling window in seconds: the feed covers events from the last + * `windowSeconds` seconds. Mutually exclusive with `since`/`until`. + */ + windowSeconds?: number +} + +export interface PeeringTerm { + /** Amount charged for one peering period, denominated in `unit`. */ + amount: number + /** Unit of `amount`, for example `msats`. */ + unit: string + /** Length of the peering period in seconds. */ + period: number +} + +export interface PeeringSettings { + /** + * Whether this relay offers peering feeds. Advertised as `peering.enabled` + * in the NIP-11 document. Advertising terms does not accept peers. + */ + enabled: boolean + /** + * Contact URI (for example `mailto:` or `https:`) for peering requests. + * At least one of `contact` or `endpoint` is required when enabled. + */ + contact?: string + /** + * WebSocket endpoint peers should connect to. At least one of `contact` or + * `endpoint` is required when enabled. + */ + endpoint?: string + /** + * NIP-13 proof-of-work bits required on peering requests. Absent or 0 means + * none. Advertised as `peering.min_pow_difficulty`. + */ + minRequestPowDifficulty?: number + /** + * Feeds this relay offers. At least one scope is required when enabled. + */ + scopes?: PeeringScope[] + /** + * Terms for peering in the NIP-11 fee-object shape (`amount`, `unit`, + * `period`). An empty list means settlement-free peering. + */ + terms?: PeeringTerm[] +} + export interface Settings { info: Info admin?: AdminSettings @@ -526,5 +589,6 @@ export interface Settings { nip56?: Nip56Settings nip66?: Nip66Settings nip77?: Nip77Settings + peering?: PeeringSettings wot?: WoTSettings } diff --git a/src/handlers/request-handlers/root-request-handler.ts b/src/handlers/request-handlers/root-request-handler.ts index 7667962d3..353d766f6 100644 --- a/src/handlers/request-handlers/root-request-handler.ts +++ b/src/handlers/request-handlers/root-request-handler.ts @@ -2,7 +2,7 @@ import { NextFunction, Request, Response } from 'express' import { path, pathEq } from 'ramda' import { createSettings } from '../../factories/settings-factory' import { escapeHtml } from '../../utils/html' -import { FeeSchedule } from '../../@types/settings' +import { FeeSchedule, PeeringSettings } from '../../@types/settings' import { DEFAULT_FILTER_LIMIT } from '../../constants/base' import { fromBech32 } from '../../utils/transform' import { getTemplate } from '../../utils/template-cache' @@ -41,6 +41,32 @@ export const hasExplicitNostrJsonAcceptHeader = (request: Request): boolean => { }) } +export const buildPeeringDocument = (peering: PeeringSettings | undefined) => { + // Disabled peering advertises only the fact that no feeds are offered, so a + // half-configured section never reads as an offer. + if (peering?.enabled !== true) { + return { enabled: false } + } + + return { + enabled: true, + ...(peering.contact ? { contact: peering.contact } : {}), + ...(peering.endpoint ? { endpoint: peering.endpoint } : {}), + // NIP-11 convention: snake_case in the document, and 0/absent both mean + // no proof-of-work requirement. + min_pow_difficulty: peering.minRequestPowDifficulty ?? 0, + scopes: (peering.scopes ?? []).map((scope) => ({ + kinds: scope.kinds, + ...(scope.since !== undefined ? { since: scope.since } : {}), + ...(scope.until !== undefined ? { until: scope.until } : {}), + ...(scope.windowSeconds !== undefined ? { window_seconds: scope.windowSeconds } : {}), + })), + // An empty list is the settlement-free case: terms are expressed as fee + // entries, so "no fee entry" is meaningful rather than a zero amount. + terms: (peering.terms ?? []).map(({ amount, unit, period }) => ({ amount, unit, period })), + } +} + export const rootRequestHandler = (request: Request, response: Response, next: NextFunction) => { const settings = createSettings() const pathPrefix = getPublicPathPrefix(request, settings) @@ -158,6 +184,7 @@ export const rootRequestHandler = (request: Request, response: Response, next: N {} as Record, ) : {}, + peering: buildPeeringDocument(settings.peering), } response diff --git a/src/utils/settings-config.ts b/src/utils/settings-config.ts index de7e94a9a..50c2997e4 100644 --- a/src/utils/settings-config.ts +++ b/src/utils/settings-config.ts @@ -688,6 +688,190 @@ const validateSubscriptionPlans = (plans: unknown, issues: ValidationIssue[]): v }) } +const PEERING_KIND_MAX = 65535 +const PEERING_POW_MAX_BITS = 256 +const PEERING_SCOPE_KEYS = new Set(['kinds', 'since', 'until', 'windowSeconds']) +const PEERING_TERM_KEYS = new Set(['amount', 'unit', 'period']) + +const isValidPeeringKindValue = (value: unknown): boolean => + typeof value === 'number' && Number.isInteger(value) && value >= 0 && value <= PEERING_KIND_MAX + +const validatePeeringKinds = (kinds: unknown, path: string, issues: ValidationIssue[]): void => { + if (!Array.isArray(kinds) || kinds.length === 0) { + issues.push({ + path, + message: `must be a non-empty list of event kinds or [min, max] ranges`, + }) + return + } + + kinds.forEach((kind, index) => { + if (Array.isArray(kind)) { + if (kind.length !== 2 || !isValidPeeringKindValue(kind[0]) || !isValidPeeringKindValue(kind[1])) { + issues.push({ + path: `${path}[${index}]`, + message: `range must contain two integers between 0 and ${PEERING_KIND_MAX}`, + }) + return + } + + if ((kind[0] as number) > (kind[1] as number)) { + issues.push({ path: `${path}[${index}]`, message: 'range start must be less than or equal to range end' }) + } + return + } + + if (!isValidPeeringKindValue(kind)) { + issues.push({ path: `${path}[${index}]`, message: `must be an integer between 0 and ${PEERING_KIND_MAX}` }) + } + }) +} + +// Scopes and terms default to empty lists, so the default settings schema has no +// entry shape to check them against. Validate each entry here instead. +const validatePeeringScopes = (scopes: unknown, issues: ValidationIssue[]): void => { + if (!Array.isArray(scopes)) { + return + } + + scopes.forEach((scope, index) => { + const prefix = `peering.scopes[${index}]` + + if (!isPlainObject(scope)) { + issues.push({ path: prefix, message: 'scope must be an object' }) + return + } + + for (const key of Object.keys(scope)) { + if (!PEERING_SCOPE_KEYS.has(key)) { + issues.push({ path: `${prefix}.${key}`, message: 'Unknown setting key' }) + } + } + + validatePeeringKinds(scope.kinds, `${prefix}.kinds`, issues) + + const { since, until, windowSeconds } = scope + + if (since !== undefined && !(typeof since === 'number' && Number.isSafeInteger(since) && since >= 0)) { + issues.push({ path: `${prefix}.since`, message: 'since must be a non-negative integer (unix seconds)' }) + } + + if (until !== undefined && !(typeof until === 'number' && Number.isSafeInteger(until) && until >= 0)) { + issues.push({ path: `${prefix}.until`, message: 'until must be a non-negative integer (unix seconds)' }) + } + + if ( + windowSeconds !== undefined && + !(typeof windowSeconds === 'number' && Number.isSafeInteger(windowSeconds) && windowSeconds > 0) + ) { + issues.push({ path: `${prefix}.windowSeconds`, message: 'windowSeconds must be a positive integer (seconds)' }) + } else if (windowSeconds !== undefined && (since !== undefined || until !== undefined)) { + issues.push({ path: prefix, message: 'windowSeconds cannot be combined with since or until' }) + } + + if ( + typeof since === 'number' && + typeof until === 'number' && + Number.isSafeInteger(since) && + Number.isSafeInteger(until) && + since > until + ) { + issues.push({ path: prefix, message: 'since must be less than or equal to until' }) + } + }) +} + +const validatePeeringTerms = (terms: unknown, issues: ValidationIssue[]): void => { + if (!Array.isArray(terms)) { + return + } + + terms.forEach((term, index) => { + const prefix = `peering.terms[${index}]` + + if (!isPlainObject(term)) { + issues.push({ path: prefix, message: 'term must be an object' }) + return + } + + for (const key of Object.keys(term)) { + if (!PEERING_TERM_KEYS.has(key)) { + issues.push({ path: `${prefix}.${key}`, message: 'Unknown setting key' }) + } + } + + if (!(typeof term.amount === 'number' && Number.isSafeInteger(term.amount) && term.amount >= 0)) { + issues.push({ path: `${prefix}.amount`, message: 'amount must be a non-negative integer' }) + } + + if (typeof term.unit !== 'string' || !term.unit.trim()) { + issues.push({ path: `${prefix}.unit`, message: 'unit must be a non-empty string' }) + } + + if (!(typeof term.period === 'number' && Number.isSafeInteger(term.period) && term.period > 0)) { + issues.push({ path: `${prefix}.period`, message: 'period must be a positive integer (seconds)' }) + } + }) +} + +const validatePeering = (settings: Settings, issues: ValidationIssue[]): void => { + const peering = settings.peering + + if (peering === undefined || peering === null) { + return + } + + // A non-object value is already reported against the default settings schema. + if (!isPlainObject(peering)) { + return + } + + const enabled = peering.enabled === true + + if (peering.minRequestPowDifficulty !== undefined) { + const bits = peering.minRequestPowDifficulty + if ( + typeof bits !== 'number' || + !Number.isInteger(bits) || + bits < 0 || + bits > PEERING_POW_MAX_BITS + ) { + issues.push({ + path: 'peering.minRequestPowDifficulty', + message: `must be an integer between 0 and ${PEERING_POW_MAX_BITS}`, + }) + } + } + + if (typeof peering.endpoint === 'string' && peering.endpoint.trim()) { + try { + const endpoint = new URL(peering.endpoint) + if (!['ws:', 'wss:'].includes(endpoint.protocol) || !endpoint.host) { + issues.push({ path: 'peering.endpoint', message: 'must be a ws:// or wss:// URL' }) + } + } catch { + issues.push({ path: 'peering.endpoint', message: 'must be a valid ws:// or wss:// URL' }) + } + } + + validatePeeringScopes(peering.scopes, issues) + validatePeeringTerms(peering.terms, issues) + + if (!enabled) { + return + } + + if (!Array.isArray(peering.scopes) || peering.scopes.length === 0) { + issues.push({ path: 'peering.scopes', message: 'at least one scope is required when peering is enabled' }) + } + + const hasContact = typeof peering.contact === 'string' && peering.contact.trim().length > 0 + const hasEndpoint = typeof peering.endpoint === 'string' && peering.endpoint.trim().length > 0 + if (!hasContact && !hasEndpoint) { + issues.push({ path: 'peering.contact', message: 'contact or endpoint is required when peering is enabled' }) + } +} + export const validateSettings = (settings: Settings): ValidationIssue[] => { const issues: ValidationIssue[] = [] @@ -764,6 +948,7 @@ export const validateSettings = (settings: Settings): ValidationIssue[] => { } validateSubscriptionPlans(settings.payments?.subscriptionPlans, issues) + validatePeering(settings, issues) const pow = settings.limits?.event?.pow if (pow?.enabled) { diff --git a/test/unit/handlers/request-handlers/root-request-handler.spec.ts b/test/unit/handlers/request-handlers/root-request-handler.spec.ts index 547025b90..30ceec7c1 100644 --- a/test/unit/handlers/request-handlers/root-request-handler.spec.ts +++ b/test/unit/handlers/request-handlers/root-request-handler.spec.ts @@ -184,6 +184,78 @@ describe('rootRequestHandler', () => { expect(doc).to.not.have.property('terms_of_service') }) + describe('peering advertisement', () => { + const settlementFreePeering = { + enabled: true, + contact: 'mailto:peering@relay.example.com', + endpoint: 'wss://relay.example.com', + scopes: [ + { kinds: [1, 6, [10000, 19999]], windowSeconds: 3600 }, + { kinds: [3], since: 1700000000, until: 1800000000 }, + ], + } + + const sendWithPeering = (peering?: Record) => { + createSettingsStub.returns({ + ...baseSettings, + ...(peering !== undefined ? { peering } : {}), + }) + + rootRequestHandler(req, res, next) + + return res.send.firstCall.args[0] + } + + it('advertises only enabled: false when peering is not configured', () => { + expect(sendWithPeering()).to.have.property('peering').deep.equal({ enabled: false }) + }) + + it('does not expose terms, scopes or contact while peering is disabled', () => { + const doc = sendWithPeering({ ...settlementFreePeering, enabled: false }) + + expect(doc.peering).to.deep.equal({ enabled: false }) + }) + + it('advertises settlement-free scopes with no fee entry', () => { + const doc = sendWithPeering(settlementFreePeering) + + expect(doc.peering).to.deep.equal({ + enabled: true, + contact: 'mailto:peering@relay.example.com', + endpoint: 'wss://relay.example.com', + min_pow_difficulty: 0, + scopes: [ + { kinds: [1, 6, [10000, 19999]], window_seconds: 3600 }, + { kinds: [3], since: 1700000000, until: 1800000000 }, + ], + terms: [], + }) + }) + + it('advertises paid terms and a minimum request proof-of-work', () => { + const doc = sendWithPeering({ + ...settlementFreePeering, + contact: 'mailto:peering@relay.example.com', + minRequestPowDifficulty: 20, + terms: [{ amount: 21000, unit: 'msats', period: 2592000 }], + }) + + expect(doc.peering.min_pow_difficulty).to.equal(20) + expect(doc.peering.terms).to.deep.equal([{ amount: 21000, unit: 'msats', period: 2592000 }]) + }) + + it('omits an unset contact or endpoint from the advertisement', () => { + const doc = sendWithPeering({ + enabled: true, + endpoint: 'wss://relay.example.com', + scopes: [{ kinds: [1] }], + }) + + expect(doc.peering).to.not.have.property('contact') + expect(doc.peering.endpoint).to.equal('wss://relay.example.com') + }) + }) + // NIP-43 clients verify relay-signed events against `self`, so the document // must advertise the key the relay actually signs with, not a stale or // placeholder string. diff --git a/test/unit/utils/settings-config.spec.ts b/test/unit/utils/settings-config.spec.ts index 2d7403e6e..3f2517578 100644 --- a/test/unit/utils/settings-config.spec.ts +++ b/test/unit/utils/settings-config.spec.ts @@ -99,6 +99,143 @@ describe('settings-config', () => { expect(issues.some((issue) => issue.path === 'network')).to.equal(true) }) + describe('peering settings', () => { + const withPeering = (peering: unknown) => + ({ + info: { + relay_url: 'wss://relay.example.com', + name: 'Test Relay', + pubkey: 'abcd', + contact: 'test@example.com', + }, + network: { maxPayloadSize: 1024 }, + peering, + }) as any + + const peeringIssues = (peering: unknown) => + validateSettings(withPeering(peering)).filter((issue) => issue.path.startsWith('peering')) + + const settlementFreePeering = { + enabled: true, + contact: 'mailto:peering@relay.example.com', + scopes: [{ kinds: [1, 6, [10000, 19999]], windowSeconds: 3600 }], + terms: [], + } + + it('accepts a settlement-free configuration', () => { + expect(peeringIssues(settlementFreePeering)).to.deep.equal([]) + }) + + it('accepts a paid configuration with absolute and rolling scopes', () => { + expect( + peeringIssues({ + ...settlementFreePeering, + minRequestPowDifficulty: 20, + scopes: [ + { kinds: [1], since: 1700000000, until: 1800000000 }, + { kinds: [3], windowSeconds: 3600 }, + ], + terms: [{ amount: 21000, unit: 'msats', period: 2592000 }], + }), + ).to.deep.equal([]) + }) + + it('accepts a disabled configuration without scopes or contact details', () => { + expect(peeringIssues({ enabled: false })).to.deep.equal([]) + }) + + it('rejects an enabled configuration without scopes', () => { + const issues = peeringIssues({ ...settlementFreePeering, scopes: [] }) + + expect(issues.some((issue) => issue.path === 'peering.scopes')).to.equal(true) + expect(issues[0].message).to.include('at least one scope') + }) + + it('rejects an enabled configuration without contact or endpoint', () => { + const issues = peeringIssues({ ...settlementFreePeering, contact: '', endpoint: '' }) + + expect(issues.some((issue) => issue.path === 'peering.contact')).to.equal(true) + expect(issues[0].message).to.include('contact or endpoint') + }) + + it('rejects an unprefixed endpoint', () => { + const issues = peeringIssues({ ...settlementFreePeering, endpoint: 'relay.example.com' }) + + expect(issues.some((issue) => issue.path === 'peering.endpoint')).to.equal(true) + }) + + it('rejects an empty kinds list', () => { + const issues = peeringIssues({ ...settlementFreePeering, scopes: [{ kinds: [] }] }) + + expect(issues.some((issue) => issue.path === 'peering.scopes[0].kinds')).to.equal(true) + }) + + it('rejects event kinds outside the NIP-01 range', () => { + const issues = peeringIssues({ ...settlementFreePeering, scopes: [{ kinds: [65536] }] }) + + expect(issues.some((issue) => issue.path === 'peering.scopes[0].kinds[0]')).to.equal(true) + }) + + it('rejects an inverted kinds range', () => { + const issues = peeringIssues({ ...settlementFreePeering, scopes: [{ kinds: [[100, 1]] }] }) + + expect(issues.some((issue) => issue.path === 'peering.scopes[0].kinds[0]')).to.equal(true) + }) + + it('rejects since later than until', () => { + const issues = peeringIssues({ + ...settlementFreePeering, + scopes: [{ kinds: [1], since: 200, until: 100 }], + }) + + expect(issues.some((issue) => issue.path === 'peering.scopes[0]')).to.equal(true) + }) + + it('rejects a rolling window combined with since or until', () => { + const issues = peeringIssues({ + ...settlementFreePeering, + scopes: [{ kinds: [1], windowSeconds: 3600, since: 100 }], + }) + + expect(issues.some((issue) => issue.path === 'peering.scopes[0]')).to.equal(true) + }) + + it('rejects unknown scope keys', () => { + const issues = peeringIssues({ + ...settlementFreePeering, + scopes: [{ kinds: [1], sinceSeconds: 3600 }], + }) + + expect(issues.some((issue) => issue.path === 'peering.scopes[0].sinceSeconds')).to.equal(true) + }) + + it('rejects malformed terms', () => { + const issues = peeringIssues({ + ...settlementFreePeering, + terms: [{ amount: -1, unit: '', period: 0 }], + }) + + expect(issues.some((issue) => issue.path === 'peering.terms[0].amount')).to.equal(true) + expect(issues.some((issue) => issue.path === 'peering.terms[0].unit')).to.equal(true) + expect(issues.some((issue) => issue.path === 'peering.terms[0].period')).to.equal(true) + }) + + it('rejects unknown term keys', () => { + const issues = peeringIssues({ + ...settlementFreePeering, + terms: [{ amount: 1, unit: 'msats', period: 1, currency: 'btc' }], + }) + + expect(issues.some((issue) => issue.path === 'peering.terms[0].currency')).to.equal(true) + }) + + it('rejects a proof-of-work requirement above the NIP-13 maximum', () => { + const issues = peeringIssues({ ...settlementFreePeering, minRequestPowDifficulty: 257 }) + + expect(issues.some((issue) => issue.path === 'peering.minRequestPowDifficulty')).to.equal(true) + }) + }) + it('rejects invalid nip66 history retention settings', () => { const issues = validateSettings({ info: { relay_url: 'wss://test.relay', name: 'test' }, From 138c128e6d8070fb7ba562b14e2bc5cb14ed1165 Mon Sep 17 00:00:00 2001 From: ABHAY PANDEY Date: Mon, 26 Oct 2026 17:06:48 +0530 Subject: [PATCH 2/3] feat(peering): advertise terms and scopes in NIP-11 (#932) --- .changeset/peering-nip11-advertisement.md | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 .changeset/peering-nip11-advertisement.md diff --git a/.changeset/peering-nip11-advertisement.md b/.changeset/peering-nip11-advertisement.md new file mode 100644 index 000000000..f960b09f4 --- /dev/null +++ b/.changeset/peering-nip11-advertisement.md @@ -0,0 +1,21 @@ +--- +"nostream": minor +--- + +feat(peering): advertise peering terms and scopes in NIP-11 + +The relay information document now carries a `peering` section built from the new +`peering` settings block. It advertises whether peering is offered, the feeds +available as `kinds` scopes with an absolute `since`/`until` range or a rolling +`windowSeconds`, terms in the existing NIP-11 fee-object shape (`amount`, `unit`, +`period`) where an empty `terms` list means settlement-free, a peer `contact` or +WebSocket `endpoint`, and `min_pow_difficulty` — the NIP-13 work a peering request +must carry, `0` meaning none. + +Disabled peering publishes only `{ "enabled": false }`, so half-configured terms are +never read as an accepted peer: this is the discovery surface other relays read +before connecting, not a peer-acceptance path. Settings validation rejects invalid +scopes, terms, time ranges, endpoints and proof-of-work bounds with a clear path, and +all `peering.*` fields are documented in `CONFIGURATION.md` with an example document. + +Closes #932. From f023ab9f33dad57970648d65988df758e4a31501 Mon Sep 17 00:00:00 2001 From: ABHAY PANDEY Date: Mon, 26 Oct 2026 17:32:56 +0530 Subject: [PATCH 3/3] fix(peering): validate contact URIs and omit blank addresses --- CONFIGURATION.md | 10 ++++---- .../request-handlers/root-request-handler.ts | 7 ++++-- src/utils/settings-config.ts | 25 ++++++++++++++----- .../root-request-handler.spec.ts | 12 +++++++++ test/unit/utils/settings-config.spec.ts | 11 ++++++++ 5 files changed, 52 insertions(+), 13 deletions(-) diff --git a/CONFIGURATION.md b/CONFIGURATION.md index 8424aa4ec..a129ac137 100644 --- a/CONFIGURATION.md +++ b/CONFIGURATION.md @@ -294,6 +294,11 @@ The settings below are listed in alphabetical order by name. Please keep this ta | peering.terms[].period | Peering period in seconds. | | peering.terms[].unit | Fee unit (for example `msats`). An empty `terms` list means settlement-free peering. | | workers.count | Number of workers to spin up to handle incoming connections. Spin workers as many CPUs are available when set to zero. Defaults to zero. | +| wot.enabled | Enables the Web of Trust graph, rooted at `wot.seedPubkey`, built from NIP-02 contact lists. Defaults to false. | +| wot.maxDepth | How many hops out from `wot.seedPubkey` the trust graph extends. Direct follows are distance 1. Defaults to 2. | +| wot.minimumFollowers | Minimum number of already-trusted accounts that must follow a pubkey before it enters the graph at 2+ hops. Direct follows are always trusted. Defaults to 1. | +| wot.refreshIntervalHours | Reserved for a future periodic full rebuild; not yet read by any code (real-time kind-3 ingestion already keeps the graph current). Defaults to 24. | +| wot.seedPubkey | The relay owner's pubkey in hex. Root of the trust graph. Required when `wot.enabled` is true. | ## Peering advertisement (NIP-11) @@ -325,8 +330,3 @@ signal, not a zero amount. `min_pow_difficulty` is the NIP-13 work a peering request must carry; `0` means there is no requirement. When peering is disabled, the section is published as `{ "enabled": false }` and no scopes, terms, or contact details are exposed. -| wot.enabled | Enables the Web of Trust graph, rooted at `wot.seedPubkey`, built from NIP-02 contact lists. Defaults to false. | -| wot.maxDepth | How many hops out from `wot.seedPubkey` the trust graph extends. Direct follows are distance 1. Defaults to 2. | -| wot.minimumFollowers | Minimum number of already-trusted accounts that must follow a pubkey before it enters the graph at 2+ hops. Direct follows are always trusted. Defaults to 1. | -| wot.refreshIntervalHours | Reserved for a future periodic full rebuild; not yet read by any code (real-time kind-3 ingestion already keeps the graph current). Defaults to 24. | -| wot.seedPubkey | The relay owner's pubkey in hex. Root of the trust graph. Required when `wot.enabled` is true. | diff --git a/src/handlers/request-handlers/root-request-handler.ts b/src/handlers/request-handlers/root-request-handler.ts index 353d766f6..f1b859d17 100644 --- a/src/handlers/request-handlers/root-request-handler.ts +++ b/src/handlers/request-handlers/root-request-handler.ts @@ -48,10 +48,13 @@ export const buildPeeringDocument = (peering: PeeringSettings | undefined) => { return { enabled: false } } + const contact = peering.contact?.trim() + const endpoint = peering.endpoint?.trim() + return { enabled: true, - ...(peering.contact ? { contact: peering.contact } : {}), - ...(peering.endpoint ? { endpoint: peering.endpoint } : {}), + ...(contact ? { contact } : {}), + ...(endpoint ? { endpoint } : {}), // NIP-11 convention: snake_case in the document, and 0/absent both mean // no proof-of-work requirement. min_pow_difficulty: peering.minRequestPowDifficulty ?? 0, diff --git a/src/utils/settings-config.ts b/src/utils/settings-config.ts index 50c2997e4..4abe1a960 100644 --- a/src/utils/settings-config.ts +++ b/src/utils/settings-config.ts @@ -843,10 +843,11 @@ const validatePeering = (settings: Settings, issues: ValidationIssue[]): void => } } - if (typeof peering.endpoint === 'string' && peering.endpoint.trim()) { + const endpoint = typeof peering.endpoint === 'string' ? peering.endpoint.trim() : '' + if (endpoint) { try { - const endpoint = new URL(peering.endpoint) - if (!['ws:', 'wss:'].includes(endpoint.protocol) || !endpoint.host) { + const parsed = new URL(endpoint) + if (!['ws:', 'wss:'].includes(parsed.protocol) || !parsed.host) { issues.push({ path: 'peering.endpoint', message: 'must be a ws:// or wss:// URL' }) } } catch { @@ -854,6 +855,20 @@ const validatePeering = (settings: Settings, issues: ValidationIssue[]): void => } } + // NIP-11 calls `contact` a URI. Reject free text so a relay does not publish + // an address other relays cannot use to reach the operator. + const contact = typeof peering.contact === 'string' ? peering.contact.trim() : '' + if (contact) { + try { + new URL(contact) + } catch { + issues.push({ + path: 'peering.contact', + message: 'must be a valid URI (for example mailto: or https:)', + }) + } + } + validatePeeringScopes(peering.scopes, issues) validatePeeringTerms(peering.terms, issues) @@ -865,9 +880,7 @@ const validatePeering = (settings: Settings, issues: ValidationIssue[]): void => issues.push({ path: 'peering.scopes', message: 'at least one scope is required when peering is enabled' }) } - const hasContact = typeof peering.contact === 'string' && peering.contact.trim().length > 0 - const hasEndpoint = typeof peering.endpoint === 'string' && peering.endpoint.trim().length > 0 - if (!hasContact && !hasEndpoint) { + if (!contact && !endpoint) { issues.push({ path: 'peering.contact', message: 'contact or endpoint is required when peering is enabled' }) } } diff --git a/test/unit/handlers/request-handlers/root-request-handler.spec.ts b/test/unit/handlers/request-handlers/root-request-handler.spec.ts index 30ceec7c1..1093f1c42 100644 --- a/test/unit/handlers/request-handlers/root-request-handler.spec.ts +++ b/test/unit/handlers/request-handlers/root-request-handler.spec.ts @@ -254,6 +254,18 @@ describe('rootRequestHandler', () => { expect(doc.peering).to.not.have.property('contact') expect(doc.peering.endpoint).to.equal('wss://relay.example.com') }) + + it('trims addresses and omits whitespace-only ones', () => { + const doc = sendWithPeering({ + enabled: true, + contact: ' mailto:peering@relay.example.com ', + endpoint: ' ', + scopes: [{ kinds: [1] }], + }) + + expect(doc.peering.contact).to.equal('mailto:peering@relay.example.com') + expect(doc.peering).to.not.have.property('endpoint') + }) }) // NIP-43 clients verify relay-signed events against `self`, so the document diff --git a/test/unit/utils/settings-config.spec.ts b/test/unit/utils/settings-config.spec.ts index 3f2517578..97b240659 100644 --- a/test/unit/utils/settings-config.spec.ts +++ b/test/unit/utils/settings-config.spec.ts @@ -164,6 +164,17 @@ describe('settings-config', () => { expect(issues.some((issue) => issue.path === 'peering.endpoint')).to.equal(true) }) + it('rejects a contact that is not a URI', () => { + const issues = peeringIssues({ ...settlementFreePeering, contact: 'not-a-uri' }) + + expect(issues.some((issue) => issue.path === 'peering.contact')).to.equal(true) + expect(issues.find((issue) => issue.path === 'peering.contact')?.message).to.include('valid URI') + }) + + it('accepts a mailto contact and ignores a whitespace-only endpoint', () => { + expect(peeringIssues({ ...settlementFreePeering, endpoint: ' ' })).to.deep.equal([]) + }) + it('rejects an empty kinds list', () => { const issues = peeringIssues({ ...settlementFreePeering, scopes: [{ kinds: [] }] })