diff --git a/.changeset/loose-jeans-lead.md b/.changeset/loose-jeans-lead.md new file mode 100644 index 00000000..a66fe65d --- /dev/null +++ b/.changeset/loose-jeans-lead.md @@ -0,0 +1,5 @@ +--- +"nostream": patch +--- + +update NIP-11 relay info fields and CORS, with test and docs updates diff --git a/CONFIGURATION.md b/CONFIGURATION.md index df63db91..002c9974 100644 --- a/CONFIGURATION.md +++ b/CONFIGURATION.md @@ -127,11 +127,15 @@ The settings below are listed in alphabetical order by name. Please keep this ta | Name | Description | |---------------------------------------------|-------------------------------------------------------------------------------| +| info.banner | Public banner image URL for the relay information document. | | info.contact | Relay operator's contact. (e.g. mailto:operator@relay-your-domain.com) | | info.description | Public description of your relay. (e.g. Toronto Bitcoin Group Public Relay) | +| info.icon | Public icon image URL for the relay information document. | | info.name | Public name of your relay. (e.g. TBG's Public Relay) | | info.pubkey | Relay operator's Nostr pubkey in hex format. | | info.relay_url | Public-facing URL of your relay. (e.g. wss://relay.your-domain.com) | +| info.self | Relay pubkey in hex format for the relay information document `self` field. | +| info.terms_of_service | Public URL to relay terms of service. | | limits.admissionCheck.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. | diff --git a/README.md b/README.md index 41a12e19..744e1a8f 100644 --- a/README.md +++ b/README.md @@ -49,7 +49,6 @@ NIPs with a relay-specific implementation are listed here. - [x] NIP-05: Mapping Nostr keys to DNS-based internet identifiers - [x] NIP-09: Event deletion - [x] NIP-11: Relay information document -- [x] NIP-11a: Relay Information Document Extensions - [x] NIP-12: Generic tag queries - [x] NIP-13: Proof of Work - [x] NIP-15: End of Stored Events Notice diff --git a/package.json b/package.json index b25f5241..f7bb1a63 100644 --- a/package.json +++ b/package.json @@ -21,9 +21,7 @@ 44, 45 ], - "supportedNipExtensions": [ - "11a" - ], + "supportedNipExtensions": [], "main": "src/index.ts", "scripts": { "dev": "node --env-file-if-exists=.env -r ts-node/register src/index.ts", diff --git a/resources/default-settings.yaml b/resources/default-settings.yaml index 5a1ed5d9..4e41716b 100755 --- a/resources/default-settings.yaml +++ b/resources/default-settings.yaml @@ -2,8 +2,12 @@ info: relay_url: wss://nostream.your-domain.com name: nostream.your-domain.com description: A nostr relay written in Typescript. + banner: https://nostream.your-domain.com/banner.png + icon: https://nostream.your-domain.com/icon.png pubkey: replace-with-your-pubkey-in-hex + self: replace-with-your-relay-pubkey-in-hex contact: mailto:operator@your-domain.com + terms_of_service: https://nostream.your-domain.com/terms payments: enabled: false processor: zebedee diff --git a/src/@types/settings.ts b/src/@types/settings.ts index 4e8a2075..55365ebc 100644 --- a/src/@types/settings.ts +++ b/src/@types/settings.ts @@ -8,7 +8,11 @@ export interface Info { name: string description: string pubkey: string + banner?: string + icon?: string + self?: string contact: string + terms_of_service?: string } export interface Network { diff --git a/src/constants/base.ts b/src/constants/base.ts index 057bef21..4c8c6cf6 100644 --- a/src/constants/base.ts +++ b/src/constants/base.ts @@ -55,6 +55,7 @@ export enum EventTags { } export const ALL_RELAYS = 'ALL_RELAYS' +export const DEFAULT_FILTER_LIMIT = 500 export enum PaymentsProcessors { LNURL = 'lnurl', diff --git a/src/handlers/request-handlers/root-request-handler.ts b/src/handlers/request-handlers/root-request-handler.ts index d3502fa3..19e39fd4 100644 --- a/src/handlers/request-handlers/root-request-handler.ts +++ b/src/handlers/request-handlers/root-request-handler.ts @@ -4,6 +4,7 @@ import { path, pathEq } from 'ramda' import { createSettings } from '../../factories/settings-factory' import { escapeHtml } from '../../utils/html' import { FeeSchedule } from '../../@types/settings' +import { DEFAULT_FILTER_LIMIT } from '../../constants/base' import { fromBech32 } from '../../utils/transform' import { getTemplate } from '../../utils/template-cache' import packageJson from '../../../package.json' @@ -13,7 +14,7 @@ export const rootRequestHandler = (request: Request, response: Response, next: N if (accepts(request).type(['application/nostr+json'])) { const { - info: { name, description, pubkey: rawPubkey, contact, relay_url }, + info: { name, description, banner, icon, pubkey: rawPubkey, self: rawSelf, contact, relay_url, terms_of_service }, } = settings const paymentsUrl = new URL(relay_url) @@ -21,18 +22,36 @@ export const rootRequestHandler = (request: Request, response: Response, next: N paymentsUrl.pathname = '/invoices' const content = settings.limits?.event?.content + const eventLimits = settings.limits?.event + const createdAtLimits = eventLimits?.createdAt + const hasAdmissionRestriction = + settings.payments?.enabled === true && + Boolean(settings.payments?.feeSchedules?.admission?.some((feeSchedule) => feeSchedule.enabled)) + const hasWriteRestriction = + hasAdmissionRestriction || + (eventLimits?.eventId?.minLeadingZeroBits ?? 0) > 0 || + (eventLimits?.pubkey?.minLeadingZeroBits ?? 0) > 0 || + (eventLimits?.pubkey?.whitelist?.length ?? 0) > 0 || + (eventLimits?.pubkey?.blacklist?.length ?? 0) > 0 || + (eventLimits?.kind?.whitelist?.length ?? 0) > 0 || + (eventLimits?.kind?.blacklist?.length ?? 0) > 0 const pubkey = rawPubkey.startsWith('npub1') ? fromBech32(rawPubkey) : rawPubkey + const self = rawSelf?.startsWith('npub1') ? fromBech32(rawSelf) : rawSelf const relayInformationDocument = { name, description, + ...(banner !== undefined ? { banner } : {}), + ...(icon !== undefined ? { icon } : {}), pubkey, + ...(self !== undefined ? { self } : {}), contact, supported_nips: packageJson.supportedNips, supported_nip_extensions: packageJson.supportedNipExtensions, software: packageJson.repository.url, version: packageJson.version, + ...(terms_of_service !== undefined ? { terms_of_service } : {}), limitation: { max_message_length: settings.network.maxPayloadSize, max_subscriptions: settings.limits?.client?.subscription?.maxSubscriptions, @@ -44,9 +63,13 @@ export const rootRequestHandler = (request: Request, response: Response, next: N max_content_length: Array.isArray(content) ? content[0].maxLength // best guess since we have per-kind limits : content?.maxLength, - min_pow_difficulty: settings.limits?.event?.eventId?.minLeadingZeroBits, + min_pow_difficulty: eventLimits?.eventId?.minLeadingZeroBits, auth_required: false, payment_required: settings.payments?.enabled, + created_at_lower_limit: createdAtLimits?.maxNegativeDelta, + created_at_upper_limit: createdAtLimits?.maxPositiveDelta, + default_limit: DEFAULT_FILTER_LIMIT, + restricted_writes: hasWriteRestriction, }, payments_url: paymentsUrl.toString(), fees: Object.getOwnPropertyNames(settings.payments.feeSchedules).reduce( @@ -68,6 +91,8 @@ export const rootRequestHandler = (request: Request, response: Response, next: N response .setHeader('content-type', 'application/nostr+json') .setHeader('access-control-allow-origin', '*') + .setHeader('access-control-allow-headers', '*') + .setHeader('access-control-allow-methods', 'GET, OPTIONS') .status(200) .send(relayInformationDocument) diff --git a/src/repositories/event-repository.ts b/src/repositories/event-repository.ts index 03c54519..56da579d 100644 --- a/src/repositories/event-repository.ts +++ b/src/repositories/event-repository.ts @@ -30,6 +30,7 @@ import { import { ContextMetadataKey, + DEFAULT_FILTER_LIMIT, EventDeduplicationMetadataKey, EventExpirationTimeMetadataKey, EventKinds, @@ -76,7 +77,7 @@ export class EventRepository implements IEventRepository { if (typeof currentFilter.limit === 'number') { builder.limit(currentFilter.limit).orderBy('event_created_at', 'DESC').orderBy('event_id', 'asc') } else { - builder.limit(500).orderBy('event_created_at', 'asc').orderBy('event_id', 'asc') + builder.limit(DEFAULT_FILTER_LIMIT).orderBy('event_created_at', 'asc').orderBy('event_id', 'asc') } if (isTagQuery) { diff --git a/test/integration/features/nip-11/nip-11.feature b/test/integration/features/nip-11/nip-11.feature index 6e1bd4c0..6ae6bf1b 100644 --- a/test/integration/features/nip-11/nip-11.feature +++ b/test/integration/features/nip-11/nip-11.feature @@ -9,6 +9,14 @@ Feature: NIP-11 When a client requests the relay information document Then the supported_nips field matches the NIPs declared in package.json + Scenario: Relay information response includes required CORS headers + When a client requests the relay information document + Then the relay information response includes required NIP-11 CORS headers + + Scenario: Relay information document includes NIP-11 limitation parity fields + When a client requests the relay information document + Then the limitation object contains NIP-11 parity fields and values + Scenario: Relay does not return information document for a non-NIP-11 Accept header When a client requests the root path with Accept header "text/html" Then the response Content-Type does not include "application/nostr+json" diff --git a/test/integration/features/nip-11/nip-11.feature.ts b/test/integration/features/nip-11/nip-11.feature.ts index 79aa1e13..a8fb3d50 100644 --- a/test/integration/features/nip-11/nip-11.feature.ts +++ b/test/integration/features/nip-11/nip-11.feature.ts @@ -3,6 +3,7 @@ import axios, { AxiosResponse } from 'axios' import chai from 'chai' import packageJson from '../../../../package.json' +import { DEFAULT_FILTER_LIMIT } from '../../../../src/constants/base' import { createSettings } from '../../../../src/factories/settings-factory' chai.use(require('sinon-chai')) @@ -70,3 +71,32 @@ Then('the limitation object contains a max_filters field', function(this: World< const expectedMaxFilters = createSettings().limits?.client?.subscription?.maxFilters expect(doc.limitation.max_filters).to.equal(expectedMaxFilters) }) + +Then('the relay information response includes required NIP-11 CORS headers', function( + this: World>, +) { + const headers = this.parameters.httpResponse.headers + expect(headers['access-control-allow-origin']).to.equal('*') + expect(headers['access-control-allow-headers']).to.equal('*') + expect(headers['access-control-allow-methods']).to.equal('GET, OPTIONS') +}) + +Then('the limitation object contains NIP-11 parity fields and values', function(this: World>) { + const doc = this.parameters.httpResponse.data + const settings = createSettings() + const eventLimits = settings.limits?.event + + const expectedRestrictedWrites = + Boolean(settings.payments?.enabled && settings.payments?.feeSchedules?.admission?.some((fee) => fee.enabled)) || + (eventLimits?.eventId?.minLeadingZeroBits ?? 0) > 0 || + (eventLimits?.pubkey?.minLeadingZeroBits ?? 0) > 0 || + (eventLimits?.pubkey?.whitelist?.length ?? 0) > 0 || + (eventLimits?.pubkey?.blacklist?.length ?? 0) > 0 || + (eventLimits?.kind?.whitelist?.length ?? 0) > 0 || + (eventLimits?.kind?.blacklist?.length ?? 0) > 0 + + expect(doc.limitation.created_at_lower_limit).to.equal(eventLimits?.createdAt?.maxNegativeDelta) + expect(doc.limitation.created_at_upper_limit).to.equal(eventLimits?.createdAt?.maxPositiveDelta) + expect(doc.limitation.default_limit).to.equal(DEFAULT_FILTER_LIMIT) + expect(doc.limitation.restricted_writes).to.equal(expectedRestrictedWrites) +}) 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 d10c2e0b..49d5503d 100644 --- a/test/unit/handlers/request-handlers/root-request-handler.spec.ts +++ b/test/unit/handlers/request-handlers/root-request-handler.spec.ts @@ -7,6 +7,7 @@ const { expect } = chai import * as settingsFactory from '../../../../src/factories/settings-factory' import * as templateCache from '../../../../src/utils/template-cache' +import { DEFAULT_FILTER_LIMIT } from '../../../../src/constants/base' import { rootRequestHandler } from '../../../../src/handlers/request-handlers/root-request-handler' const baseSettings = { @@ -83,6 +84,14 @@ describe('rootRequestHandler', () => { expect(res.status).to.have.been.calledWith(200) }) + it('sets required NIP-11 CORS headers', () => { + rootRequestHandler(req, res, next) + + expect(res.setHeader).to.have.been.calledWith('access-control-allow-origin', '*') + expect(res.setHeader).to.have.been.calledWith('access-control-allow-headers', '*') + expect(res.setHeader).to.have.been.calledWith('access-control-allow-methods', 'GET, OPTIONS') + }) + it('includes the relay name in the response', () => { rootRequestHandler(req, res, next) @@ -95,6 +104,74 @@ describe('rootRequestHandler', () => { expect(getTemplateStub).to.not.have.been.called }) + + it('includes optional NIP-11 fields when configured', () => { + createSettingsStub.returns({ + ...baseSettings, + info: { + ...baseSettings.info, + banner: 'https://relay.example.com/banner.png', + icon: 'https://relay.example.com/icon.png', + self: 'f'.repeat(64), + terms_of_service: 'https://relay.example.com/terms', + }, + }) + + rootRequestHandler(req, res, next) + + const doc = res.send.firstCall.args[0] + expect(doc.banner).to.equal('https://relay.example.com/banner.png') + expect(doc.icon).to.equal('https://relay.example.com/icon.png') + expect(doc.self).to.equal('f'.repeat(64)) + expect(doc.terms_of_service).to.equal('https://relay.example.com/terms') + }) + + it('does not include optional NIP-11 fields when not configured', () => { + rootRequestHandler(req, res, next) + + const doc = res.send.firstCall.args[0] + expect(doc).to.not.have.property('banner') + expect(doc).to.not.have.property('icon') + expect(doc).to.not.have.property('self') + expect(doc).to.not.have.property('terms_of_service') + }) + + it('includes NIP-11 limitation created_at and default_limit fields', () => { + createSettingsStub.returns({ + ...baseSettings, + limits: { + ...baseSettings.limits, + event: { + ...baseSettings.limits.event, + createdAt: { + maxNegativeDelta: 86400, + maxPositiveDelta: 300, + }, + }, + }, + }) + + rootRequestHandler(req, res, next) + + const doc = res.send.firstCall.args[0] + expect(doc.limitation.created_at_lower_limit).to.equal(86400) + expect(doc.limitation.created_at_upper_limit).to.equal(300) + expect(doc.limitation.default_limit).to.equal(DEFAULT_FILTER_LIMIT) + }) + + it('sets limitation.restricted_writes based on active write restrictions', () => { + rootRequestHandler(req, res, next) + const defaultDoc = res.send.firstCall.args[0] + expect(defaultDoc.limitation.restricted_writes).to.equal(false) + + res.send.resetHistory() + createSettingsStub.returns(settingsWithFee) + + rootRequestHandler(req, res, next) + + const restrictedDoc = res.send.firstCall.args[0] + expect(restrictedDoc.limitation.restricted_writes).to.equal(true) + }) }) describe('when serving HTML', () => {