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. diff --git a/CONFIGURATION.md b/CONFIGURATION.md index 03299fb4e..8d194fc05 100644 --- a/CONFIGURATION.md +++ b/CONFIGURATION.md @@ -282,9 +282,51 @@ 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. | | 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) + +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. 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 f4ea38446..2be6efa5c 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' @@ -42,6 +42,35 @@ 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 } + } + + const contact = peering.contact?.trim() + const endpoint = peering.endpoint?.trim() + + return { + enabled: true, + ...(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, + 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) @@ -177,6 +206,7 @@ export const rootRequestHandler = (request: Request, response: Response, next: N } : {}), }, + peering: buildPeeringDocument(settings.peering), } response diff --git a/src/utils/settings-config.ts b/src/utils/settings-config.ts index de7e94a9a..4abe1a960 100644 --- a/src/utils/settings-config.ts +++ b/src/utils/settings-config.ts @@ -688,6 +688,203 @@ 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}`, + }) + } + } + + const endpoint = typeof peering.endpoint === 'string' ? peering.endpoint.trim() : '' + if (endpoint) { + try { + 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 { + issues.push({ path: 'peering.endpoint', message: 'must be a valid ws:// or wss:// URL' }) + } + } + + // 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) + + 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' }) + } + + if (!contact && !endpoint) { + 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 +961,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/integration/features/nip-11/nip-11.feature b/test/integration/features/nip-11/nip-11.feature index d6972e8ab..ca6a13279 100644 --- a/test/integration/features/nip-11/nip-11.feature +++ b/test/integration/features/nip-11/nip-11.feature @@ -31,6 +31,25 @@ Feature: NIP-11 When a client requests the relay information document Then the limitation object contains a max_filters field + Scenario: Relay information document advertises configured peering terms + When a client requests the relay information document + Then the peering object matches the configured peering settings + + Scenario: Settings validation accepts a complete peering configuration + When peering settings are validated with a complete configuration + Then the peering settings validation reports no issues + + Scenario: Settings validation rejects invalid peering configuration + When peering settings are validated with an invalid configuration + Then the peering settings validation reports an issue at "peering.contact" + And the peering settings validation reports an issue at "peering.endpoint" + And the peering settings validation reports an issue at "peering.minRequestPowDifficulty" + And the peering settings validation reports an issue at "peering.scopes" + And the peering settings validation reports an issue at "peering.scopes[0]" + And the peering settings validation reports an issue at "peering.scopes[0].kinds[0]" + And the peering settings validation reports an issue at "peering.terms[0].amount" + And the peering settings validation reports an issue at "peering.terms[0].period" + Scenario: WebSocket connections coexist with HTTP on the same port Given someone called Alice When Alice sends a text_note event with content "nostr is great" diff --git a/test/integration/features/nip-11/nip-11.feature.ts b/test/integration/features/nip-11/nip-11.feature.ts index 0840e51fe..728a830f4 100644 --- a/test/integration/features/nip-11/nip-11.feature.ts +++ b/test/integration/features/nip-11/nip-11.feature.ts @@ -3,8 +3,10 @@ import axios, { AxiosResponse } from 'axios' import chai from 'chai' import packageJson from '../../../../package.json' +import { Settings } from '../../../../src/@types/settings' import { DEFAULT_FILTER_LIMIT } from '../../../../src/constants/base' import { createSettings } from '../../../../src/factories/settings-factory' +import { validateSettings } from '../../../../src/utils/settings-config' chai.use(require('sinon-chai')) const { expect } = chai @@ -84,6 +86,71 @@ Then('the limitation object contains a max_filters field', function(this: World< expect(doc.limitation.max_filters).to.equal(expectedMaxFilters) }) +Then('the peering object matches the configured peering settings', function(this: World>) { + const doc = this.parameters.httpResponse.data + expect(doc.peering).to.deep.equal({ + enabled: true, + contact: 'mailto:peering@example.com', + endpoint: 'wss://relay.example.com', + min_pow_difficulty: 16, + scopes: [ + { kinds: [1, 6], window_seconds: 3600 }, + { kinds: [3], since: 1700000000 }, + ], + terms: [{ amount: 1000, unit: 'msats', period: 2592000 }], + }) +}) + +const peeringIssuesFor = (peering: unknown) => + validateSettings({ ...createSettings(), peering } as Settings).filter((issue) => issue.path.startsWith('peering')) + +When('peering settings are validated with a complete configuration', function(this: World>) { + this.parameters.peeringValidationIssues = peeringIssuesFor({ + enabled: true, + contact: 'mailto:peering@example.com', + endpoint: 'wss://relay.example.com', + minRequestPowDifficulty: 16, + scopes: [ + { kinds: [1, [10000, 19999]], windowSeconds: 3600 }, + { kinds: [3], since: 1700000000, until: 1800000000 }, + ], + terms: [{ amount: 1000, unit: 'msats', period: 2592000 }], + }) +}) + +When('peering settings are validated with an invalid configuration', function(this: World>) { + this.parameters.peeringValidationIssues = [ + { + enabled: true, + contact: 'not-a-uri', + endpoint: 'not-a-websocket', + minRequestPowDifficulty: 300, + scopes: [], + }, + { + enabled: true, + contact: '', + endpoint: '', + scopes: [{ kinds: [65536, [100, 1]], since: 200, until: 100, windowSeconds: 0, extra: true }], + terms: [{ amount: -1, unit: '', period: 0, extra: true }], + }, + ].flatMap(peeringIssuesFor) +}) + +Then('the peering settings validation reports no issues', function(this: World>) { + expect(this.parameters.peeringValidationIssues).to.deep.equal([]) +}) + +Then('the peering settings validation reports an issue at {string}', function( + this: World>, + path: string, +) { + expect( + this.parameters.peeringValidationIssues.some((issue: { path: string }) => issue.path === path), + `expected a validation issue at "${path}"`, + ).to.equal(true) +}) + Then('the relay information response includes required NIP-11 CORS headers', function( this: World>, ) { diff --git a/test/integration/settings.yaml b/test/integration/settings.yaml index 469f5d619..79dbe9a61 100644 --- a/test/integration/settings.yaml +++ b/test/integration/settings.yaml @@ -5,3 +5,17 @@ nip50: enabled: true language: simple maxQueryLength: 256 +peering: + enabled: true + contact: mailto:peering@example.com + endpoint: wss://relay.example.com + minRequestPowDifficulty: 16 + scopes: + - kinds: [1, 6] + windowSeconds: 3600 + - kinds: [3] + since: 1700000000 + terms: + - amount: 1000 + unit: msats + period: 2592000 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 1e1a54e96..107fb02f6 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,90 @@ 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') + }) + + 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 // 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..97b240659 100644 --- a/test/unit/utils/settings-config.spec.ts +++ b/test/unit/utils/settings-config.spec.ts @@ -99,6 +99,154 @@ 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 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: [] }] }) + + 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' },