From d9180f58b7bb74fbca849d780376d74d08916e94 Mon Sep 17 00:00:00 2001 From: Priyanshubhartistm Date: Fri, 16 Oct 2026 00:40:11 +0530 Subject: [PATCH 1/2] feat(nip77): advertise NIP-77 in NIP-11 supported_nips when active Signed-off-by: Priyanshubhartistm --- .changeset/nip77-nip11-advertisement.md | 5 +++ package.json | 3 +- .../request-handlers/root-request-handler.ts | 10 +++-- .../features/nip-11/nip-11.feature.ts | 6 +-- .../root-request-handler.spec.ts | 42 +++++++++++++++++++ 5 files changed, 58 insertions(+), 8 deletions(-) create mode 100644 .changeset/nip77-nip11-advertisement.md diff --git a/.changeset/nip77-nip11-advertisement.md b/.changeset/nip77-nip11-advertisement.md new file mode 100644 index 000000000..a123dc20d --- /dev/null +++ b/.changeset/nip77-nip11-advertisement.md @@ -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 diff --git a/package.json b/package.json index 6b4a3d11e..c6b9a6194 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,8 @@ 44, 45, 50, - 65 + 65, + 77 ], "supportedNipExtensions": [], "supportedMips": [ diff --git a/src/handlers/request-handlers/root-request-handler.ts b/src/handlers/request-handlers/root-request-handler.ts index 6a5eeaf6f..7667962d3 100644 --- a/src/handlers/request-handlers/root-request-handler.ts +++ b/src/handlers/request-handlers/root-request-handler.ts @@ -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' @@ -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, diff --git a/test/integration/features/nip-11/nip-11.feature.ts b/test/integration/features/nip-11/nip-11.feature.ts index dd706f6a0..0840e51fe 100644 --- a/test/integration/features/nip-11/nip-11.feature.ts +++ b/test/integration/features/nip-11/nip-11.feature.ts @@ -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>) { 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>) { 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 838e6588e..547025b90 100644 --- a/test/unit/handlers/request-handlers/root-request-handler.spec.ts +++ b/test/unit/handlers/request-handlers/root-request-handler.spec.ts @@ -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' @@ -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) From 9aaaaff623a8f01f357e0c371661f4203789b712 Mon Sep 17 00:00:00 2001 From: Priyanshubhartistm Date: Fri, 16 Oct 2026 00:41:47 +0530 Subject: [PATCH 2/2] feat(nip77): expose nip77 settings in the admin console Signed-off-by: Priyanshubhartistm --- CONFIGURATION.md | 2 +- src/utils/settings-guided-schema.ts | 37 +++++++++++++++++++++++++ test/unit/utils/settings-config.spec.ts | 21 ++++++++++++++ 3 files changed, 59 insertions(+), 1 deletion(-) diff --git a/CONFIGURATION.md b/CONFIGURATION.md index 21be4c16b..735454bff 100644 --- a/CONFIGURATION.md +++ b/CONFIGURATION.md @@ -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. | diff --git a/src/utils/settings-guided-schema.ts b/src/utils/settings-guided-schema.ts index 109e5135b..d1a1512bc 100644 --- a/src/utils/settings-guided-schema.ts +++ b/src/utils/settings-guided-schema.ts @@ -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', @@ -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, + }, ], }, { diff --git a/test/unit/utils/settings-config.spec.ts b/test/unit/utils/settings-config.spec.ts index d0c285d86..6f79dc773 100644 --- a/test/unit/utils/settings-config.spec.ts +++ b/test/unit/utils/settings-config.spec.ts @@ -22,6 +22,7 @@ import { guidedSettingCategories, requireNonEmptySettingValue, requireSafeNonNegativeIntegerSettingValue, + requireSafePositiveIntegerSettingValue, } from '../../../src/utils/settings-guided-schema' describe('settings-config', () => { @@ -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-'))