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
5 changes: 5 additions & 0 deletions .changeset/loose-jeans-lead.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"nostream": patch
---

update NIP-11 relay info fields and CORS, with test and docs updates
4 changes: 4 additions & 0 deletions CONFIGURATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down
1 change: 0 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 1 addition & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
4 changes: 4 additions & 0 deletions resources/default-settings.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions src/@types/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
1 change: 1 addition & 0 deletions src/constants/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ export enum EventTags {
}

export const ALL_RELAYS = 'ALL_RELAYS'
export const DEFAULT_FILTER_LIMIT = 500

export enum PaymentsProcessors {
LNURL = 'lnurl',
Expand Down
29 changes: 27 additions & 2 deletions src/handlers/request-handlers/root-request-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -13,26 +14,44 @@ 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)
paymentsUrl.protocol = paymentsUrl.protocol === 'wss:' ? 'https:' : 'http:'
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
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👏


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,
Expand All @@ -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(
Expand All @@ -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)

Expand Down
3 changes: 2 additions & 1 deletion src/repositories/event-repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import {

import {
ContextMetadataKey,
DEFAULT_FILTER_LIMIT,
EventDeduplicationMetadataKey,
EventExpirationTimeMetadataKey,
EventKinds,
Expand Down Expand Up @@ -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) {
Expand Down
8 changes: 8 additions & 0 deletions test/integration/features/nip-11/nip-11.feature
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
30 changes: 30 additions & 0 deletions test/integration/features/nip-11/nip-11.feature.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'))
Expand Down Expand Up @@ -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<Record<string, any>>,
) {
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<Record<string, any>>) {
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)
})
77 changes: 77 additions & 0 deletions test/unit/handlers/request-handlers/root-request-handler.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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)

Expand All @@ -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', () => {
Expand Down
Loading