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/nip77-nip11-advertisement.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"nostream": minor
---

feat(nip77): advertise NIP-77 in NIP-11 supported_nips while it is active and expose its settings in the admin console
2 changes: 1 addition & 1 deletion CONFIGURATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -232,7 +232,7 @@ The settings below are listed in alphabetical order by name. Please keep this ta
| nip66.timeouts.nip11Ms | NIP-11 fetch timeout in milliseconds. Defaults to 10000. |
| nip66.timeouts.tlsMs | TLS probe timeout in milliseconds. Defaults to 10000. |
| nip66.timeouts.wsRttMs | WebSocket open RTT probe timeout in milliseconds. Defaults to 10000. |
| nip77.enabled | Enable NIP-77 (Negentropy) set-reconciliation sync, so clients and other relays can find which events differ without downloading them all. Defaults to false. Only answers when `eventStore.backend` is `postgres`; other backends bring their own negentropy implementation. When `nip42.restrictedReads` is enabled, reconciliation would list the ids of restricted events, so a `NEG-OPEN` filter that could match one (it names a restricted kind or no kinds at all) is only accepted when scoped by `authors` or `#p` to pubkeys the client has authenticated as, and is re-checked on every `NEG-MSG`; otherwise the session is closed with `NEG-ERR auth-required:`. Each open session holds the ids of every event matching its filter in memory, so size the limits below for your hardware. |
| nip77.enabled | Enable NIP-77 (Negentropy) set-reconciliation sync, so clients and other relays can find which events differ without downloading them all. Defaults to false. Only answers when `eventStore.backend` is `postgres`; other backends bring their own negentropy implementation. While active, the relay lists `77` in the NIP-11 `supported_nips`; it is left out when this is off or another backend is used. All `nip77.*` settings can be edited in the admin console and the CLI settings menu. When `nip42.restrictedReads` is enabled, reconciliation would list the ids of restricted events, so a `NEG-OPEN` filter that could match one (it names a restricted kind or no kinds at all) is only accepted when scoped by `authors` or `#p` to pubkeys the client has authenticated as, and is re-checked on every `NEG-MSG`; otherwise the session is closed with `NEG-ERR auth-required:`. Each open session holds the ids of every event matching its filter in memory, so size the limits below for your hardware. |
| nip77.maxEventsPerSession | Most events one session may cover. A `NEG-OPEN` whose filter matches more is rejected with `NEG-ERR blocked:` (clients should split the sync into `since`/`until` windows). The filter's `limit` is ignored, since reconciliation needs the whole set. Defaults to 100000. |
| nip77.maxSessionsPerConnection | Concurrent negentropy sessions one websocket connection may hold; opening an id that is already open replaces it. Further `NEG-OPEN`s are rejected with `NEG-ERR blocked:`. Defaults to 3. |
| nip77.sessionIdleTimeoutMs | A session that receives no `NEG-MSG` for this many milliseconds is closed with `NEG-ERR closed:`. Defaults to 60000. |
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,8 @@
44,
45,
50,
65
65,
77
],
"supportedNipExtensions": [],
"supportedMips": [
Expand Down
10 changes: 6 additions & 4 deletions src/handlers/request-handlers/root-request-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { DEFAULT_FILTER_LIMIT } from '../../constants/base'
import { fromBech32 } from '../../utils/transform'
import { getTemplate } from '../../utils/template-cache'
import { getPublicPathPrefix, joinPathPrefix } from '../../utils/http'
import { isNip77Active } from '../../utils/nip77'
import { resolveRelaySelfPubkey } from '../../utils/nip43'
import packageJson from '../../../package.json'

Expand Down Expand Up @@ -88,10 +89,11 @@ export const rootRequestHandler = (request: Request, response: Response, next: N
...(self !== undefined ? { self } : {}),
contact,
// NIP-43: clients MUST only send join requests to relays advertising 43,
// so only advertise it when the feature is actually enabled
supported_nips: settings.nip43?.enabled === true
? packageJson.supportedNips
: packageJson.supportedNips.filter((nip: number) => nip !== 43),
// so only advertise it when the feature is actually enabled. Likewise 77
// only while this relay answers NEG-OPEN itself (enabled, PostgreSQL backend).
supported_nips: packageJson.supportedNips.filter(
(nip: number) => (nip !== 43 || settings.nip43?.enabled === true) && (nip !== 77 || isNip77Active(settings)),
),
supported_nip_extensions: packageJson.supportedNipExtensions,
supported_mips: packageJson.supportedMips,
software: packageJson.repository.url,
Expand Down
37 changes: 37 additions & 0 deletions src/utils/settings-guided-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,24 @@ export const requireSafeNonNegativeIntegerSettingValue = (value: string): string
return result.success ? undefined : result.error.issues[0].message
}

const positiveSafeIntegerSettingValueSchema = z
.string()
.trim()
.regex(/^\d+$/, 'Value must be a positive integer')
.transform(Number)
.pipe(
z
.number()
.int()
.min(1, 'Value must be a positive integer')
.max(Number.MAX_SAFE_INTEGER, 'Value must be a safe integer'),
)

export const requireSafePositiveIntegerSettingValue = (value: string): string | undefined => {
const result = positiveSafeIntegerSettingValueSchema.safeParse(value)
return result.success ? undefined : result.error.issues[0].message
}

export const guidedSettingCategories: GuidedSettingCategory[] = [
{
value: 'relay-info',
Expand Down Expand Up @@ -254,6 +272,25 @@ export const guidedSettingCategories: GuidedSettingCategory[] = [
path: 'nip56.hideActionableReports',
type: 'boolean',
},
{ label: 'Enable NIP-77 negentropy sync (PostgreSQL backend only)', path: 'nip77.enabled', type: 'boolean' },
{
label: 'NIP-77 max events per session',
path: 'nip77.maxEventsPerSession',
type: 'number',
validate: requireSafePositiveIntegerSettingValue,
},
{
label: 'NIP-77 max sessions per connection',
path: 'nip77.maxSessionsPerConnection',
type: 'number',
validate: requireSafePositiveIntegerSettingValue,
},
{
label: 'NIP-77 session idle timeout (ms)',
path: 'nip77.sessionIdleTimeoutMs',
type: 'number',
validate: requireSafePositiveIntegerSettingValue,
},
],
},
{
Expand Down
6 changes: 3 additions & 3 deletions test/integration/features/nip-11/nip-11.feature.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,9 +67,9 @@ Then('the relay information document contains the required fields', function(thi

Then('the supported_nips field matches the NIPs declared in package.json', function(this: World<Record<string, any>>) {
const doc = this.parameters.httpResponse.data
// NIP-43 is only advertised when enabled in settings; the integration
// relay runs with NIP-43 disabled, so it is filtered from the document.
expect(doc.supported_nips).to.deep.equal(packageJson.supportedNips.filter((nip: number) => nip !== 43))
// NIP-43 and NIP-77 are only advertised when enabled in settings; the integration
// relay runs with both disabled, so they are filtered from the document.
expect(doc.supported_nips).to.deep.equal(packageJson.supportedNips.filter((nip: number) => nip !== 43 && nip !== 77))
})

Then('the response body is not a relay information document', function(this: World<Record<string, any>>) {
Expand Down
42 changes: 42 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 @@ -12,6 +12,7 @@ import {
rootRequestHandler,
} from '../../../../src/handlers/request-handlers/root-request-handler'
import { DEFAULT_FILTER_LIMIT } from '../../../../src/constants/base'
import * as eventStoreUtils from '../../../../src/utils/event-store'
import * as eventUtils from '../../../../src/utils/event'
import { toBech32 } from '../../../../src/utils/transform'

Expand Down Expand Up @@ -265,6 +266,47 @@ describe('rootRequestHandler', () => {
expect(doc.limitation.default_limit).to.equal(DEFAULT_FILTER_LIMIT)
})

describe('supported_nips and NIP-77', () => {
const advertised = (settings: any) => {
createSettingsStub.returns(settings)
rootRequestHandler(req, res, next)
return res.send.lastCall.args[0].supported_nips
}

it('advertises 77 when NIP-77 is enabled on the PostgreSQL backend', () => {
expect(advertised({ ...baseSettings, nip77: { enabled: true } })).to.include(77)
expect(
advertised({ ...baseSettings, nip77: { enabled: true }, eventStore: { backend: 'postgres' } }),
).to.include(77)
})

it('does not advertise 77 when NIP-77 is disabled or unset', () => {
expect(advertised(baseSettings)).to.not.include(77)
expect(advertised({ ...baseSettings, nip77: { enabled: false } })).to.not.include(77)
})

it('does not advertise 77 when another event store backend is used', () => {
// postgres is the only backend the settings accept today; simulate the strfry backend
// that will bring its own negentropy implementation.
const backendStub = sinon.stub(eventStoreUtils, 'getEventStoreBackend').returns('strfry' as any)
try {
expect(advertised({ ...baseSettings, nip77: { enabled: true } })).to.not.include(77)
} finally {
backendStub.restore()
}
})

it('still gates 43 on NIP-43 independently of 77', () => {
const both = advertised({ ...baseSettings, nip43: { enabled: true }, nip77: { enabled: true } })
expect(both).to.include(43)
expect(both).to.include(77)

const onlyNip77 = advertised({ ...baseSettings, nip77: { enabled: true } })
expect(onlyNip77).to.not.include(43)
expect(onlyNip77).to.include(77)
})
})

it('sets limitation.search_supported to false when NIP-50 is disabled', () => {
rootRequestHandler(req, res, next)

Expand Down
21 changes: 21 additions & 0 deletions test/unit/utils/settings-config.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
guidedSettingCategories,
requireNonEmptySettingValue,
requireSafeNonNegativeIntegerSettingValue,
requireSafePositiveIntegerSettingValue,
} from '../../../src/utils/settings-guided-schema'

describe('settings-config', () => {
Expand Down Expand Up @@ -446,6 +447,26 @@ describe('settings-guided-schema', () => {
expect(requireNonEmptySettingValue(' ')).to.equal('Value is required')
})

it('validates guided positive integer fields', () => {
expect(requireSafePositiveIntegerSettingValue('bad')).to.equal('Value must be a positive integer')
expect(requireSafePositiveIntegerSettingValue('-1')).to.equal('Value must be a positive integer')
expect(requireSafePositiveIntegerSettingValue('0')).to.equal('Value must be a positive integer')
expect(requireSafePositiveIntegerSettingValue('9007199254740993')).to.equal('Value must be a safe integer')
expect(requireSafePositiveIntegerSettingValue(' 60000 ')).to.equal(undefined)
})

it('exposes the nip77 settings with positive integer validation', () => {
const category = 'nip-features'

expect(getGuidedSettingField(category, 'nip77.enabled')?.type).to.equal('boolean')
for (const key of ['maxEventsPerSession', 'maxSessionsPerConnection', 'sessionIdleTimeoutMs']) {
const field = getGuidedSettingField(category, `nip77.${key}`)
expect(field?.type, key).to.equal('number')
expect(field?.validate?.('0'), key).to.equal('Value must be a positive integer')
expect(field?.validate?.('3'), key).to.equal(undefined)
}
})

it('does not create settings.yaml when ensuring config dir exists', () => {
const originalConfigDir = process.env.NOSTR_CONFIG_DIR
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'nostream-settings-config-'))
Expand Down
Loading