From ea52a301082e4ec3fc8e6b87435fed3a5df84b97 Mon Sep 17 00:00:00 2001 From: anshumancanrock Date: Mon, 27 Apr 2026 01:42:25 +0530 Subject: [PATCH 01/12] feat: implement NIP-50 full-text search support --- .changeset/nip-50-search.md | 17 ++++ CONFIGURATION.md | 3 + .../20260427_000000_add_nip50_fts_index.js | 11 +++ package.json | 3 +- resources/default-settings.yaml | 5 + src/@types/settings.ts | 7 ++ src/@types/subscription.ts | 1 + src/factories/worker-factory.ts | 2 +- .../request-handlers/root-request-handler.ts | 1 + src/handlers/subscribe-message-handler.ts | 8 +- src/repositories/event-repository.ts | 60 ++++++++++-- src/schemas/filter-schema.ts | 4 +- src/utils/event.ts | 9 ++ .../features/nip-50/nip-50.feature | 22 +++++ .../features/nip-50/nip-50.feature.ts | 63 ++++++++++++ test/integration/features/shared.ts | 1 + .../root-request-handler.spec.ts | 19 ++++ .../repositories/event-repository.spec.ts | 98 +++++++++++++++++++ test/unit/schemas/filter-schema.spec.ts | 45 +++++++++ test/unit/utils/event.spec.ts | 53 ++++++++++ 20 files changed, 420 insertions(+), 12 deletions(-) create mode 100644 .changeset/nip-50-search.md create mode 100644 migrations/20260427_000000_add_nip50_fts_index.js create mode 100644 test/integration/features/nip-50/nip-50.feature create mode 100644 test/integration/features/nip-50/nip-50.feature.ts diff --git a/.changeset/nip-50-search.md b/.changeset/nip-50-search.md new file mode 100644 index 00000000..fcf4e998 --- /dev/null +++ b/.changeset/nip-50-search.md @@ -0,0 +1,17 @@ +--- +"nostream": major +--- + +Add NIP-50 full-text search support with PostgreSQL `tsvector`/`GIN` indexing. + +Clients can now include a `search` field in REQ filter objects to perform full-text +queries against event content. Results are ranked by relevance (`ts_rank`) instead +of the usual `created_at` ordering, per the NIP-50 specification. + +Features: +- New `search` filter field accepted in REQ messages +- PostgreSQL GIN index on `to_tsvector('simple', event_content)` for fast full-text lookups +- Configurable text-search language (defaults to `simple`, supports `english`, `spanish`, etc.) +- Configurable max search query length for abuse prevention +- NIP-50 listed in NIP-11 relay information document +- Search can be combined with all existing filter fields (kinds, authors, tags, etc.) diff --git a/CONFIGURATION.md b/CONFIGURATION.md index 5091ea2f..fe8d9e30 100644 --- a/CONFIGURATION.md +++ b/CONFIGURATION.md @@ -179,6 +179,9 @@ The settings below are listed in alphabetical order by name. Please keep this ta | nip05.verifyExpiration | Time in milliseconds before a successful NIP-05 verification expires and needs re-checking. Defaults to 604800000 (1 week). | | nip05.verifyUpdateFrequency | Minimum interval in milliseconds between re-verification attempts for a given author. Defaults to 86400000 (24 hours). | | nip45.enabled | Enable or disable NIP-45 COUNT handling. Defaults to true. | +| nip50.enabled | Enable or disable NIP-50 full-text search. Defaults to false. When enabled, clients can include a `search` field in REQ filters to perform text queries against event content. Requires the GIN full-text index migration. | +| nip50.language | PostgreSQL text-search configuration name. Defaults to `simple` (language-agnostic tokenization). Set to `english`, `spanish`, etc. for stemming support. See [PostgreSQL text search configurations](https://www.postgresql.org/docs/current/textsearch-configuration.html). | +| nip50.maxQueryLength | Maximum length of the search query string. Queries exceeding this are truncated. Defaults to 256. | | paymentProcessors.lnbits.baseURL | Base URL of your Lnbits instance. | | paymentProcessors.lnbits.callbackBaseURL | Public-facing Nostream's Lnbits Callback URL. (e.g. https://relay.your-domain.com/callbacks/lnbits) | | paymentProcessors.lnurl.invoiceURL | [LUD-06 Pay Request](https://github.com/lnurl/luds/blob/luds/06.md) provider URL. (e.g. https://getalby.com/lnurlp/your-username) | diff --git a/migrations/20260427_000000_add_nip50_fts_index.js b/migrations/20260427_000000_add_nip50_fts_index.js new file mode 100644 index 00000000..468cc102 --- /dev/null +++ b/migrations/20260427_000000_add_nip50_fts_index.js @@ -0,0 +1,11 @@ +exports.config = { transaction: false } + +exports.up = function (knex) { + return knex.raw( + "CREATE INDEX CONCURRENTLY IF NOT EXISTS events_content_fts_idx ON events USING gin (to_tsvector('simple', event_content))", + ) +} + +exports.down = function (knex) { + return knex.raw('DROP INDEX CONCURRENTLY IF EXISTS events_content_fts_idx') +} diff --git a/package.json b/package.json index 675394da..d21ec601 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,8 @@ 33, 40, 44, - 45 + 45, + 50 ], "supportedNipExtensions": [], "main": "src/index.ts", diff --git a/resources/default-settings.yaml b/resources/default-settings.yaml index 6cdfebb7..85354d64 100755 --- a/resources/default-settings.yaml +++ b/resources/default-settings.yaml @@ -62,6 +62,11 @@ nip05: domainBlacklist: [] nip45: enabled: true +nip50: + enabled: false + # 'simple' (no stemming) or a language name like 'english', 'spanish' + language: simple + maxQueryLength: 256 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 d4463dc9..5af4889b 100644 --- a/src/@types/settings.ts +++ b/src/@types/settings.ts @@ -245,6 +245,12 @@ export interface Nip45Settings { enabled?: boolean } +export interface Nip50Settings { + enabled?: boolean + language?: string + maxQueryLength?: number +} + export interface Nip05Settings { mode: Nip05Mode /** @@ -276,4 +282,5 @@ export interface Settings { mirroring?: Mirroring nip05?: Nip05Settings nip45?: Nip45Settings + nip50?: Nip50Settings } diff --git a/src/@types/subscription.ts b/src/@types/subscription.ts index 265506d5..8f548ecf 100644 --- a/src/@types/subscription.ts +++ b/src/@types/subscription.ts @@ -10,5 +10,6 @@ export interface SubscriptionFilter { until?: number authors?: Pubkey[] limit?: number + search?: string [key: `#${string}`]: string[] } diff --git a/src/factories/worker-factory.ts b/src/factories/worker-factory.ts index 5d9fe549..b893cde8 100644 --- a/src/factories/worker-factory.ts +++ b/src/factories/worker-factory.ts @@ -19,7 +19,7 @@ const logger = createLogger('worker-factory') export const workerFactory = (): AppWorker => { const dbClient = getMasterDbClient() const readReplicaDbClient = getReadReplicaDbClient() - const eventRepository = new EventRepository(dbClient, readReplicaDbClient) + const eventRepository = new EventRepository(dbClient, readReplicaDbClient, createSettings) const userRepository = new UserRepository(dbClient, eventRepository) const nip05VerificationRepository = new Nip05VerificationRepository(dbClient) diff --git a/src/handlers/request-handlers/root-request-handler.ts b/src/handlers/request-handlers/root-request-handler.ts index 19e39fd4..20a5a60a 100644 --- a/src/handlers/request-handlers/root-request-handler.ts +++ b/src/handlers/request-handlers/root-request-handler.ts @@ -70,6 +70,7 @@ export const rootRequestHandler = (request: Request, response: Response, next: N created_at_upper_limit: createdAtLimits?.maxPositiveDelta, default_limit: DEFAULT_FILTER_LIMIT, restricted_writes: hasWriteRestriction, + search_supported: settings.nip50?.enabled ?? false, }, payments_url: paymentsUrl.toString(), fees: Object.getOwnPropertyNames(settings.payments.feeSchedules).reduce( diff --git a/src/handlers/subscribe-message-handler.ts b/src/handlers/subscribe-message-handler.ts index 6d5c1e1e..5df64aca 100644 --- a/src/handlers/subscribe-message-handler.ts +++ b/src/handlers/subscribe-message-handler.ts @@ -1,4 +1,4 @@ -import { anyPass, equals, isNil, map, propSatisfies, uniqWith } from 'ramda' +import { anyPass, equals, isNil, map, omit, propSatisfies, uniqWith } from 'ramda' // import { addAbortSignal } from 'stream' import { pipeline } from 'stream/promises' @@ -38,7 +38,11 @@ export class SubscribeMessageHandler implements IMessageHandler, IAbortable { public async handleMessage(message: SubscribeMessage): Promise { const subscriptionId = message[1] - const filters = uniqWith(equals, message.slice(2)) as SubscriptionFilter[] + const rawFilters = uniqWith(equals, message.slice(2)) as SubscriptionFilter[] + + // NIP-50: strip search from filters when disabled so isEventMatchingFilter ignores it + const nip50Enabled = this.settings()?.nip50?.enabled ?? false + const filters = nip50Enabled ? rawFilters : rawFilters.map(omit(['search'])) as SubscriptionFilter[] const reason = this.canSubscribe(subscriptionId, filters) if (reason) { diff --git a/src/repositories/event-repository.ts b/src/repositories/event-repository.ts index 56da579d..1e1f9903 100644 --- a/src/repositories/event-repository.ts +++ b/src/repositories/event-repository.ts @@ -58,10 +58,21 @@ const groupByLengthSpec = groupBy( const logger = createLogger('event-repository') +/** Default text-search configuration when nip50.language is unset. */ +const DEFAULT_TS_CONFIG = 'simple' +/** Maximum search query length when nip50.maxQueryLength is unset. */ +const DEFAULT_MAX_SEARCH_QUERY_LENGTH = 256 + +interface FilterConditionFlags { + isTagQuery: boolean + isSearchQuery: boolean +} + export class EventRepository implements IEventRepository { public constructor( private readonly masterDbClient: DatabaseClient, private readonly readReplicaDbClient: DatabaseClient, + private readonly settings?: () => { nip50?: { enabled?: boolean; language?: string; maxQueryLength?: number } }, ) {} public findByFilters(filters: SubscriptionFilter[]): IQueryResult { @@ -72,15 +83,29 @@ export class EventRepository implements IEventRepository { const queries = filters.map((currentFilter) => { const builder = this.readReplicaDbClient('events') - const isTagQuery = this.applyFilterConditions(builder, currentFilter) - - if (typeof currentFilter.limit === 'number') { + const { isTagQuery, isSearchQuery } = this.applyFilterConditions(builder, currentFilter) + + if (isSearchQuery) { + // NIP-50: sort by relevance (ts_rank) descending, then by event_id for stability + const tsConfig = this.getNip50Language() + const limit = typeof currentFilter.limit === 'number' ? currentFilter.limit : DEFAULT_FILTER_LIMIT + builder + .select( + this.readReplicaDbClient.raw( + `events.*, ts_rank(to_tsvector('${tsConfig}', event_content), plainto_tsquery('${tsConfig}', ?)) AS search_rank`, + [currentFilter.search], + ), + ) + .limit(limit) + .orderBy('search_rank', 'DESC') + .orderBy('event_id', 'asc') + } else if (typeof currentFilter.limit === 'number') { builder.limit(currentFilter.limit).orderBy('event_created_at', 'DESC').orderBy('event_id', 'asc') } else { builder.limit(DEFAULT_FILTER_LIMIT).orderBy('event_created_at', 'asc').orderBy('event_id', 'asc') } - if (isTagQuery) { + if (isTagQuery && !isSearchQuery) { builder.select('events.*') } @@ -107,7 +132,7 @@ export class EventRepository implements IEventRepository { const queries = filters.map((currentFilter) => { const builder = this.readReplicaDbClient('events').select('events.event_id') - const isTagQuery = this.applyFilterConditions(builder, currentFilter) + const { isTagQuery } = this.applyFilterConditions(builder, currentFilter) if (typeof currentFilter.limit === 'number') { builder.limit(currentFilter.limit).orderBy('event_created_at', 'DESC').orderBy('event_id', 'asc') @@ -134,7 +159,7 @@ export class EventRepository implements IEventRepository { return Number(result?.count ?? 0) } - private applyFilterConditions(builder: any, currentFilter: SubscriptionFilter): boolean { + private applyFilterConditions(builder: any, currentFilter: SubscriptionFilter): FilterConditionFlags { forEachObjIndexed((tableFields: string[], filterName: string | number) => { builder.andWhere((bd) => { cond([ @@ -179,6 +204,22 @@ export class EventRepository implements IEventRepository { builder.where('event_created_at', '<=', currentFilter.until) } + // NIP-50: full-text search condition + let isSearchQuery = false + if (typeof currentFilter.search === 'string' && currentFilter.search.length > 0) { + const nip50Settings = this.settings?.() + if (nip50Settings?.nip50?.enabled) { + const tsConfig = this.getNip50Language() + const maxLen = nip50Settings.nip50.maxQueryLength ?? DEFAULT_MAX_SEARCH_QUERY_LENGTH + const searchQuery = currentFilter.search.slice(0, maxLen) + builder.andWhereRaw( + `to_tsvector('${tsConfig}', event_content) @@ plainto_tsquery('${tsConfig}', ?)`, + [searchQuery], + ) + isSearchQuery = true + } + } + const andWhereRaw = invoker(1, 'andWhereRaw') const orWhereRaw = invoker(2, 'orWhereRaw') @@ -205,7 +246,12 @@ export class EventRepository implements IEventRepository { builder.leftJoin('event_tags', 'events.event_id', 'event_tags.event_id') } - return isTagQuery + return { isTagQuery, isSearchQuery } + } + + /** Resolve the PostgreSQL text-search configuration name from settings. */ + private getNip50Language(): string { + return this.settings?.()?.nip50?.language ?? DEFAULT_TS_CONFIG } public async create(event: Event): Promise { diff --git a/src/schemas/filter-schema.ts b/src/schemas/filter-schema.ts index 1aa41897..cfa81194 100644 --- a/src/schemas/filter-schema.ts +++ b/src/schemas/filter-schema.ts @@ -3,7 +3,7 @@ import { z } from 'zod' import { createdAtSchema, kindSchema, prefixSchema } from './base-schema' import { isGenericTagQuery } from '../utils/filter' -const knownFilterKeys = new Set(['ids', 'authors', 'kinds', 'since', 'until', 'limit']) +const knownFilterKeys = new Set(['ids', 'authors', 'kinds', 'since', 'until', 'limit', 'search']) export const filterSchema = z .object({ @@ -13,6 +13,8 @@ export const filterSchema = z since: createdAtSchema.optional(), until: createdAtSchema.optional(), limit: z.number().int().min(0).optional(), + // NIP-50: full-text search query string + search: z.string().min(1).max(1024).optional(), }) .catchall(z.array(z.string().max(1024))) .superRefine((data, ctx) => { diff --git a/src/utils/event.ts b/src/utils/event.ts index 18bad057..9f4e4b1a 100644 --- a/src/utils/event.ts +++ b/src/utils/event.ts @@ -90,6 +90,15 @@ export const isEventMatchingFilter = return false } + // NIP-50 + if (typeof filter.search === 'string' && filter.search.length > 0) { + const contentLower = event.content.toLowerCase() + const terms = filter.search.toLowerCase().split(/\s+/).filter(Boolean) + if (terms.length === 0 || !terms.every((term) => contentLower.includes(term))) { + return false + } + } + return true } diff --git a/test/integration/features/nip-50/nip-50.feature b/test/integration/features/nip-50/nip-50.feature new file mode 100644 index 00000000..c72932f7 --- /dev/null +++ b/test/integration/features/nip-50/nip-50.feature @@ -0,0 +1,22 @@ +Feature: NIP-50 + Scenario: Alice searches for events by content + Given someone called Alice + And someone called Bob + When Bob sends a text_note event with content "Bitcoin and Lightning Network are great" + And Bob sends a text_note event with content "Nostr is a decentralized protocol" + And Alice subscribes to search for "bitcoin lightning" + Then Alice receives 1 text_note event from Bob with search match and EOSE + + Scenario: Alice gets no results for a search with no matches + Given someone called Alice + And someone called Bob + When Bob sends a text_note event with content "Hello world from Nostr" + And Alice subscribes to search for "ethereum solana" + Then Alice receives 0 events for search and EOSE + + Scenario: Alice combines search with kind filter + Given someone called Alice + And someone called Bob + When Bob sends a text_note event with content "Bitcoin is freedom" + And Alice subscribes to search for "bitcoin" with kinds 1 + Then Alice receives 1 text_note event from Bob with search match and EOSE diff --git a/test/integration/features/nip-50/nip-50.feature.ts b/test/integration/features/nip-50/nip-50.feature.ts new file mode 100644 index 00000000..608bc983 --- /dev/null +++ b/test/integration/features/nip-50/nip-50.feature.ts @@ -0,0 +1,63 @@ +import { Then, When, World } from '@cucumber/cucumber' +import chai from 'chai' +import sinonChai from 'sinon-chai' +import { WebSocket } from 'ws' + +import { + createSubscription, + waitForEOSE, + waitForEventCount, +} from '../helpers' + +chai.use(sinonChai) +const { expect } = chai + +When( + /^(\w+) subscribes to search for "([^"]+)"$/, + async function (this: World>, name: string, searchQuery: string) { + const ws = this.parameters.clients[name] as WebSocket + const subscription = { name: `test-${Math.random()}`, filters: [{ search: searchQuery }] } + this.parameters.subscriptions[name].push(subscription) + + await createSubscription(ws, subscription.name, subscription.filters) + }, +) + +When( + /^(\w+) subscribes to search for "([^"]+)" with kinds (\d+)$/, + async function (this: World>, name: string, searchQuery: string, kind: string) { + const ws = this.parameters.clients[name] as WebSocket + const subscription = { + name: `test-${Math.random()}`, + filters: [{ search: searchQuery, kinds: [Number(kind)] }], + } + this.parameters.subscriptions[name].push(subscription) + + await createSubscription(ws, subscription.name, subscription.filters) + }, +) + +Then( + /^(\w+) receives (\d+) text_note events? from (\w+) with search match and EOSE$/, + async function (this: World>, name: string, count: string, author: string) { + const ws = this.parameters.clients[name] as WebSocket + const subscription = this.parameters.subscriptions[name][this.parameters.subscriptions[name].length - 1] + const events = await waitForEventCount(ws, subscription.name, Number(count), true) + + expect(events.length).to.equal(Number(count)) + for (const event of events) { + expect(event.kind).to.equal(1) + expect(event.pubkey).to.equal(this.parameters.identities[author].pubkey) + } + }, +) + +Then( + /^(\w+) receives 0 events for search and EOSE$/, + async function (this: World>, name: string) { + const ws = this.parameters.clients[name] as WebSocket + const subscription = this.parameters.subscriptions[name][this.parameters.subscriptions[name].length - 1] + + await waitForEOSE(ws, subscription.name) + }, +) diff --git a/test/integration/features/shared.ts b/test/integration/features/shared.ts index 71153a20..c38dab68 100644 --- a/test/integration/features/shared.ts +++ b/test/integration/features/shared.ts @@ -38,6 +38,7 @@ BeforeAll({ timeout: 1000 }, async function () { assocPath(['limits', 'event', 'rateLimits'], []), assocPath(['limits', 'invoice', 'rateLimits'], []), assocPath(['limits', 'connection', 'rateLimits'], []), + assocPath(['nip50', 'enabled'], true), )(settings) as any worker = workerFactory() 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 49d5503d..183d2fdf 100644 --- a/test/unit/handlers/request-handlers/root-request-handler.spec.ts +++ b/test/unit/handlers/request-handlers/root-request-handler.spec.ts @@ -159,6 +159,25 @@ describe('rootRequestHandler', () => { expect(doc.limitation.default_limit).to.equal(DEFAULT_FILTER_LIMIT) }) + it('sets limitation.search_supported to false when NIP-50 is disabled', () => { + rootRequestHandler(req, res, next) + + const doc = res.send.firstCall.args[0] + expect(doc.limitation.search_supported).to.equal(false) + }) + + it('sets limitation.search_supported to true when NIP-50 is enabled', () => { + createSettingsStub.returns({ + ...baseSettings, + nip50: { enabled: true, language: 'simple', maxQueryLength: 256 }, + }) + + rootRequestHandler(req, res, next) + + const doc = res.send.firstCall.args[0] + expect(doc.limitation.search_supported).to.equal(true) + }) + it('sets limitation.restricted_writes based on active write restrictions', () => { rootRequestHandler(req, res, next) const defaultDoc = res.send.firstCall.args[0] diff --git a/test/unit/repositories/event-repository.spec.ts b/test/unit/repositories/event-repository.spec.ts index 6a9ffbfa..a1dd6160 100644 --- a/test/unit/repositories/event-repository.spec.ts +++ b/test/unit/repositories/event-repository.spec.ts @@ -427,6 +427,104 @@ describe('EventRepository', () => { ) }) }) + + describe('NIP-50: search', () => { + let searchEnabledRepository: IEventRepository + + beforeEach(() => { + searchEnabledRepository = new EventRepository(dbClient, rrDbClient, () => ({ + nip50: { enabled: true, language: 'simple', maxQueryLength: 256 }, + })) + }) + + it('adds tsvector/tsquery WHERE clause when search is provided and enabled', () => { + const filters = [{ search: 'bitcoin lightning' }] + + const query = searchEnabledRepository.findByFilters(filters).toString() + + expect(query).to.include("to_tsvector('simple', event_content) @@ plainto_tsquery('simple', 'bitcoin lightning')") + }) + + it('orders results by search_rank DESC when search is active', () => { + const filters = [{ search: 'nostr relay' }] + + const query = searchEnabledRepository.findByFilters(filters).toString() + + expect(query).to.include('search_rank') + expect(query).to.include('"search_rank" DESC') + }) + + it('applies default limit of 500 when search has no explicit limit', () => { + const filters = [{ search: 'test query' }] + + const query = searchEnabledRepository.findByFilters(filters).toString() + + expect(query).to.include('limit 500') + }) + + it('applies custom limit when search has explicit limit', () => { + const filters = [{ search: 'test query', limit: 20 }] + + const query = searchEnabledRepository.findByFilters(filters).toString() + + expect(query).to.include('limit 20') + }) + + it('combines search with kinds filter', () => { + const filters = [{ search: 'bitcoin', kinds: [1] }] + + const query = searchEnabledRepository.findByFilters(filters).toString() + + expect(query).to.include("plainto_tsquery('simple', 'bitcoin')") + expect(query).to.include('"event_kind" in (1)') + }) + + it('ignores search filter when NIP-50 is disabled', () => { + const disabledRepository = new EventRepository(dbClient, rrDbClient, () => ({ + nip50: { enabled: false }, + })) + const filters = [{ search: 'bitcoin' }] + + const query = disabledRepository.findByFilters(filters).toString() + + expect(query).to.not.include('tsvector') + expect(query).to.not.include('tsquery') + expect(query).to.not.include('search_rank') + }) + + it('ignores search filter when no settings are provided', () => { + const noSettingsRepository = new EventRepository(dbClient, rrDbClient) + const filters = [{ search: 'bitcoin' }] + + const query = noSettingsRepository.findByFilters(filters).toString() + + expect(query).to.not.include('tsvector') + expect(query).to.not.include('tsquery') + }) + + it('uses configured language for text search', () => { + const englishRepository = new EventRepository(dbClient, rrDbClient, () => ({ + nip50: { enabled: true, language: 'english' }, + })) + const filters = [{ search: 'running' }] + + const query = englishRepository.findByFilters(filters).toString() + + expect(query).to.include("to_tsvector('english', event_content)") + expect(query).to.include("plainto_tsquery('english', 'running')") + }) + + it('truncates search query to maxQueryLength', () => { + const shortMaxRepository = new EventRepository(dbClient, rrDbClient, () => ({ + nip50: { enabled: true, language: 'simple', maxQueryLength: 5 }, + })) + const filters = [{ search: 'bitcoinlightning' }] + + const query = shortMaxRepository.findByFilters(filters).toString() + + expect(query).to.include("plainto_tsquery('simple', 'bitco')") + }) + }) }) describe('.countByFilters', () => { diff --git a/test/unit/schemas/filter-schema.spec.ts b/test/unit/schemas/filter-schema.spec.ts index d6720008..eb34731c 100644 --- a/test/unit/schemas/filter-schema.spec.ts +++ b/test/unit/schemas/filter-schema.spec.ts @@ -140,4 +140,49 @@ describe('NIP-01', () => { }) } }) + + describe('NIP-50: search filter', () => { + it('accepts filter with valid search string', () => { + const filter = { search: 'bitcoin lightning' } + const result = validateSchema(filterSchema)(filter) + expect(result.error).to.be.undefined + expect(result.value).to.deep.equal(filter) + }) + + it('accepts search combined with kinds and limit', () => { + const filter = { search: 'nostr relay', kinds: [1], limit: 20 } + const result = validateSchema(filterSchema)(filter) + expect(result.error).to.be.undefined + expect(result.value).to.deep.equal(filter) + }) + + it('accepts search combined with authors and tags', () => { + const filter = { + search: 'bitcoin', + authors: ['22e804d26ed16b68db5259e78449e96dab5d464c8f470bda3eb1a70467f2c793'], + '#e': ['aaaaaa'], + } + const result = validateSchema(filterSchema)(filter) + expect(result.error).to.be.undefined + expect(result.value).to.deep.equal(filter) + }) + + it('rejects empty search string', () => { + const filter = { search: '' } + const result = validateSchema(filterSchema)(filter) + expect(result).to.have.property('error').that.is.not.undefined + }) + + it('rejects search string longer than 1024 characters', () => { + const filter = { search: 'a'.repeat(1025) } + const result = validateSchema(filterSchema)(filter) + expect(result).to.have.property('error').that.is.not.undefined + }) + + it('accepts search string at maximum length of 1024 characters', () => { + const filter = { search: 'a'.repeat(1024) } + const result = validateSchema(filterSchema)(filter) + expect(result.error).to.be.undefined + }) + }) }) diff --git a/test/unit/utils/event.spec.ts b/test/unit/utils/event.spec.ts index f059f940..fbd23684 100644 --- a/test/unit/utils/event.spec.ts +++ b/test/unit/utils/event.spec.ts @@ -204,6 +204,59 @@ describe('NIP-01', () => { }) }) + describe('NIP-50: search filter', () => { + let event: Event + + beforeEach(() => { + event = { + id: '6b3cdd0302ded8068ad3f0269c74423ca4fee460f800f3d90103b63f14400407', + pubkey: '22e804d26ed16b68db5259e78449e96dab5d464c8f470bda3eb1a70467f2c793', + created_at: 1648351380, + kind: 1, + tags: [], + content: 'Bitcoin and Lightning Network are revolutionizing payments', + sig: 'b37adfed0e6398546d623536f9ddc92b95b7dc71927e1123266332659253ecd0ffa91ddf2c0a82a8426c5b363139d28534d6cac893b8a810149557a3f6d36768', + } + }) + + it('returns true if search matches single term in content', () => { + expect(isEventMatchingFilter({ search: 'bitcoin' })(event)).to.be.true + }) + + it('returns true if search matches multiple terms in content', () => { + expect(isEventMatchingFilter({ search: 'bitcoin lightning' })(event)).to.be.true + }) + + it('returns false if search term is not in content', () => { + expect(isEventMatchingFilter({ search: 'ethereum' })(event)).to.be.false + }) + + it('returns false if one of multiple search terms is missing', () => { + expect(isEventMatchingFilter({ search: 'bitcoin ethereum' })(event)).to.be.false + }) + + it('is case-insensitive', () => { + expect(isEventMatchingFilter({ search: 'BITCOIN' })(event)).to.be.true + }) + + it('returns true if search is undefined', () => { + expect(isEventMatchingFilter({})(event)).to.be.true + }) + + it('returns true if search is an empty string', () => { + expect(isEventMatchingFilter({ search: '' })(event)).to.be.true + }) + + it('returns false if search is whitespace-only', () => { + expect(isEventMatchingFilter({ search: ' ' })(event)).to.be.false + }) + + it('combines with other filters', () => { + expect(isEventMatchingFilter({ search: 'bitcoin', kinds: [1] })(event)).to.be.true + expect(isEventMatchingFilter({ search: 'bitcoin', kinds: [2] })(event)).to.be.false + }) + }) + describe('isEventSignatureValid', () => { let event: Event From a358b7910726a1dc35569c575c6137f0ba290cd0 Mon Sep 17 00:00:00 2001 From: anshumancanrock Date: Wed, 29 Apr 2026 22:23:21 +0530 Subject: [PATCH 02/12] fix: use timingSafeEqual and zod for nodeless HMAC check --- .../callbacks/nodeless-callback-controller.ts | 38 +++++++++++++++---- src/schemas/nodeless-callback-schema.ts | 4 ++ .../nodeless-callback-controller.spec.ts | 13 ++++++- 3 files changed, 46 insertions(+), 9 deletions(-) diff --git a/src/controllers/callbacks/nodeless-callback-controller.ts b/src/controllers/callbacks/nodeless-callback-controller.ts index 72f01e4c..a418e060 100644 --- a/src/controllers/callbacks/nodeless-callback-controller.ts +++ b/src/controllers/callbacks/nodeless-callback-controller.ts @@ -1,3 +1,5 @@ +import { timingSafeEqual } from 'crypto' + import { always, applySpec, ifElse, is, path, prop, propEq, propSatisfies } from 'ramda' import { Request, Response } from 'express' @@ -8,7 +10,7 @@ import { fromNodelessInvoice } from '../../utils/transform' import { hmacSha256 } from '../../utils/secret' import { IController } from '../../@types/controllers' import { IPaymentsService } from '../../@types/services' -import { nodelessCallbackBodySchema } from '../../schemas/nodeless-callback-schema' +import { nodelessCallbackBodySchema, nodelessSignatureSchema } from '../../schemas/nodeless-callback-schema' import { validateSchema } from '../../utils/validation' const logger = createLogger('nodeless-callback-controller') @@ -30,20 +32,40 @@ export class NodelessCallbackController implements IController { return } - const settings = createSettings() - const paymentProcessor = settings.payments?.processor + const nodelessWebhookSecret = process.env.NODELESS_WEBHOOK_SECRET + if (!nodelessWebhookSecret) { + logger.error('NODELESS_WEBHOOK_SECRET is not configured') + response + .status(500) + .setHeader('content-type', 'application/json; charset=utf8') + .send('{"status":"error","message":"Internal Server Error"}') + return + } + + const signatureValidation = validateSchema(nodelessSignatureSchema)(request.headers['nodeless-signature']) + if (signatureValidation.error) { + logger('nodeless callback request rejected: invalid signature format') + response + .status(400) + .setHeader('content-type', 'application/json; charset=utf8') + .send('{"status":"error","message":"Invalid signature"}') + return + } - const expected = hmacSha256(process.env.NODELESS_WEBHOOK_SECRET, (request as any).rawBody).toString('hex') - const actual = request.headers['nodeless-signature'] + const expectedBuf = hmacSha256(nodelessWebhookSecret, (request as any).rawBody) + const actualBuf = Buffer.from(signatureValidation.value, 'hex') - if (expected !== actual) { - logger.error('nodeless callback request rejected: signature mismatch:', { expected, actual }) + if (!timingSafeEqual(expectedBuf, actualBuf)) { + logger.error('nodeless callback request rejected: signature mismatch') response.status(403).send('Forbidden') return } + const settings = createSettings() + const paymentProcessor = settings.payments?.processor + if (paymentProcessor !== 'nodeless') { - logger('denied request from %s to /callbacks/nodeless which is not the current payment processor') + logger('denied request to /callbacks/nodeless which is not the current payment processor') response.status(403).send('Forbidden') return } diff --git a/src/schemas/nodeless-callback-schema.ts b/src/schemas/nodeless-callback-schema.ts index 8413c88d..b89d3b5b 100644 --- a/src/schemas/nodeless-callback-schema.ts +++ b/src/schemas/nodeless-callback-schema.ts @@ -1,6 +1,10 @@ import { pubkeySchema } from './base-schema' import { z } from 'zod' +const hexRegex = /^[0-9a-f]+$/i + +export const nodelessSignatureSchema = z.string().regex(hexRegex).length(64) + export const nodelessCallbackBodySchema = z .object({ id: z.string().optional(), diff --git a/test/unit/controllers/callbacks/nodeless-callback-controller.spec.ts b/test/unit/controllers/callbacks/nodeless-callback-controller.spec.ts index b0c91d3f..f3d775ac 100644 --- a/test/unit/controllers/callbacks/nodeless-callback-controller.spec.ts +++ b/test/unit/controllers/callbacks/nodeless-callback-controller.spec.ts @@ -119,12 +119,23 @@ describe('NodelessCallbackController', () => { expect(res.send).to.have.been.calledWith('{"status":"error","message":"Malformed body"}') }) - it('returns 403 when callback signature is invalid', async () => { + it('returns 400 when callback signature has invalid format', async () => { const { controller, paymentsService } = makeController() const res = makeRes() await controller.handleRequest(makeReq({ signature: 'invalid-signature' }), res) + expect(res.status).to.have.been.calledWith(400) + expect(res.send).to.have.been.calledWith('{"status":"error","message":"Invalid signature"}') + expect(paymentsService.updateInvoiceStatus).to.not.have.been.called + }) + + it('returns 403 when callback signature does not match', async () => { + const { controller, paymentsService } = makeController() + const res = makeRes() + + await controller.handleRequest(makeReq({ signature: 'b'.repeat(64) }), res) + expect(res.status).to.have.been.calledWith(403) expect(res.send).to.have.been.calledWith('Forbidden') expect(paymentsService.updateInvoiceStatus).to.not.have.been.called From 5d3092c3c9e2ee97076bf9a7833d1019c8f51560 Mon Sep 17 00:00:00 2001 From: anshumancanrock Date: Wed, 29 Apr 2026 22:28:03 +0530 Subject: [PATCH 03/12] refactor: only register nodeless route when enabled --- .../callbacks/nodeless-callback-controller.ts | 10 ---------- src/routes/callbacks/index.ts | 11 +++++++++-- .../nodeless-callback-controller.spec.ts | 19 ------------------- 3 files changed, 9 insertions(+), 31 deletions(-) diff --git a/src/controllers/callbacks/nodeless-callback-controller.ts b/src/controllers/callbacks/nodeless-callback-controller.ts index a418e060..b08de354 100644 --- a/src/controllers/callbacks/nodeless-callback-controller.ts +++ b/src/controllers/callbacks/nodeless-callback-controller.ts @@ -5,7 +5,6 @@ import { Request, Response } from 'express' import { Invoice, InvoiceStatus } from '../../@types/invoice' import { createLogger } from '../../factories/logger-factory' -import { createSettings } from '../../factories/settings-factory' import { fromNodelessInvoice } from '../../utils/transform' import { hmacSha256 } from '../../utils/secret' import { IController } from '../../@types/controllers' @@ -61,15 +60,6 @@ export class NodelessCallbackController implements IController { return } - const settings = createSettings() - const paymentProcessor = settings.payments?.processor - - if (paymentProcessor !== 'nodeless') { - logger('denied request to /callbacks/nodeless which is not the current payment processor') - response.status(403).send('Forbidden') - return - } - const nodelessInvoice = applySpec({ id: prop('uuid'), status: prop('status'), diff --git a/src/routes/callbacks/index.ts b/src/routes/callbacks/index.ts index 944ef970..eed0ea49 100644 --- a/src/routes/callbacks/index.ts +++ b/src/routes/callbacks/index.ts @@ -3,15 +3,22 @@ import { json, Router, urlencoded } from 'express' import { createLNbitsCallbackController } from '../../factories/controllers/lnbits-callback-controller-factory' import { createNodelessCallbackController } from '../../factories/controllers/nodeless-callback-controller-factory' import { createOpenNodeCallbackController } from '../../factories/controllers/opennode-callback-controller-factory' +import { createSettings } from '../../factories/settings-factory' import { createZebedeeCallbackController } from '../../factories/controllers/zebedee-callback-controller-factory' import { withController } from '../../handlers/request-handlers/with-controller-request-handler' const router: Router = Router() +const settings = createSettings() +const processor = settings.payments?.processor + router .post('/zebedee', json(), withController(createZebedeeCallbackController)) .post('/lnbits', json(), withController(createLNbitsCallbackController)) - .post( + .post('/opennode', urlencoded({ extended: false }), json(), withController(createOpenNodeCallbackController)) + +if (processor === 'nodeless') { + router.post( '/nodeless', json({ verify(req, _res, buf) { @@ -20,6 +27,6 @@ router }), withController(createNodelessCallbackController), ) - .post('/opennode', urlencoded({ extended: false }), json(), withController(createOpenNodeCallbackController)) +} export default router diff --git a/test/unit/controllers/callbacks/nodeless-callback-controller.spec.ts b/test/unit/controllers/callbacks/nodeless-callback-controller.spec.ts index f3d775ac..6f2fe993 100644 --- a/test/unit/controllers/callbacks/nodeless-callback-controller.spec.ts +++ b/test/unit/controllers/callbacks/nodeless-callback-controller.spec.ts @@ -7,17 +7,12 @@ chai.use(sinonChai) chai.use(chaiAsPromised) const { expect } = chai -import * as settingsFactory from '../../../../src/factories/settings-factory' import { InvoiceStatus, InvoiceUnit } from '../../../../src/@types/invoice' import { hmacSha256 } from '../../../../src/utils/secret' import { NodelessCallbackController } from '../../../../src/controllers/callbacks/nodeless-callback-controller' const PUBKEY = 'a'.repeat(64) -const baseSettings: any = { - payments: { processor: 'nodeless' }, -} - const validBody = { uuid: 'nodeless-invoice-id', status: 'paid', @@ -84,7 +79,6 @@ const makeReq = (overrides: any = {}): any => { } describe('NodelessCallbackController', () => { - let createSettingsStub: sinon.SinonStub let consoleErrorStub: sinon.SinonStub let previousWebhookSecret: string | undefined @@ -92,7 +86,6 @@ describe('NodelessCallbackController', () => { previousWebhookSecret = process.env.NODELESS_WEBHOOK_SECRET process.env.NODELESS_WEBHOOK_SECRET = 'nodeless-test-secret' - createSettingsStub = sinon.stub(settingsFactory, 'createSettings').returns(baseSettings) consoleErrorStub = sinon.stub(console, 'error') }) @@ -103,7 +96,6 @@ describe('NodelessCallbackController', () => { process.env.NODELESS_WEBHOOK_SECRET = previousWebhookSecret } - createSettingsStub.restore() consoleErrorStub.restore() }) @@ -141,17 +133,6 @@ describe('NodelessCallbackController', () => { expect(paymentsService.updateInvoiceStatus).to.not.have.been.called }) - it('returns 403 when nodeless is not the configured processor', async () => { - createSettingsStub.returns({ payments: { processor: 'zebedee' } }) - const { controller, paymentsService } = makeController() - const res = makeRes() - - await controller.handleRequest(makeReq(), res) - - expect(res.status).to.have.been.calledWith(403) - expect(res.send).to.have.been.calledWith('Forbidden') - expect(paymentsService.updateInvoiceStatus).to.not.have.been.called - }) }) describe('invoice state handling', () => { From 907e9b932f3e981d4f4498029c38a9be725714d5 Mon Sep 17 00:00:00 2001 From: anshumancanrock Date: Sun, 21 Jun 2026 22:23:12 +0530 Subject: [PATCH 04/12] chore: sync with upstream main --- .github/workflows/checks.yml | 98 +++++++++--- CONTRIBUTING.md | 35 +++++ README.md | 2 + package.json | 23 ++- pnpm-lock.yaml | 88 +++++------ resources/get-invoice.html | 4 +- resources/index.html | 6 +- resources/invoices.html | 7 +- resources/post-invoice.html | 5 +- resources/privacy.html | 4 +- src/@types/adapters.ts | 6 + src/@types/event.ts | 13 ++ src/@types/messages.ts | 16 +- src/adapters/redis-adapter.ts | 2 + src/adapters/web-socket-adapter.ts | 24 ++- src/cache/client.ts | 55 ++++++- src/cli/commands/dev.ts | 18 +++ src/cli/commands/info.ts | 4 +- src/cli/commands/update.ts | 4 + src/cli/index.ts | 8 + src/cli/utils/process.ts | 50 +++++-- src/constants/base.ts | 27 ++++ .../callbacks/lnbits-callback-controller.ts | 7 - .../callbacks/opennode-callback-controller.ts | 9 -- .../callbacks/zebedee-callback-controller.ts | 7 - .../invoices/get-invoice-controller.ts | 4 +- .../invoices/post-invoice-controller.ts | 5 +- src/factories/event-strategy-factory.ts | 9 +- src/factories/message-handler-factory.ts | 3 + src/handlers/event-message-handler.ts | 10 +- .../get-privacy-request-handler.ts | 7 +- .../get-terms-request-handler.ts | 7 +- src/routes/index.ts | 5 +- src/schemas/base-schema.ts | 6 + src/schemas/event-schema.ts | 66 ++++++++- src/schemas/message-schema.ts | 5 +- src/utils/http.ts | 79 ++++++++-- src/utils/messages.ts | 6 + src/utils/sliding-window-rate-limiter.ts | 63 ++++++-- .../callbacks/opennode-callback.feature.ts | 9 ++ .../features/nip-11/nip-11.feature | 5 + .../features/nip-11/nip-11.feature.ts | 10 ++ test/unit/adapters/web-socket-adapter.spec.ts | 93 ++++++++++++ test/unit/cli/info.spec.ts | 25 +++- test/unit/cli/update.spec.ts | 2 + .../lnbits-callback-controller.spec.ts | 29 ---- .../opennode-callback-controller.spec.ts | 14 -- .../zebedee-callback-controller.spec.ts | 15 -- .../invoices/get-invoice-controller.spec.ts | 18 ++- .../invoices/post-invoice-controller.spec.ts | 25 +++- .../factories/event-strategy-factory.spec.ts | 36 +++++ .../factories/message-handler-factory.spec.ts | 8 + .../handlers/event-message-handler.spec.ts | 13 ++ .../get-privacy-request-handler.spec.ts | 9 ++ test/unit/routes/callbacks.spec.ts | 7 + test/unit/schemas/event-schema.spec.ts | 140 +++++++++++++++++- test/unit/schemas/message-schema.spec.ts | 37 +++++ test/unit/utils/http.spec.ts | 105 +++++++++++-- test/unit/utils/nip44.spec.ts | 116 +++++---------- test/unit/utils/settings.spec.ts | 27 ++++ .../utils/sliding-window-rate-limiter.spec.ts | 25 +++- 61 files changed, 1241 insertions(+), 324 deletions(-) diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index 14534607..99e89404 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -12,16 +12,46 @@ concurrency: cancel-in-progress: true jobs: + changes: + name: Detect changed paths + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: read + outputs: + src: ${{ steps.filter.outputs.src }} + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - id: filter + uses: dorny/paths-filter@v3 + with: + filters: | + src: + - 'src/**' + - 'test/**' + - 'package.json' + - 'pnpm-lock.yaml' + - 'tsconfig*.json' + - 'biome.json' + - '.knip.json' + - 'Dockerfile*' + - 'docker-compose*.yml' + - '.nvmrc' + - '.github/workflows/checks.yml' + commit-lint: name: Lint commits runs-on: ubuntu-latest + if: github.event_name == 'pull_request' || github.event_name == 'workflow_dispatch' steps: - name: Checkout - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: fetch-depth: 0 - uses: pnpm/action-setup@v4 - - uses: actions/setup-node@v3 + - uses: actions/setup-node@v4 with: node-version-file: .nvmrc cache: pnpm @@ -29,14 +59,17 @@ jobs: run: pnpm install --frozen-lockfile - name: Run commitlint uses: wagoid/commitlint-github-action@v5 + lint: name: Lint code runs-on: ubuntu-latest + needs: changes + if: needs.changes.outputs.src == 'true' steps: - name: Checkout - uses: actions/checkout@v3 + uses: actions/checkout@v4 - uses: pnpm/action-setup@v4 - - uses: actions/setup-node@v3 + - uses: actions/setup-node@v4 with: node-version-file: .nvmrc cache: pnpm @@ -46,14 +79,17 @@ jobs: run: pnpm run lint - name: Run Knip run: pnpm run check:deps + build-check: name: Build check runs-on: ubuntu-latest + needs: changes + if: needs.changes.outputs.src == 'true' steps: - name: Checkout - uses: actions/checkout@v3 + uses: actions/checkout@v4 - uses: pnpm/action-setup@v4 - - uses: actions/setup-node@v3 + - uses: actions/setup-node@v4 with: node-version-file: .nvmrc cache: pnpm @@ -65,18 +101,23 @@ jobs: run: pnpm run build - name: Verify built CLI entrypoint run: pnpm run verify:cli:build + test-units-and-cover: name: Unit Tests And Coverage runs-on: ubuntu-latest needs: - - commit-lint + - changes - lint - build-check + if: | + needs.changes.outputs.src == 'true' && + needs.lint.result == 'success' && + needs.build-check.result == 'success' steps: - name: Checkout - uses: actions/checkout@v3 + uses: actions/checkout@v4 - uses: pnpm/action-setup@v4 - - uses: actions/setup-node@v3 + - uses: actions/setup-node@v4 with: node-version-file: .nvmrc cache: pnpm @@ -96,25 +137,30 @@ jobs: name: unit-coverage-lcov path: .coverage/unit/lcov.info - name: Coveralls - uses: coverallsapp/github-action@master + uses: coverallsapp/github-action@v2.3.6 if: ${{ always() }} with: path-to-lcov: ./.coverage/unit/lcov.info flag-name: Unit github-token: ${{ secrets.GITHUB_TOKEN }} parallel: true + test-integrations-and-cover: name: Integration Tests and Coverage runs-on: ubuntu-latest needs: - - commit-lint + - changes - lint - build-check + if: | + needs.changes.outputs.src == 'true' && + needs.lint.result == 'success' && + needs.build-check.result == 'success' steps: - name: Checkout - uses: actions/checkout@v3 + uses: actions/checkout@v4 - uses: pnpm/action-setup@v4 - - uses: actions/setup-node@v3 + - uses: actions/setup-node@v4 with: node-version-file: .nvmrc - name: Run integration tests @@ -129,7 +175,7 @@ jobs: - name: Run coverage for integration tests run: pnpm run docker:cover:integration - name: Coveralls - uses: coverallsapp/github-action@master + uses: coverallsapp/github-action@v2.3.6 if: ${{ always() }} with: path-to-lcov: .coverage/integration/lcov.info @@ -142,28 +188,36 @@ jobs: with: name: integration-coverage-lcov path: .coverage/integration/lcov.info + post-tests: name: Post Tests - needs: [test-units-and-cover, test-integrations-and-cover] runs-on: ubuntu-latest + needs: + - changes + - test-units-and-cover + - test-integrations-and-cover if: ${{ always() }} steps: - - name: Coveralls Finished - uses: coverallsapp/github-action@master - with: - github-token: ${{ secrets.GITHUB_TOKEN }} - parallel-finished: true + - name: Coveralls Finished + uses: coverallsapp/github-action@v2.3.6 + if: | + needs.test-units-and-cover.result != 'skipped' || + needs.test-integrations-and-cover.result != 'skipped' + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + parallel-finished: true + changeset-check: name: Changeset Required runs-on: ubuntu-latest if: github.event_name == 'pull_request' && github.head_ref != 'changeset-release/main' steps: - name: Checkout - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: fetch-depth: 0 - uses: pnpm/action-setup@v4 - - uses: actions/setup-node@v3 + - uses: actions/setup-node@v4 with: node-version-file: .nvmrc cache: pnpm diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f787cf55..44340e50 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -302,6 +302,41 @@ To observe client and subscription counts in real-time during a test, you can in docker compose logs -f nostream ``` +## Performance Testing (k6) + +Nostream includes k6-based load tests to validate rate limiter behavior under concurrent WebSocket +connections. These tests verify that connection and message rate limits are correctly enforced. + +### Prerequisites + +Install [k6](https://grafana.com/docs/k6/latest/set-up/install-k6/) before running performance +tests. k6 is a standalone Go binary and is not included as an npm dependency. + +### Running the Tests + +Ensure the relay is running first (`pnpm run cli -- start`), then: + +```bash +# Test connection rate limiting +pnpm run cli -- dev test:perf:connection + +# Test message rate limiting +pnpm run cli -- dev test:perf:message +``` + +To test against a different relay instance: + +```bash +k6 run -e RELAY_URL=ws://your-host:8008 test/performance/connection-limiting-k6.ts +``` + +### What the Tests Validate + +- **Connection rate limiter** — Ramps concurrent connections through multiple stages and verifies + the relay rejects excess connections beyond the configured limit (default: 12 conn/sec). +- **Message rate limiter** — Opens WebSocket connections and sends continuous REQ messages, + verifying the relay returns NOTICE rejections when the message rate limit is exceeded. + ## Local Quality Checks Run dead code and dependency analysis before opening a pull request: diff --git a/README.md b/README.md index 8ac5c31b..a53ee90a 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,7 @@ NIPs with a relay-specific implementation are listed here. - [x] NIP-16: Event Treatment - [x] NIP-20: Command Results - [x] NIP-22: Event `created_at` Limits +- [x] NIP-25: Reactions - [ ] NIP-26: Delegated Event Signing (REMOVED) - [x] NIP-28: Public Chat - [x] NIP-33: Parameterized Replaceable Events @@ -63,6 +64,7 @@ NIPs with a relay-specific implementation are listed here. - [x] NIP-44: Encrypted Payloads (Versioned) - [x] NIP-45: Event Counts - [x] NIP-62: Request to Vanish +- [x] NIP-65: Relay List Metadata ## Requirements diff --git a/package.json b/package.json index d21ec601..0705fadc 100644 --- a/package.json +++ b/package.json @@ -18,14 +18,17 @@ 17, 20, 22, + 25, 28, 33, 40, 44, 45, - 50 + 50, + 65 ], "supportedNipExtensions": [], + "supportedMips": [0, 1, 2, 3], "main": "src/index.ts", "bin": { "nostream": "./dist/src/cli/index.js" @@ -76,6 +79,8 @@ "test:load": "node -r ts-node/register ./scripts/security-load-test.ts", "smoke:nip03": "node -r ts-node/register scripts/smoke-nip03.ts", "test:integration": "cucumber-js", + "test:performance:connection-rate-limit": "k6 run test/performance/connection-limiting-k6.ts", + "test:performance:message-rate-limit": "k6 run test/performance/message-limiting-k6.ts", "cover:integration": "nyc --report-dir .coverage/integration pnpm run test:integration -p cover", "export": "node --env-file-if-exists=.env -r ts-node/register src/scripts/export-events.ts", "docker:compose:start": "pnpm run cli -- start", @@ -93,7 +98,7 @@ "docker:cover:integration": "pnpm run docker:integration:run pnpm exec nyc --report-dir .coverage/integration pnpm run test:integration -- -p cover", "postdocker:integration:run": "docker compose -f ./test/integration/docker-compose.yml down", "prepack": "pnpm run build", - "prepare": "husky install || exit 0", + "prepare": "test -f .husky/install.mjs && node .husky/install.mjs || true", "changeset:version": "changeset version && pnpm install --lockfile-only", "changeset:publish": "changeset publish" }, @@ -124,6 +129,7 @@ "@types/chai-as-promised": "^7.1.5", "@types/express": "4.17.21", "@types/js-yaml": "4.0.5", + "@types/k6": "^1.7.0", "@types/mocha": "^9.1.1", "@types/node": "^24.12.2", "@types/pg": "^8.6.5", @@ -153,11 +159,10 @@ "node": ">=24.14.1" }, "dependencies": { - "@getalby/sdk": "^5.0.0", "@clack/prompts": "^1.2.0", + "@getalby/sdk": "^5.0.0", "@noble/secp256k1": "1.7.1", - "accepts": "^1.3.8", - "axios": "^1.15.0", + "axios": "^1.16.0", "cac": "^7.0.0", "colorette": "^2.0.20", "express": "4.22.1", @@ -170,13 +175,15 @@ "ramda": "0.28.0", "redis": "4.5.1", "stream-json": "^2.1.0", - "ws": "^8.18.0", + "ws": "^8.20.1", "zod": "^3.22.4" }, "optionalDependencies": { "lzma-native": "^8.0.6" }, - "overrides": { - "axios@<0.31.0": ">=0.31.0" + "pnpm": { + "overrides": { + "serialize-javascript": ">=7.0.3 <8" + } } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 153bcb60..5fae5644 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4,6 +4,9 @@ settings: autoInstallPeers: true excludeLinksFromLockfile: false +overrides: + serialize-javascript: '>=7.0.3 <8' + importers: .: @@ -17,12 +20,9 @@ importers: '@noble/secp256k1': specifier: 1.7.1 version: 1.7.1 - accepts: - specifier: ^1.3.8 - version: 1.3.8 axios: - specifier: ^1.15.0 - version: 1.15.1 + specifier: ^1.16.0 + version: 1.16.0 cac: specifier: ^7.0.0 version: 7.0.0 @@ -60,8 +60,8 @@ importers: specifier: ^2.1.0 version: 2.1.0 ws: - specifier: ^8.18.0 - version: 8.20.0 + specifier: ^8.20.1 + version: 8.20.1 zod: specifier: ^3.22.4 version: 3.25.76 @@ -99,6 +99,9 @@ importers: '@types/js-yaml': specifier: 4.0.5 version: 4.0.5 + '@types/k6': + specifier: ^1.7.0 + version: 1.7.0 '@types/mocha': specifier: ^9.1.1 version: 9.1.1 @@ -724,6 +727,7 @@ packages: '@snyk/github-codeowners@1.1.0': resolution: {integrity: sha512-lGFf08pbkEac0NYgVf4hdANpAgApRjNByLXB+WBip3qj1iendOIyAwP2GKkKbQMNVy2r1xxDf0ssfWscoiC+Vw==} engines: {node: '>=8.10'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. hasBin: true '@teppeis/multimaps@3.0.0': @@ -766,6 +770,9 @@ packages: '@types/js-yaml@4.0.5': resolution: {integrity: sha512-FhpRzf927MNQdRZP0J5DLIdTXhjLYzeUTmLAu69mnVksLH9CJY3IuSeEgbKUki7GQZm0WqDkGzyxju2EZGD2wA==} + '@types/k6@1.7.0': + resolution: {integrity: sha512-oL4mckVcOPIA2HUrCVj3aQXCJgCqsQe35Uc4fRTffmrQuR24v92GJImnagqUaRnC1TQVJFx85o3aHQPP+0bxpg==} + '@types/minimist@1.2.5': resolution: {integrity: sha512-hov8bUuiLiyFPGyFPE1lwWhmzYbirOXQNNo40+y3zow8aFVTeyn3VWL0VFFfdNddA8S4Vf0Tc062rzyNr7Paag==} @@ -947,8 +954,8 @@ packages: resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==} engines: {node: '>=8.0.0'} - axios@1.15.1: - resolution: {integrity: sha512-WOG+Jj8ZOvR0a3rAn+Tuf1UQJRxw5venr6DgdbJzngJE3qG7X0kL83CZGpdHMxEm+ZK3seAbvFsw4FfOfP9vxg==} + axios@1.16.0: + resolution: {integrity: sha512-6hp5CwvTPlN2A31g5dxnwAX0orzM7pmCRDLnZSX772mv8WDqICwFjowHuPs04Mc8deIld1+ejhtaMn5vp6b+1w==} babylon@6.18.0: resolution: {integrity: sha512-q/UEjfGJ2Cm3oKV71DJz9d25TPnq5rhBVL2Q4fA5wcC3jcrdn7+SssEybFIxwAvvP+YCsCYNKughoF33GxgycQ==} @@ -1387,8 +1394,8 @@ packages: resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} engines: {node: '>= 0.4'} - es-object-atoms@1.1.1: - resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} + es-object-atoms@1.1.2: + resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} engines: {node: '>= 0.4'} es-set-tostringtag@2.1.0: @@ -1472,8 +1479,8 @@ packages: fast-string-width@1.1.0: resolution: {integrity: sha512-O3fwIVIH5gKB38QNbdg+3760ZmGz0SZMgvwJbA1b2TGXceKE6A2cOlfogh1iw8lr049zPyd7YADHy+B7U4W9bQ==} - fast-uri@3.1.0: - resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==} + fast-uri@3.1.2: + resolution: {integrity: sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==} fast-wrap-ansi@0.1.6: resolution: {integrity: sha512-HlUwET7a5gqjURj70D5jl7aC3Zmy4weA1SHUfM0JFI0Ptq987NH2TwbBFLoERhfwk+E+eaq4EK3jXoT+R3yp3w==} @@ -1691,8 +1698,8 @@ packages: resolution: {integrity: sha512-Hrp5vIK/xr5SkeN2onO32H0MgNZ0f17HRNH39WfL0SYUNOTZ5Lz1TJ8Pajo/87dYGEFlLMm7mIc/k/s6Bvz9HQ==} engines: {node: '>=8'} - hasown@2.0.3: - resolution: {integrity: sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==} + hasown@2.0.4: + resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} engines: {node: '>= 0.4'} he@1.2.0: @@ -2662,9 +2669,6 @@ packages: ramda@0.28.0: resolution: {integrity: sha512-9QnLuG/kPVgWvMQ4aODhsBUFKOUmnbUnsSXACv+NCQZcHbeb+v8Lodp8OVxtRULN1/xOyYLLaL6npE6dMq5QTA==} - randombytes@2.1.0: - resolution: {integrity: sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==} - range-parser@1.2.1: resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} engines: {node: '>= 0.6'} @@ -2851,8 +2855,9 @@ packages: resolution: {integrity: sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==} engines: {node: '>= 0.8.0'} - serialize-javascript@6.0.2: - resolution: {integrity: sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==} + serialize-javascript@7.0.5: + resolution: {integrity: sha512-F4LcB0UqUl1zErq+1nYEEzSHJnIwb3AF2XWB94b+afhrekOUijwooAYqFyRbjYkm2PAKBabx6oYv/xDxNi8IBw==} + engines: {node: '>=20.0.0'} serve-static@1.16.3: resolution: {integrity: sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==} @@ -3267,14 +3272,17 @@ packages: uuid@8.3.2: resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==} + deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). hasBin: true uuid@9.0.0: resolution: {integrity: sha512-MXcSTerfPa4uqyzStbRoTgt5XIe3x5+42+q1sDuy3R5MDk66URdLMOZe5aPX/SQd+kuYAh0FdP/pO28IkQyTeg==} + deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). hasBin: true uuid@9.0.1: resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==} + deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). hasBin: true v8-compile-cache-lib@3.0.1: @@ -3336,8 +3344,8 @@ packages: write-file-atomic@3.0.3: resolution: {integrity: sha512-AvHcyZ5JnSfq3ioSyjrBkH9yW4m7Ayk8/9My/DD9onKeu/94fwrMocemO2QAJFAlnnDN+ZDS+ZjAR5ua1/PV/Q==} - ws@8.20.0: - resolution: {integrity: sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==} + ws@8.20.1: + resolution: {integrity: sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==} engines: {node: '>=10.0.0'} peerDependencies: bufferutil: ^4.0.1 @@ -4292,6 +4300,8 @@ snapshots: '@types/js-yaml@4.0.5': {} + '@types/k6@1.7.0': {} + '@types/minimist@1.2.5': {} '@types/mocha@9.1.1': {} @@ -4386,7 +4396,7 @@ snapshots: ajv@8.18.0: dependencies: fast-deep-equal: 3.1.3 - fast-uri: 3.1.0 + fast-uri: 3.1.2 json-schema-traverse: 1.0.0 require-from-string: 2.0.2 @@ -4453,7 +4463,7 @@ snapshots: atomic-sleep@1.0.0: {} - axios@1.15.1: + axios@1.16.0: dependencies: follow-redirects: 1.16.0 form-data: 4.0.5 @@ -4870,7 +4880,7 @@ snapshots: es-errors@1.3.0: {} - es-object-atoms@1.1.1: + es-object-atoms@1.1.2: dependencies: es-errors: 1.3.0 @@ -4879,7 +4889,7 @@ snapshots: es-errors: 1.3.0 get-intrinsic: 1.3.0 has-tostringtag: 1.0.2 - hasown: 2.0.3 + hasown: 2.0.4 es6-error@4.1.1: {} @@ -4981,7 +4991,7 @@ snapshots: dependencies: fast-string-truncated-width: 1.2.1 - fast-uri@3.1.0: {} + fast-uri@3.1.2: {} fast-wrap-ansi@0.1.6: dependencies: @@ -5050,7 +5060,7 @@ snapshots: asynckit: 0.4.0 combined-stream: 1.0.8 es-set-tostringtag: 2.1.0 - hasown: 2.0.3 + hasown: 2.0.4 mime-types: 2.1.35 forwarded@0.2.0: {} @@ -5107,12 +5117,12 @@ snapshots: call-bind-apply-helpers: 1.0.2 es-define-property: 1.0.1 es-errors: 1.3.0 - es-object-atoms: 1.1.1 + es-object-atoms: 1.1.2 function-bind: 1.1.2 get-proto: 1.0.1 gopd: 1.2.0 has-symbols: 1.1.0 - hasown: 2.0.3 + hasown: 2.0.4 math-intrinsics: 1.1.0 get-package-type@0.1.0: {} @@ -5120,7 +5130,7 @@ snapshots: get-proto@1.0.1: dependencies: dunder-proto: 1.0.1 - es-object-atoms: 1.1.1 + es-object-atoms: 1.1.2 get-stream@6.0.1: {} @@ -5206,7 +5216,7 @@ snapshots: is-stream: 2.0.1 type-fest: 0.8.1 - hasown@2.0.3: + hasown@2.0.4: dependencies: function-bind: 1.1.2 @@ -5284,7 +5294,7 @@ snapshots: is-core-module@2.16.1: dependencies: - hasown: 2.0.3 + hasown: 2.0.4 is-extglob@2.1.1: {} @@ -5690,7 +5700,7 @@ snapshots: minimatch: 9.0.9 ms: 2.1.3 picocolors: 1.1.1 - serialize-javascript: 6.0.2 + serialize-javascript: 7.0.5 strip-json-comments: 3.1.1 supports-color: 8.1.1 workerpool: 9.3.4 @@ -6153,10 +6163,6 @@ snapshots: ramda@0.28.0: {} - randombytes@2.1.0: - dependencies: - safe-buffer: 5.2.1 - range-parser@1.2.1: {} raw-body@2.5.3: @@ -6346,9 +6352,7 @@ snapshots: transitivePeerDependencies: - supports-color - serialize-javascript@6.0.2: - dependencies: - randombytes: 2.1.0 + serialize-javascript@7.0.5: {} serve-static@1.16.3: dependencies: @@ -6821,7 +6825,7 @@ snapshots: signal-exit: 3.0.7 typedarray-to-buffer: 3.1.5 - ws@8.20.0: {} + ws@8.20.1: {} xmlbuilder@15.1.1: {} diff --git a/resources/get-invoice.html b/resources/get-invoice.html index 7e72a439..9682a0c7 100644 --- a/resources/get-invoice.html +++ b/resources/get-invoice.html @@ -9,7 +9,7 @@
-
+

{{name}}

@@ -46,7 +46,7 @@

{{name}}

diff --git a/resources/index.html b/resources/index.html index c982f9e0..c0fa55df 100644 --- a/resources/index.html +++ b/resources/index.html @@ -46,7 +46,7 @@
Admission Required
This relay requires a one-time admission fee of {{amount}} sats to publish events. Reading events is free.

- Pay Admission Fee + Pay Admission Fee
@@ -62,9 +62,9 @@
Open Relay
diff --git a/resources/invoices.html b/resources/invoices.html index 3df95f0c..0742fed4 100644 --- a/resources/invoices.html +++ b/resources/invoices.html @@ -14,7 +14,7 @@
- +

{{name}}

@@ -106,6 +106,7 @@

Invoice expired!

var reference = "{{reference}}" var relayUrl = "{{relay_url}}" var relayPubkey = "{{relay_pubkey}}" + var pathPrefix = {{path_prefix_json}}; var invoice = "{{invoice}}"; var pubkey = "{{pubkey}}" var expiresAt = "{{expires_at}}" @@ -124,7 +125,7 @@

Invoice expired!

} async function getInvoiceStatus() { - fetch(`/invoices/${reference}/status`).then(async (response) => { + fetch(`${pathPrefix}/invoices/${reference}/status`).then(async (response) => { const data = await response.json() console.log('data', data) const { status } = data; @@ -269,4 +270,4 @@

Invoice expired!

document.getElementById('sendPaymentBtn').addEventListener('click', sendPayment) - \ No newline at end of file + diff --git a/resources/post-invoice.html b/resources/post-invoice.html index 446efdb6..119d0750 100644 --- a/resources/post-invoice.html +++ b/resources/post-invoice.html @@ -14,7 +14,7 @@
- +

{{name}}

@@ -106,6 +106,7 @@

Invoice expired!

var reference = {{reference_json}} var relayUrl = {{relay_url_json}} var relayPubkey = {{relay_pubkey_json}} + var pathPrefix = {{path_prefix_json}} var invoice = {{invoice_json}} var pubkey = {{pubkey_json}} var expiresAt = {{expires_at_json}} @@ -124,7 +125,7 @@

Invoice expired!

} async function getInvoiceStatus() { - fetch(`/invoices/${reference}/status`).then(async (response) => { + fetch(`${pathPrefix}/invoices/${reference}/status`).then(async (response) => { if (!response.ok) { throw new Error(`unexpected status ${response.status}`) } diff --git a/resources/privacy.html b/resources/privacy.html index af36159d..eb8f7785 100644 --- a/resources/privacy.html +++ b/resources/privacy.html @@ -61,9 +61,9 @@
Changes to this policy

diff --git a/src/@types/adapters.ts b/src/@types/adapters.ts index 130d7853..c42bc0a8 100644 --- a/src/@types/adapters.ts +++ b/src/@types/adapters.ts @@ -15,6 +15,9 @@ export type IWebSocketAdapter = EventEmitter & { getClientId(): string getClientAddress(): string getSubscriptions(): Map + getChallenge(): string + getAuthenticatedPubkeys(): ReadonlySet + addAuthenticatedPubkey(pubkey: string): void } export interface ICacheAdapter { @@ -25,8 +28,11 @@ export interface ICacheAdapter { removeRangeByScoreFromSortedSet(key: string, min: number, max: number): Promise getRangeFromSortedSet(key: string, start: number, stop: number): Promise setKeyExpiry(key: string, expiry: number): Promise + deleteKey(key: string): Promise getHKey(key: string, field: string): Promise setHKey(key: string, fields: Record): Promise + + eval(script: string, keys: string[], args: string[]): Promise } diff --git a/src/@types/event.ts b/src/@types/event.ts index dee845c7..db83a40d 100644 --- a/src/@types/event.ts +++ b/src/@types/event.ts @@ -49,6 +49,19 @@ export interface DBEvent { expires_at?: number } +export type ReactionEntry = { + targetEventId?: string + targetPubkey?: string + targetAddress?: string + targetKind?: number + content: string +} + +export type RelayListEntry = { + url: string + marker?: 'read' | 'write' +} + export interface CanonicalEvent { 0: 0 1: string diff --git a/src/@types/messages.ts b/src/@types/messages.ts index f95538f8..44372592 100644 --- a/src/@types/messages.ts +++ b/src/@types/messages.ts @@ -12,13 +12,14 @@ export enum MessageType { OK = 'OK', COUNT = 'COUNT', CLOSED = 'CLOSED', + AUTH = 'AUTH', } -export type IncomingMessage = (SubscribeMessage | IncomingEventMessage | UnsubscribeMessage | CountMessage) & { +export type IncomingMessage = (SubscribeMessage | IncomingEventMessage | UnsubscribeMessage | CountMessage | AuthMessage) & { [ContextMetadataKey]?: ContextMetadata } -export type OutgoingMessage = OutgoingEventMessage | EndOfStoredEventsNotice | NoticeMessage | CommandResult | CountResultMessage | ClosedMessage +export type OutgoingMessage = OutgoingEventMessage | EndOfStoredEventsNotice | NoticeMessage | CommandResult | CountResultMessage | ClosedMessage | AuthChallengeMessage export type SubscribeMessage = { [index in Range<2, 100>]: SubscriptionFilter @@ -89,3 +90,14 @@ export interface ClosedMessage { 1: SubscriptionId 2: string } + +// NIP-42 +export interface AuthMessage { + 0: MessageType.AUTH + 1: Event +} + +export interface AuthChallengeMessage { + 0: MessageType.AUTH + 1: string +} diff --git a/src/adapters/redis-adapter.ts b/src/adapters/redis-adapter.ts index 3b8e062f..0203d02b 100644 --- a/src/adapters/redis-adapter.ts +++ b/src/adapters/redis-adapter.ts @@ -96,6 +96,7 @@ export class RedisAdapter implements ICacheAdapter { return this.client.zAdd(key, members) } + public async deleteKey(key: string): Promise { await this.connection logger('delete %s key', key) @@ -123,4 +124,5 @@ export class RedisAdapter implements ICacheAdapter { return await this.client.evalSha(this.scriptShas.get(script)!, { keys, arguments: args }) } + } diff --git a/src/adapters/web-socket-adapter.ts b/src/adapters/web-socket-adapter.ts index cd81e8e6..570a50b2 100644 --- a/src/adapters/web-socket-adapter.ts +++ b/src/adapters/web-socket-adapter.ts @@ -1,3 +1,4 @@ +import { randomBytes } from 'crypto' import cluster from 'cluster' import { EventEmitter } from 'stream' import { IncomingMessage as IncomingHttpMessage } from 'http' @@ -5,7 +6,7 @@ import { WebSocket } from 'ws' import { ZodError } from 'zod' import { ContextMetadata, Factory } from '../@types/base' -import { createNoticeMessage, createOutgoingEventMessage } from '../utils/messages' +import { createAuthChallengeMessage, createNoticeMessage, createOutgoingEventMessage } from '../utils/messages' import { IAbortable, IMessageHandler } from '../@types/message-handlers' import { IncomingMessage, OutgoingMessage } from '../@types/messages' import { IWebSocketAdapter, IWebSocketServerAdapter } from '../@types/adapters' @@ -32,6 +33,8 @@ export class WebSocketAdapter extends EventEmitter implements IWebSocketAdapter private clientAddress: SocketAddress private alive: boolean private subscriptions: Map + private readonly challenge: string + private readonly authenticatedPubkeys: Set public constructor( private readonly client: WebSocket, @@ -79,6 +82,11 @@ export class WebSocketAdapter extends EventEmitter implements IWebSocketAdapter .on(WebSocketAdapterEvent.Message, this.sendMessage.bind(this)) logger('client %s connected from %s', this.clientId, this.clientAddress.address) + + // NIP-42 + this.challenge = randomBytes(32).toString('base64url') + this.authenticatedPubkeys = new Set() + this.sendMessage(createAuthChallengeMessage(this.challenge)) } public getClientId(): string { @@ -141,6 +149,19 @@ export class WebSocketAdapter extends EventEmitter implements IWebSocketAdapter return new Map(this.subscriptions) } + // NIP-42 + public getChallenge(): string { + return this.challenge + } + + public getAuthenticatedPubkeys(): ReadonlySet { + return new Set(this.authenticatedPubkeys) + } + + public addAuthenticatedPubkey(pubkey: string): void { + this.authenticatedPubkeys.add(pubkey) + } + private async onClientMessage(raw: Buffer) { this.alive = true let abortable = false @@ -241,6 +262,7 @@ export class WebSocketAdapter extends EventEmitter implements IWebSocketAdapter private onClientClose() { this.alive = false this.subscriptions.clear() + this.authenticatedPubkeys.clear() const handlers = abortableMessageHandlers.get(this.client) if (Array.isArray(handlers) && handlers.length) { diff --git a/src/cache/client.ts b/src/cache/client.ts index d9064657..c00a4b4b 100644 --- a/src/cache/client.ts +++ b/src/cache/client.ts @@ -4,12 +4,50 @@ import { createLogger } from '../factories/logger-factory' const logger = createLogger('cache-client') -export const getCacheConfig = (): RedisClientOptions => ({ - url: process.env.REDIS_URI - ? process.env.REDIS_URI - : `redis://${process.env.REDIS_USER}:${process.env.REDIS_PASSWORD}@${process.env.REDIS_HOST}:${process.env.REDIS_PORT}`, - password: process.env.REDIS_PASSWORD, -}) +const redactRedisUrlCredentials = (url: string): string => { + try { + const parsedUrl = new URL(url) + + if (!parsedUrl.username && !parsedUrl.password) { + return url + } + + parsedUrl.username = parsedUrl.username ? '***' : '' + parsedUrl.password = parsedUrl.password ? '***' : '' + + return parsedUrl.toString() + } catch { + return url + } +} + +export const getCacheConfig = (): RedisClientOptions => { + const password = process.env.REDIS_PASSWORD + + if (process.env.REDIS_URI) { + return { + url: process.env.REDIS_URI, + ...(password ? { password } : {}), + } + } + + const host = process.env.REDIS_HOST + const port = process.env.REDIS_PORT + + if (password) { + const username = process.env.REDIS_USER ?? 'default' + + return { + url: `redis://${host}:${port}`, + username, + password, + } + } + + return { + url: `redis://${host}:${port}`, + } +} let instance: CacheClient | undefined = undefined @@ -18,7 +56,10 @@ export const getCacheClient = (): CacheClient => { const config = getCacheConfig() // eslint-disable-next-line @typescript-eslint/no-unused-vars const { password: _, ...loggableConfig } = config - logger('config: %o', loggableConfig) + logger('config: %o', { + ...loggableConfig, + ...(loggableConfig.url ? { url: redactRedisUrlCredentials(loggableConfig.url) } : {}), + }) instance = createClient(config) } diff --git a/src/cli/commands/dev.ts b/src/cli/commands/dev.ts index 7b4fa3d0..59dbbbc9 100644 --- a/src/cli/commands/dev.ts +++ b/src/cli/commands/dev.ts @@ -137,3 +137,21 @@ export const runDevTestIntegration = async (): Promise => { () => runCommand('pnpm', ['run', 'test:integration']), ) } + +export const runDevTestPerfConnection = async (): Promise => { + return runWithSpinner( + 'Running connection rate limit performance test...', + 'Connection rate limit test completed', + 'Connection rate limit test failed', + () => runCommand('k6', ['run', 'test/performance/connection-limiting-k6.ts']), + ) +} + +export const runDevTestPerfMessage = async (): Promise => { + return runWithSpinner( + 'Running message rate limit performance test...', + 'Message rate limit test completed', + 'Message rate limit test failed', + () => runCommand('k6', ['run', 'test/performance/message-limiting-k6.ts']), + ) +} diff --git a/src/cli/commands/info.ts b/src/cli/commands/info.ts index 513e3e4d..9b162ff8 100644 --- a/src/cli/commands/info.ts +++ b/src/cli/commands/info.ts @@ -57,7 +57,7 @@ const getEventCount = async (): Promise => { const getRelayUptimeSeconds = async (): Promise => { const idResult = await runCommandWithOutput('docker', ['compose', 'ps', '-q', 'nostream'], { timeoutMs: 1000 }) - if (idResult.code !== 0) { + if (!idResult.ok || idResult.code !== 0) { return null } @@ -69,7 +69,7 @@ const getRelayUptimeSeconds = async (): Promise => { const startedAtResult = await runCommandWithOutput('docker', ['inspect', '--format', '{{.State.StartedAt}}', containerId], { timeoutMs: 1000, }) - if (startedAtResult.code !== 0) { + if (!startedAtResult.ok || startedAtResult.code !== 0) { return null } diff --git a/src/cli/commands/update.ts b/src/cli/commands/update.ts index 6fb3026d..0c6859da 100644 --- a/src/cli/commands/update.ts +++ b/src/cli/commands/update.ts @@ -18,6 +18,10 @@ export const runUpdate = async (passthrough: string[]): Promise => { } const stashResult = await runCommandWithOutput('git', ['stash', 'push', '-u', '-m', 'nostream-cli-update']) + if (!stashResult.ok) { + spinner.fail(stashResult.ok === false && stashResult.reason === 'not-found' ? 'Update failed: git is not installed' : 'Update failed while stashing local changes') + return 1 + } if (stashResult.code !== 0) { spinner.fail('Update failed while stashing local changes') return stashResult.code diff --git a/src/cli/index.ts b/src/cli/index.ts index 8366eabe..f3e4f53c 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -28,6 +28,8 @@ import { runDevTestCli, runDevTestIntegration, runDevTestUnit, + runDevTestPerfConnection, + runDevTestPerfMessage } from './commands/dev' import { runTui } from './tui/main' import { logError, logInfo } from './utils/output' @@ -97,6 +99,8 @@ const devSubHelp: Record = { 'test:unit': 'Usage: nostream dev test:unit', 'test:cli': 'Usage: nostream dev test:cli', 'test:integration': 'Usage: nostream dev test:integration', + 'test:perf:connection': 'Usage: nostream dev test:perf:connection', + 'test:perf:message': 'Usage: nostream dev test:perf:message', } const withErrorBoundary = @@ -410,6 +414,10 @@ cli return runDevTestCli() case 'test:integration': return runDevTestIntegration() + case 'test:perf:connection': + return runDevTestPerfConnection() + case 'test:perf:message': + return runDevTestPerfMessage() default: logInfo( 'Usage: nostream dev [args]', diff --git a/src/cli/utils/process.ts b/src/cli/utils/process.ts index a574de08..51185144 100644 --- a/src/cli/utils/process.ts +++ b/src/cli/utils/process.ts @@ -7,6 +7,10 @@ export type RunOptions = { timeoutMs?: number } +export type CommandResult = + | { ok: true; code: number; stdout: string; stderr: string } + | { ok: false; reason: 'not-found' | 'permission-denied' | 'spawn-error' | 'timeout' | 'signal'; stdout: string; stderr: string } + export const runCommand = (command: string, args: string[], options: RunOptions = {}): Promise => { return new Promise((resolve, reject) => { const child = spawn(command, args, { @@ -38,10 +42,19 @@ export const runCommandWithOutput = ( command: string, args: string[], options: RunOptions = {}, -): Promise<{ code: number; stdout: string; stderr: string }> => { - return new Promise((resolve, reject) => { +): Promise => { + return new Promise((resolve) => { let stdout = '' let stderr = '' + let timedOut = false + let settled = false + + const settle = (result: CommandResult) => { + if (!settled) { + settled = true + resolve(result) + } + } const child = spawn(command, args, { cwd: options.cwd, @@ -53,6 +66,7 @@ export const runCommandWithOutput = ( const timer = typeof options.timeoutMs === 'number' ? setTimeout(() => { + timedOut = true child.kill('SIGTERM') }, options.timeoutMs) : undefined @@ -65,17 +79,31 @@ export const runCommandWithOutput = ( stderr += chunk.toString() }) - child.on('error', reject) - child.on('close', (code) => { - if (timer) { - clearTimeout(timer) + child.on('error', (err: NodeJS.ErrnoException) => { + if (timer) { clearTimeout(timer) } + if (err.code === 'ENOENT') { + settle({ ok: false, reason: 'not-found', stdout, stderr }) + } else if (err.code === 'EACCES') { + settle({ ok: false, reason: 'permission-denied', stdout, stderr }) + } else { + settle({ ok: false, reason: 'spawn-error', stdout, stderr }) + } + }) + + child.on('close', (code, signal) => { + if (timer) { clearTimeout(timer) } + + if (timedOut) { + settle({ ok: false, reason: 'timeout', stdout, stderr }) + return + } + + if (signal !== null && code === null) { + settle({ ok: false, reason: 'signal', stdout, stderr }) + return } - resolve({ - code: code ?? 1, - stdout, - stderr, - }) + settle({ ok: true, code: code ?? 1, stdout, stderr }) }) }) } diff --git a/src/constants/base.ts b/src/constants/base.ts index f1daba11..d5b5f526 100644 --- a/src/constants/base.ts +++ b/src/constants/base.ts @@ -11,6 +11,8 @@ export enum EventKinds { SEAL = 13, DIRECT_MESSAGE = 14, FILE_MESSAGE = 15, + // NIP-25: External content reaction + EXTERNAL_CONTENT_REACTION = 17, REQUEST_TO_VANISH = 62, // Channels CHANNEL_CREATION = 40, @@ -20,6 +22,10 @@ export enum EventKinds { CHANNEL_MUTE_USER = 44, CHANNEL_RESERVED_FIRST = 45, CHANNEL_RESERVED_LAST = 49, + // Marmot Protocol: E2EE Group Messaging (MIPs) + MARMOT_KEY_PACKAGE_LEGACY = 443, // MIP-00: legacy KeyPackage (regular event, superseded by 30443) + MARMOT_WELCOME_RUMOR = 444, // MIP-02: Welcome rumor (must not be published directly; wraps inside gift wrap) + MARMOT_GROUP_EVENT = 445, // MIP-03: Group Event (proposals, commits, application messages) // NIP-17: Gift Wrap GIFT_WRAP = 1059, // NIP-03: OpenTimestamps attestation @@ -32,12 +38,20 @@ export enum EventKinds { ZAP_RECEIPT = 9735, // Replaceable events REPLACEABLE_FIRST = 10000, + // NIP-65: Relay List Metadata + RELAY_LIST = 10002, + // Marmot Protocol MIP-00: KeyPackage Relay List + MARMOT_KEY_PACKAGE_RELAY_LIST = 10051, REPLACEABLE_LAST = 19999, // Ephemeral events EPHEMERAL_FIRST = 20000, + // NIP-42: Client Authentication + AUTH = 22242, EPHEMERAL_LAST = 29999, // Parameterized replaceable events PARAMETERIZED_REPLACEABLE_FIRST = 30000, + // Marmot Protocol MIP-00: KeyPackage (addressable, replaces legacy 443) + MARMOT_KEY_PACKAGE = 30443, PARAMETERIZED_REPLACEABLE_LAST = 39999, USER_APPLICATION_FIRST = 40000, } @@ -54,6 +68,19 @@ export enum EventTags { Invoice = 'bolt11', // NIP-03: target event kind on an OpenTimestamps attestation Kind = 'k', + // NIP-25: Reactions + Address = 'a', + Index = 'i', + Emoji = 'emoji', + // NIP-12: geohash tag for location-based queries + Geohash = 'g', + // NIP-42: Authentication tags + Challenge = 'challenge', + AuthRelay = 'relay', + // Marmot Protocol MIP-03: group ID for filtering kind:445 Group Events + Group = 'h', + // NIP-70: Protected Events + Protected = '-', } export const ALL_RELAYS = 'ALL_RELAYS' diff --git a/src/controllers/callbacks/lnbits-callback-controller.ts b/src/controllers/callbacks/lnbits-callback-controller.ts index 30fdae34..5f0c0db5 100644 --- a/src/controllers/callbacks/lnbits-callback-controller.ts +++ b/src/controllers/callbacks/lnbits-callback-controller.ts @@ -25,13 +25,6 @@ export class LNbitsCallbackController implements IController { const settings = createSettings() const remoteAddress = getRemoteAddress(request, settings) - const paymentProcessor = settings.payments?.processor ?? 'null' - - if (paymentProcessor !== 'lnbits') { - logger('denied request from %s to /callbacks/lnbits which is not the current payment processor', remoteAddress) - response.status(403).send('Forbidden') - return - } const queryValidation = validateSchema(lnbitsCallbackQuerySchema)(request.query) if (queryValidation.error) { diff --git a/src/controllers/callbacks/opennode-callback-controller.ts b/src/controllers/callbacks/opennode-callback-controller.ts index 66eb7981..cc349c95 100644 --- a/src/controllers/callbacks/opennode-callback-controller.ts +++ b/src/controllers/callbacks/opennode-callback-controller.ts @@ -22,15 +22,6 @@ export class OpenNodeCallbackController implements IController { const settings = createSettings() const remoteAddress = getRemoteAddress(request, settings) - const paymentProcessor = settings.payments?.processor - - if (paymentProcessor !== 'opennode') { - logger('denied request from %s to /callbacks/opennode which is not the current payment processor', remoteAddress) - response - .status(403) - .send('Forbidden') - return - } const bodyValidation = validateSchema(opennodeWebhookCallbackBodySchema)(request.body) if (bodyValidation.error) { diff --git a/src/controllers/callbacks/zebedee-callback-controller.ts b/src/controllers/callbacks/zebedee-callback-controller.ts index e9c448a3..e4c2b792 100644 --- a/src/controllers/callbacks/zebedee-callback-controller.ts +++ b/src/controllers/callbacks/zebedee-callback-controller.ts @@ -30,7 +30,6 @@ export class ZebedeeCallbackController implements IController { const { ipWhitelist = [] } = settings.paymentsProcessors?.zebedee ?? {} const remoteAddress = getRemoteAddress(request, settings) - const paymentProcessor = settings.payments?.processor if (ipWhitelist.length && !ipWhitelist.includes(remoteAddress)) { logger('unauthorized request from %s to /callbacks/zebedee', remoteAddress) @@ -38,12 +37,6 @@ export class ZebedeeCallbackController implements IController { return } - if (paymentProcessor !== 'zebedee') { - logger('denied request from %s to /callbacks/zebedee which is not the current payment processor', remoteAddress) - response.status(403).send('Forbidden') - return - } - const invoice = fromZebedeeInvoice(request.body) logger('invoice', invoice) diff --git a/src/controllers/invoices/get-invoice-controller.ts b/src/controllers/invoices/get-invoice-controller.ts index 621532f5..510d9b92 100644 --- a/src/controllers/invoices/get-invoice-controller.ts +++ b/src/controllers/invoices/get-invoice-controller.ts @@ -8,9 +8,10 @@ import { FeeSchedule } from '../../@types/settings' import { IController } from '../../@types/controllers' import { getTemplate } from '../../utils/template-cache' +import { getPublicPathPrefix } from '../../utils/http' export class GetInvoiceController implements IController { - public async handleRequest(_req: Request, res: Response): Promise { + public async handleRequest(req: Request, res: Response): Promise { const settings = createSettings() if ( @@ -21,6 +22,7 @@ export class GetInvoiceController implements IController { const feeSchedule = path(['payments', 'feeSchedules', 'admission', '0'], settings) const page = getTemplate('./resources/get-invoice.html') .replaceAll('{{name}}', escapeHtml(name)) + .replaceAll('{{path_prefix}}', escapeHtml(getPublicPathPrefix(req, settings))) .replaceAll('{{processor_json}}', safeJsonForScript(settings.payments.processor)) .replaceAll('{{amount}}', (BigInt(feeSchedule.amount) / 1000n).toString()) .replaceAll('{{nonce}}', res.locals.nonce) diff --git a/src/controllers/invoices/post-invoice-controller.ts b/src/controllers/invoices/post-invoice-controller.ts index e8bee3fe..f35443dc 100644 --- a/src/controllers/invoices/post-invoice-controller.ts +++ b/src/controllers/invoices/post-invoice-controller.ts @@ -14,7 +14,7 @@ import { createLogger } from '../../factories/logger-factory' import { escapeHtml, safeJsonForScript } from '../../utils/html' import { fromBech32, toBech32 } from '../../utils/transform' import { getPublicKey, getRelayPrivateKey } from '../../utils/event' -import { getRemoteAddress } from '../../utils/http' +import { getPublicPathPrefix, getRemoteAddress } from '../../utils/http' import { getTemplate } from '../../utils/template-cache' const logger = createLogger('post-invoice-controller') @@ -125,6 +125,7 @@ export class PostInvoiceController implements IController { const relayPubkey = getPublicKey(relayPrivkey) const expiresAt = invoice.expiresAt?.toISOString() ?? '' + const pathPrefix = getPublicPathPrefix(request, currentSettings) const pageContent = getTemplate('./resources/post-invoice.html') const body = pageContent @@ -133,6 +134,7 @@ export class PostInvoiceController implements IController { .replaceAll('{{relay_url_html}}', escapeHtml(relayUrl)) .replaceAll('{{invoice_html}}', escapeHtml(invoice.bolt11)) .replaceAll('{{pubkey_html}}', escapeHtml(pubkey)) + .replaceAll('{{path_prefix}}', escapeHtml(pathPrefix)) .replaceAll('{{amount}}', (amount / 1000n).toString()) // JS contexts — safeJsonForScript serializes and escapes < to prevent injection .replaceAll('{{reference_json}}', safeJsonForScript(invoice.id)) @@ -141,6 +143,7 @@ export class PostInvoiceController implements IController { .replaceAll('{{invoice_json}}', safeJsonForScript(invoice.bolt11)) .replaceAll('{{pubkey_json}}', safeJsonForScript(pubkey)) .replaceAll('{{expires_at_json}}', safeJsonForScript(expiresAt)) + .replaceAll('{{path_prefix_json}}', safeJsonForScript(pathPrefix)) .replaceAll('{{processor_json}}', safeJsonForScript(currentSettings.payments.processor)) // nonce is crypto-random base64 — safe in both attribute and script contexts .replaceAll('{{nonce}}', response.locals.nonce) diff --git a/src/factories/event-strategy-factory.ts b/src/factories/event-strategy-factory.ts index 289804f7..b88aa1c6 100644 --- a/src/factories/event-strategy-factory.ts +++ b/src/factories/event-strategy-factory.ts @@ -3,17 +3,20 @@ import { isDeleteEvent, isEphemeralEvent, isGiftWrapEvent, + isMarmotGroupEvent, isOpenTimestampsEvent, isParameterizedReplaceableEvent, isReplaceableEvent, isRequestToVanishEvent, } from '../utils/event' +import { isRelayListEvent } from '../utils/nip65' import { DefaultEventStrategy } from '../handlers/event-strategies/default-event-strategy' import { DeleteEventStrategy } from '../handlers/event-strategies/delete-event-strategy' import { EphemeralEventStrategy } from '../handlers/event-strategies/ephemeral-event-strategy' import { Event } from '../@types/event' import { Factory } from '../@types/base' import { GiftWrapEventStrategy } from '../handlers/event-strategies/gift-wrap-event-strategy' +import { GroupEventStrategy } from '../handlers/event-strategies/group-event-strategy' import { IEventStrategy } from '../@types/message-handlers' import { IWebSocketAdapter } from '../@types/adapters' import { ParameterizedReplaceableEventStrategy } from '../handlers/event-strategies/parameterized-replaceable-event-strategy' @@ -31,9 +34,11 @@ export const eventStrategyFactory = return new VanishEventStrategy(adapter, eventRepository, userRepository) } else if (isGiftWrapEvent(event)) { return new GiftWrapEventStrategy(adapter, eventRepository) + } else if (isMarmotGroupEvent(event)) { + return new GroupEventStrategy(adapter, eventRepository) } else if (isOpenTimestampsEvent(event)) { return new TimestampEventStrategy(adapter, eventRepository) - } else if (isReplaceableEvent(event)) { + } else if (isRelayListEvent(event) || isReplaceableEvent(event)) { return new ReplaceableEventStrategy(adapter, eventRepository) } else if (isEphemeralEvent(event)) { return new EphemeralEventStrategy(adapter) @@ -44,4 +49,4 @@ export const eventStrategyFactory = } return new DefaultEventStrategy(adapter, eventRepository) - } + } \ No newline at end of file diff --git a/src/factories/message-handler-factory.ts b/src/factories/message-handler-factory.ts index 273b5b37..b6943725 100644 --- a/src/factories/message-handler-factory.ts +++ b/src/factories/message-handler-factory.ts @@ -2,6 +2,7 @@ import { ICacheAdapter, IWebSocketAdapter } from '../@types/adapters' import { IEventRepository, INip05VerificationRepository, IUserRepository } from '../@types/repositories' import { IncomingMessage, MessageType } from '../@types/messages' import { createSettings } from './settings-factory' +import { AuthMessageHandler } from '../handlers/auth-message-handler' import { CountMessageHandler } from '../handlers/count-message-handler' import { EventMessageHandler } from '../handlers/event-message-handler' import { eventStrategyFactory } from './event-strategy-factory' @@ -45,6 +46,8 @@ export const messageHandlerFactory = return new UnsubscribeMessageHandler(adapter) case MessageType.COUNT: return new CountMessageHandler(adapter, eventRepository, createSettings) + case MessageType.AUTH: + return new AuthMessageHandler(adapter, createSettings) default: throw new Error(`Unknown message type: ${String(message[0]).substring(0, 64)}`) } diff --git a/src/handlers/event-message-handler.ts b/src/handlers/event-message-handler.ts index 235b1c13..c58efeb9 100644 --- a/src/handlers/event-message-handler.ts +++ b/src/handlers/event-message-handler.ts @@ -23,6 +23,7 @@ import { isFileMessageEvent, isRequestToVanishEvent, isSealEvent, + isWelcomeRumorEvent, } from '../utils/event' import { IEventRepository, INip05VerificationRepository, IUserRepository } from '../@types/repositories' import { IEventStrategy, IMessageHandler } from '../@types/message-handlers' @@ -238,9 +239,16 @@ export class EventMessageHandler implements IMessageHandler { // NIP-17: kind 13 (Seal) and kind 14 (Direct Message) are inner events that // must never be published directly to a relay. They are encrypted inside a // kind 1059 Gift Wrap (NIP-59) before being sent here. - if (isSealEvent(event) || isDirectMessageEvent(event) || isFileMessageEvent(event)) { + // Marmot MIP-02: kind 444 (Welcome rumor) is similarly an inner event that + // must only be delivered inside a kind 1059 gift wrap. + if (isSealEvent(event) || isDirectMessageEvent(event) || isFileMessageEvent(event) || isWelcomeRumorEvent(event)) { return `blocked: kind ${event.kind} events must not be published directly; wrap them in a kind 1059 gift wrap` } + + // NIP-42: auth events must use the AUTH message type + if (event.kind === EventKinds.AUTH) { + return 'invalid: auth events must be sent using the AUTH message type' + } } protected async isBlockedByRequestToVanish(event: Event): Promise { diff --git a/src/handlers/request-handlers/get-privacy-request-handler.ts b/src/handlers/request-handlers/get-privacy-request-handler.ts index eaf931d1..fbe0379a 100644 --- a/src/handlers/request-handlers/get-privacy-request-handler.ts +++ b/src/handlers/request-handlers/get-privacy-request-handler.ts @@ -3,18 +3,21 @@ import { NextFunction, Request, Response } from 'express' import { createSettings as settings } from '../../factories/settings-factory' import { escapeHtml } from '../../utils/html' +import { getPublicPathPrefix } from '../../utils/http' import { getTemplate } from '../../utils/template-cache' -export const getPrivacyRequestHandler = (_req: Request, res: Response, next: NextFunction) => { +export const getPrivacyRequestHandler = (req: Request, res: Response, next: NextFunction) => { + const currentSettings = settings() const { info: { name }, - } = settings() + } = currentSettings let page: string try { page = getTemplate('./resources/privacy.html') .replaceAll('{{name}}', escapeHtml(name)) + .replaceAll('{{path_prefix}}', escapeHtml(getPublicPathPrefix(req, currentSettings))) .replaceAll('{{nonce}}', res.locals.nonce) } catch (err) { next(err) diff --git a/src/handlers/request-handlers/get-terms-request-handler.ts b/src/handlers/request-handlers/get-terms-request-handler.ts index 66747cc2..32cc755b 100644 --- a/src/handlers/request-handlers/get-terms-request-handler.ts +++ b/src/handlers/request-handlers/get-terms-request-handler.ts @@ -1,17 +1,20 @@ import { NextFunction, Request, Response } from 'express' import { escapeHtml } from '../../utils/html' +import { getPublicPathPrefix } from '../../utils/http' import { getTemplate } from '../../utils/template-cache' import { createSettings as settings } from '../../factories/settings-factory' -export const getTermsRequestHandler = (_req: Request, res: Response, next: NextFunction) => { +export const getTermsRequestHandler = (req: Request, res: Response, next: NextFunction) => { + const currentSettings = settings() const { info: { name }, - } = settings() + } = currentSettings let page: string try { page = getTemplate('./resources/terms.html') .replaceAll('{{name}}', escapeHtml(name)) + .replaceAll('{{path_prefix}}', escapeHtml(getPublicPathPrefix(req, currentSettings))) .replaceAll('{{nonce}}', res.locals.nonce) } catch (err) { next(err) diff --git a/src/routes/index.ts b/src/routes/index.ts index 95a250c4..1c50e500 100644 --- a/src/routes/index.ts +++ b/src/routes/index.ts @@ -1,4 +1,3 @@ -import accepts from 'accepts' import express, { Router } from 'express' import { nodeinfo21Handler, nodeinfoHandler } from '../handlers/request-handlers/nodeinfo-handler' @@ -9,12 +8,12 @@ import { getPrivacyRequestHandler } from '../handlers/request-handlers/get-priva import { getTermsRequestHandler } from '../handlers/request-handlers/get-terms-request-handler' import invoiceRouter from './invoices' import { rateLimiterMiddleware } from '../handlers/request-handlers/rate-limiter-middleware' -import { rootRequestHandler } from '../handlers/request-handlers/root-request-handler' +import { hasExplicitNostrJsonAcceptHeader, rootRequestHandler } from '../handlers/request-handlers/root-request-handler' const router: Router = express.Router() router.use((req, res, next) => { - if (req.method === 'GET' && accepts(req).type(['application/nostr+json'])) { + if (req.method === 'GET' && req.path === '/' && hasExplicitNostrJsonAcceptHeader(req)) { return rootRequestHandler(req, res, next) } next() diff --git a/src/schemas/base-schema.ts b/src/schemas/base-schema.ts index 5af6c308..f03461d7 100644 --- a/src/schemas/base-schema.ts +++ b/src/schemas/base-schema.ts @@ -1,7 +1,13 @@ import { z } from 'zod' +import { GEOHASH_FILTER_PATTERN, GEOHASH_PATTERN } from '../utils/geohash' + const lowerHexRegex = /^[0-9a-f]+$/ +// NIP-12 geohash schemas +export const geohashSchema = z.string().regex(GEOHASH_PATTERN, 'Invalid geohash') +export const geohashFilterValueSchema = z.string().regex(GEOHASH_FILTER_PATTERN, 'Invalid geohash filter') + export const prefixSchema = z.string().regex(lowerHexRegex).min(4).max(64) export const idSchema = z.string().regex(lowerHexRegex).length(64) diff --git a/src/schemas/event-schema.ts b/src/schemas/event-schema.ts index 6aa2cf22..26bfb86f 100644 --- a/src/schemas/event-schema.ts +++ b/src/schemas/event-schema.ts @@ -1,6 +1,16 @@ import { z } from 'zod' -import { createdAtSchema, idSchema, kindSchema, pubkeySchema, signatureSchema, tagSchema } from './base-schema' +import { EventKinds, EventTags } from '../constants/base' +import { isExternalContentReactionEvent, isReactionEvent } from '../utils/nip25' +import { + createdAtSchema, + geohashSchema, + idSchema, + kindSchema, + pubkeySchema, + signatureSchema, + tagSchema, +} from './base-schema' /** * { @@ -29,3 +39,57 @@ export const eventSchema = z sig: signatureSchema, }) .strict() + .superRefine((event, ctx) => { + if (isReactionEvent(event)) { + let hasEventTag = false + let hasAddressTag = false + for (const tag of event.tags) { + if (tag[0] === EventTags.Event && typeof tag[1] === 'string' && tag[1].length > 0) { hasEventTag = true } + else if (tag[0] === EventTags.Address && typeof tag[1] === 'string' && tag[1].length > 0) { hasAddressTag = true } + if (hasEventTag && hasAddressTag) { break } + } + if (!hasEventTag && !hasAddressTag) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Reaction event (kind 7) must have at least one e or a tag', + path: ['tags'], + }) + } + } else if (isExternalContentReactionEvent(event)) { + let hasKTag = false + let hasITag = false + for (const tag of event.tags) { + if (tag[0] === EventTags.Kind && tag.length >= 2 && typeof tag[1] === 'string' && tag[1].length > 0) { hasKTag = true } + else if (tag[0] === EventTags.Index && tag.length >= 2 && typeof tag[1] === 'string' && tag[1].length > 0) { hasITag = true } + if (hasKTag && hasITag) { break } + } + if (!hasKTag || !hasITag) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'External content reaction event (kind 17) must have k and i tags', + path: ['tags'], + }) + } + } else if (event.kind === EventKinds.RELAY_LIST) { + event.tags.forEach((tag, index) => { + if (tag[0] === EventTags.Relay && !z.string().url().safeParse(tag[1]).success) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `Invalid relay URL`, + path: ['tags', index, 1], + }) + } + }) + } + + // Validate geohash tag values (NIP-12 #g) + event.tags.forEach((tag, index) => { + if (tag[0] === EventTags.Geohash && typeof tag[1] === 'string' && !geohashSchema.safeParse(tag[1]).success) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Invalid geohash', + path: ['tags', index, 1], + }) + } + }) + }) diff --git a/src/schemas/message-schema.ts b/src/schemas/message-schema.ts index 53b8f09f..c246b4ee 100644 --- a/src/schemas/message-schema.ts +++ b/src/schemas/message-schema.ts @@ -55,4 +55,7 @@ export const countMessageSchema = z export const closeMessageSchema = z.tuple([z.literal(MessageType.CLOSE), subscriptionSchema]) -export const messageSchema = z.union([eventMessageSchema, reqMessageSchema, closeMessageSchema, countMessageSchema]) +// NIP-42 +export const authMessageSchema = z.tuple([z.literal(MessageType.AUTH), eventSchema]) + +export const messageSchema = z.union([eventMessageSchema, reqMessageSchema, closeMessageSchema, countMessageSchema, authMessageSchema]) diff --git a/src/utils/http.ts b/src/utils/http.ts index 0903330a..5083f915 100644 --- a/src/utils/http.ts +++ b/src/utils/http.ts @@ -27,16 +27,21 @@ const isTrustedProxy = (ipAddress: string, settings: Settings): boolean => { }) } -export const getRemoteAddress = (request: IncomingMessage, settings: Settings): string => { - let header: string | undefined - // TODO: Remove deprecation warning - if ('network' in settings && 'remote_ip_header' in settings.network) { - logger.warn(`WARNING: Setting network.remote_ip_header is deprecated and will be removed in a future version. - Use network.remoteIpHeader instead.`) - header = settings.network['remote_ip_header'] as string - } else { - header = settings.network.remoteIpHeader as string +const warnIfDeprecatedRemoteIpHeaderIsConfigured = (settings: Settings): void => { + const networkSettings = settings.network as Record | undefined + const deprecatedHeader = networkSettings?.remote_ip_header + + if (typeof deprecatedHeader === 'string' && deprecatedHeader.trim() !== '') { + logger.warn( + 'WARNING: network.remote_ip_header is deprecated and no longer used. Rename it to network.remoteIpHeader to restore forwarded header handling.', + ) } +} + +export const getRemoteAddress = (request: IncomingMessage, settings: Settings): string => { + warnIfDeprecatedRemoteIpHeaderIsConfigured(settings) + + const header = settings.network?.remoteIpHeader as string const trustedProxies = settings.network?.trustedProxies if (header && (!Array.isArray(trustedProxies) || trustedProxies.length === 0)) { @@ -56,3 +61,59 @@ export const getRemoteAddress = (request: IncomingMessage, settings: Settings): return (result as string).split(',')[0].trim() } + +const normalizePathPrefix = (pathPrefix: string | undefined): string => { + if (typeof pathPrefix !== 'string') { + return '' + } + + const prefix = pathPrefix.split(',')[0].trim() + + if (!prefix.startsWith('/') || prefix.startsWith('//')) { + return '' + } + + try { + const { pathname } = new URL(prefix, 'http://nostream.local') + const normalized = pathname.replace(/\/+$/, '') + + return normalized === '/' ? '' : normalized + } catch { + return '' + } +} + +const getRelayUrlPathPrefix = (relayUrl: string | undefined): string => { + if (typeof relayUrl !== 'string') { + return '' + } + + try { + return normalizePathPrefix(new URL(relayUrl).pathname) + } catch { + return '' + } +} + +const getTrustedForwardedPathPrefix = (request: IncomingMessage, settings: Settings): string => { + const socketAddress = request.socket?.remoteAddress + if (typeof socketAddress !== 'string' || !isTrustedProxy(socketAddress, settings)) { + return '' + } + + const rawHeader = request.headers?.['x-forwarded-prefix'] + const rawPrefix = Array.isArray(rawHeader) ? rawHeader[0] : rawHeader + + return normalizePathPrefix(rawPrefix) +} + +export const getPublicPathPrefix = (request: IncomingMessage, settings: Settings): string => { + return getTrustedForwardedPathPrefix(request, settings) || getRelayUrlPathPrefix(settings.info?.relay_url) +} + +export const joinPathPrefix = (prefix: string, path: string): string => { + const normalizedPrefix = prefix.replace(/\/+$/, '') + const normalizedPath = path.startsWith('/') ? path : `/${path}` + + return `${normalizedPrefix}${normalizedPath}` +} diff --git a/src/utils/messages.ts b/src/utils/messages.ts index 0b98d5f4..f9189dd0 100644 --- a/src/utils/messages.ts +++ b/src/utils/messages.ts @@ -1,4 +1,5 @@ import { + AuthChallengeMessage, ClosedMessage, CountResultMessage, CountResultPayload, @@ -41,6 +42,11 @@ export const createClosedMessage = (queryId: SubscriptionId, reason: string): Cl return [MessageType.CLOSED, queryId, reason] } +// NIP-42 +export const createAuthChallengeMessage = (challenge: string): AuthChallengeMessage => { + return [MessageType.AUTH, challenge] +} + export const createSubscriptionMessage = ( subscriptionId: SubscriptionId, filters: SubscriptionFilter[], diff --git a/src/utils/sliding-window-rate-limiter.ts b/src/utils/sliding-window-rate-limiter.ts index 44d94432..c91efae4 100644 --- a/src/utils/sliding-window-rate-limiter.ts +++ b/src/utils/sliding-window-rate-limiter.ts @@ -4,24 +4,67 @@ import { ICacheAdapter } from '../@types/adapters' const logger = createLogger('sliding-window-rate-limiter') +const SLIDING_WINDOW_RATE_LIMITER_LUA_SCRIPT = ` + local key = KEYS[1] + local timestamp = tonumber(ARGV[1]) + local period = tonumber(ARGV[2]) + local step = tonumber(ARGV[3]) + local max_rate = tonumber(ARGV[4]) + + local windowStart = timestamp - period + + redis.call('ZREMRANGEBYSCORE', key, 0, windowStart) + + local entries = redis.call('ZRANGE', key, 0, -1) + local hits = 0 + for i=1, #entries do + local step_str = string.match(entries[i], "^[^:]+:([^:]+)") + if step_str then + local entry_step = tonumber(step_str) + if entry_step then + hits = hits + entry_step + end + end + end + + if hits + step > max_rate then + return 1 + end + + local base_member = timestamp .. ':' .. step + local member = base_member + local counter = 0 + while redis.call('ZSCORE', key, member) do + counter = counter + 1 + member = base_member .. ':' .. counter + end + + redis.call('ZADD', key, timestamp, member) + redis.call('PEXPIRE', key, period) + + return 0 +` + export class SlidingWindowRateLimiter implements IRateLimiter { - public constructor(private readonly cache: ICacheAdapter) {} + public constructor( + private readonly cache: ICacheAdapter, + ) { } public async hit(key: string, step: number, options: IRateLimiterOptions): Promise { const timestamp = Date.now() - const { period } = options + const { period, rate } = options - const [, , entries] = await Promise.all([ - this.cache.removeRangeByScoreFromSortedSet(key, 0, timestamp - period), - this.cache.addToSortedSet(key, { [`${timestamp}:${step}`]: timestamp.toString() }), - this.cache.getRangeFromSortedSet(key, 0, -1), - this.cache.setKeyExpiry(key, period), + const result = await this.cache.eval(SLIDING_WINDOW_RATE_LIMITER_LUA_SCRIPT, [key], [ + timestamp.toString(), + period.toString(), + step.toString(), + rate.toString(), ]) - const hits = entries.reduce((acc, timestampAndStep) => acc + Number(timestampAndStep.split(':')[1]), 0) + const isRateLimited = result === 1 || result === '1' - logger('hit count on %s bucket: %d', key, hits) + logger('hit on %s bucket: is rate limited? %s', key, isRateLimited) - return hits > options.rate + return isRateLimited } } diff --git a/test/integration/features/callbacks/opennode-callback.feature.ts b/test/integration/features/callbacks/opennode-callback.feature.ts index 0678e580..74b5d780 100644 --- a/test/integration/features/callbacks/opennode-callback.feature.ts +++ b/test/integration/features/callbacks/opennode-callback.feature.ts @@ -36,8 +36,17 @@ Given('OpenNode callback processing is enabled', function () { ...settings, payments: { ...(settings?.payments ?? {}), + enabled: true, processor: 'opennode', }, + paymentsProcessors: { + ...(settings?.paymentsProcessors ?? {}), + opennode: { + ...(settings?.paymentsProcessors?.opennode ?? {}), + baseURL: 'api.opennode.com', + callbackBaseURL: 'http://localhost:18808/callbacks/opennode', + }, + }, } process.env.OPENNODE_API_KEY = OPENNODE_TEST_API_KEY diff --git a/test/integration/features/nip-11/nip-11.feature b/test/integration/features/nip-11/nip-11.feature index 6ae6bf1b..d6972e8a 100644 --- a/test/integration/features/nip-11/nip-11.feature +++ b/test/integration/features/nip-11/nip-11.feature @@ -22,6 +22,11 @@ Feature: NIP-11 Then the response Content-Type does not include "application/nostr+json" And the response body is not a relay information document + Scenario: Relay serves HTML for typical browser Accept header + When a browser requests the root path + Then the response Content-Type includes "text/html" + And the response body is not a relay information document + Scenario: Relay information document reports max_filters from settings When a client requests the relay information document Then the limitation object contains a max_filters field diff --git a/test/integration/features/nip-11/nip-11.feature.ts b/test/integration/features/nip-11/nip-11.feature.ts index a8fb3d50..4d7413ba 100644 --- a/test/integration/features/nip-11/nip-11.feature.ts +++ b/test/integration/features/nip-11/nip-11.feature.ts @@ -30,6 +30,16 @@ When('a client requests the root path with Accept header {string}', async functi this.parameters.httpResponse = response }) +When('a browser requests the root path', async function(this: World>) { + const response: AxiosResponse = await axios.get(BASE_URL, { + headers: { + Accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8', + }, + validateStatus: () => true, + }) + this.parameters.httpResponse = response +}) + Then('the response status is {int}', function(this: World>, status: number) { expect(this.parameters.httpResponse.status).to.equal(status) }) diff --git a/test/unit/adapters/web-socket-adapter.spec.ts b/test/unit/adapters/web-socket-adapter.spec.ts index be535c38..f3d48066 100644 --- a/test/unit/adapters/web-socket-adapter.spec.ts +++ b/test/unit/adapters/web-socket-adapter.spec.ts @@ -77,6 +77,9 @@ describe('WebSocketAdapter', () => { slidingWindowRateLimiter, settingsFactory, ) + + // Reset send history so existing tests see a clean slate + client.send.resetHistory() }) afterEach(() => { @@ -603,4 +606,94 @@ describe('WebSocketAdapter', () => { ipv6Adapter.removeAllListeners() }) }) + + describe('NIP-42 authentication', () => { + it('sends AUTH challenge message on construction', () => { + const freshClient = { + on: sandbox.stub().returnsThis(), + send: sandbox.stub(), + close: sandbox.stub(), + ping: sandbox.stub(), + pong: sandbox.stub(), + readyState: WebSocket.OPEN, + removeAllListeners: sandbox.stub(), + } + const freshAdapter = new WebSocketAdapter( + freshClient as any, + request, + webSocketServer as any, + createMessageHandler, + slidingWindowRateLimiter, + settingsFactory, + ) + + expect(freshClient.send).to.have.been.calledOnce + const sent = JSON.parse(freshClient.send.firstCall.args[0]) + expect(sent[0]).to.equal('AUTH') + expect(sent[1]).to.be.a('string') + expect(sent[1].length).to.be.greaterThan(0) + freshAdapter.removeAllListeners() + }) + + it('getChallenge returns a non-empty string', () => { + const challenge = adapter.getChallenge() + expect(challenge).to.be.a('string') + expect(challenge.length).to.be.greaterThan(0) + }) + + it('getChallenge returns consistent value for the same adapter', () => { + const c1 = adapter.getChallenge() + const c2 = adapter.getChallenge() + expect(c1).to.equal(c2) + }) + + it('getAuthenticatedPubkeys returns empty set initially', () => { + const pubkeys = adapter.getAuthenticatedPubkeys() + expect(pubkeys.size).to.equal(0) + }) + + it('addAuthenticatedPubkey adds a pubkey', () => { + const pubkey = 'a'.repeat(64) + adapter.addAuthenticatedPubkey(pubkey) + + const pubkeys = adapter.getAuthenticatedPubkeys() + expect(pubkeys.size).to.equal(1) + expect(pubkeys.has(pubkey)).to.be.true + }) + + it('addAuthenticatedPubkey supports multiple pubkeys', () => { + const pk1 = 'a'.repeat(64) + const pk2 = 'b'.repeat(64) + adapter.addAuthenticatedPubkey(pk1) + adapter.addAuthenticatedPubkey(pk2) + + const pubkeys = adapter.getAuthenticatedPubkeys() + expect(pubkeys.size).to.equal(2) + expect(pubkeys.has(pk1)).to.be.true + expect(pubkeys.has(pk2)).to.be.true + }) + + it('addAuthenticatedPubkey deduplicates same pubkey', () => { + const pubkey = 'a'.repeat(64) + adapter.addAuthenticatedPubkey(pubkey) + adapter.addAuthenticatedPubkey(pubkey) + + const pubkeys = adapter.getAuthenticatedPubkeys() + expect(pubkeys.size).to.equal(1) + }) + + it('generates different challenges for different adapters', () => { + const adapter2 = new WebSocketAdapter( + client, + request, + webSocketServer as any, + createMessageHandler, + slidingWindowRateLimiter, + settingsFactory, + ) + + expect(adapter.getChallenge()).not.to.equal(adapter2.getChallenge()) + adapter2.removeAllListeners() + }) + }) }) diff --git a/test/unit/cli/info.spec.ts b/test/unit/cli/info.spec.ts index ffe0bd83..7bf9b892 100644 --- a/test/unit/cli/info.spec.ts +++ b/test/unit/cli/info.spec.ts @@ -31,14 +31,27 @@ describe('runInfo', () => { sinon.restore() }) + it('outputs valid JSON when docker is not installed (ENOENT)', async () => { + sinon.stub(fs, 'existsSync').returns(false) + sinon.stub(processUtils, 'runCommandWithOutput').resolves({ ok: false, reason: 'not-found', stdout: '', stderr: '' }) + + const code = await infoCommand.runInfo({ json: true }) + + expect(code).to.equal(0) + const parsed = JSON.parse(stdout) + expect(parsed).to.have.nested.property('runtime.uptimeSeconds', null) + expect(stderr).to.equal('') + }) + it('prints detected I2P hostnames as JSON', async () => { sinon.stub(fs, 'existsSync').callsFake((target) => String(target).endsWith('nostream.dat')) sinon .stub(processUtils, 'runCommandWithOutput') .onFirstCall() - .resolves({ code: 1, stdout: '', stderr: '' }) + .resolves({ ok: true, code: 1, stdout: '', stderr: '' }) .onSecondCall() .resolves({ + ok: true, code: 0, stdout: 'alphaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.b32.i2p\n', stderr: 'betabbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb.b32.i2p\n', @@ -58,7 +71,7 @@ describe('runInfo', () => { it('prints a JSON error when I2P keys are missing', async () => { sinon.stub(fs, 'existsSync').returns(false) - sinon.stub(processUtils, 'runCommandWithOutput').resolves({ code: 1, stdout: '', stderr: '' }) + sinon.stub(processUtils, 'runCommandWithOutput').resolves({ ok: true, code: 1, stdout: '', stderr: '' }) const code = await infoCommand.runInfo({ i2pHostname: true, json: true }) @@ -77,9 +90,9 @@ describe('runInfo', () => { sinon .stub(processUtils, 'runCommandWithOutput') .onFirstCall() - .resolves({ code: 1, stdout: '', stderr: '' }) + .resolves({ ok: true, code: 1, stdout: '', stderr: '' }) .onSecondCall() - .resolves({ code: 0, stdout: '', stderr: '' }) + .resolves({ ok: true, code: 0, stdout: '', stderr: '' }) const code = await infoCommand.runInfo({ i2pHostname: true, json: true }) @@ -101,9 +114,9 @@ describe('runInfo', () => { sinon .stub(processUtils, 'runCommandWithOutput') .onFirstCall() - .resolves({ code: 1, stdout: '', stderr: '' }) + .resolves({ ok: true, code: 1, stdout: '', stderr: '' }) .onSecondCall() - .resolves({ code: 0, stdout: '', stderr: '' }) + .resolves({ ok: true, code: 0, stdout: '', stderr: '' }) const code = await infoCommand.runInfo({ i2pHostname: true }) diff --git a/test/unit/cli/update.spec.ts b/test/unit/cli/update.spec.ts index 6c5d9308..7f216b71 100644 --- a/test/unit/cli/update.spec.ts +++ b/test/unit/cli/update.spec.ts @@ -15,6 +15,7 @@ describe('runUpdate', () => { sinon.stub(stopCommand, 'runStop').resolves(0) const runStartStub = sinon.stub(startCommand, 'runStart').resolves(0) sinon.stub(processUtils, 'runCommandWithOutput').resolves({ + ok: true, code: 0, stdout: 'Saved working directory and index state WIP on main: abc123', stderr: '', @@ -38,6 +39,7 @@ describe('runUpdate', () => { sinon.stub(stopCommand, 'runStop').resolves(0) const runStartStub = sinon.stub(startCommand, 'runStart').resolves(0) sinon.stub(processUtils, 'runCommandWithOutput').resolves({ + ok: true, code: 0, stdout: 'Saved working directory and index state WIP on main: abc123', stderr: '', diff --git a/test/unit/controllers/callbacks/lnbits-callback-controller.spec.ts b/test/unit/controllers/callbacks/lnbits-callback-controller.spec.ts index 05cd99e0..9ac78fef 100644 --- a/test/unit/controllers/callbacks/lnbits-callback-controller.spec.ts +++ b/test/unit/controllers/callbacks/lnbits-callback-controller.spec.ts @@ -108,35 +108,6 @@ describe('LNbitsCallbackController', () => { }) describe('authorization and validation', () => { - it('returns 403 when payment processor settings are missing', async () => { - createSettingsStub.returns({ - network: { remoteIpHeader: 'x-forwarded-for' }, - }) - const { controller, paymentsService } = makeController() - const res = makeRes() - - await controller.handleRequest(makeReq(), res) - - expect(res.status).to.have.been.calledWith(403) - expect(res.send).to.have.been.calledWith('Forbidden') - expect(paymentsService.getInvoiceFromPaymentsProcessor).to.not.have.been.called - }) - - it('returns 403 when lnbits is not the configured processor', async () => { - createSettingsStub.returns({ - ...baseSettings, - payments: { processor: 'opennode' }, - }) - const { controller, paymentsService } = makeController() - const res = makeRes() - - await controller.handleRequest(makeReq(), res) - - expect(res.status).to.have.been.calledWith(403) - expect(res.send).to.have.been.calledWith('Forbidden') - expect(paymentsService.getInvoiceFromPaymentsProcessor).to.not.have.been.called - }) - it('returns 403 for invalid query parameters', async () => { const { controller } = makeController() const res = makeRes() diff --git a/test/unit/controllers/callbacks/opennode-callback-controller.spec.ts b/test/unit/controllers/callbacks/opennode-callback-controller.spec.ts index 744669f2..cc781a4a 100644 --- a/test/unit/controllers/callbacks/opennode-callback-controller.spec.ts +++ b/test/unit/controllers/callbacks/opennode-callback-controller.spec.ts @@ -99,20 +99,6 @@ describe('OpenNodeCallbackController', () => { }) describe('authorization and validation', () => { - it('returns 403 when opennode is not the configured processor', async () => { - createSettingsStub.returns({ - payments: { processor: 'lnbits' }, - }) - const { controller, paymentsService } = makeController() - const res = makeRes() - - await controller.handleRequest(makeReq(), res) - - expect(res.status).to.have.been.calledWith(403) - expect(res.send).to.have.been.calledWith('Forbidden') - expect(paymentsService.updateInvoiceStatus).to.not.have.been.called - }) - it('returns 400 for malformed request body', async () => { const { controller, paymentsService } = makeController() const res = makeRes() diff --git a/test/unit/controllers/callbacks/zebedee-callback-controller.spec.ts b/test/unit/controllers/callbacks/zebedee-callback-controller.spec.ts index afb05a7b..acfbec9f 100644 --- a/test/unit/controllers/callbacks/zebedee-callback-controller.spec.ts +++ b/test/unit/controllers/callbacks/zebedee-callback-controller.spec.ts @@ -136,21 +136,6 @@ describe('ZebedeeCallbackController', () => { expect(res.send).to.have.been.calledWith('Forbidden') expect(paymentsService.updateInvoiceStatus).to.not.have.been.called }) - - it('returns 403 when zebedee is not the configured processor', async () => { - createSettingsStub.returns({ - ...baseSettings, - payments: { processor: 'lnbits' }, - }) - const { controller, paymentsService } = makeController() - const res = makeRes() - - await controller.handleRequest(makeReq(), res) - - expect(res.status).to.have.been.calledWith(403) - expect(res.send).to.have.been.calledWith('Forbidden') - expect(paymentsService.updateInvoiceStatus).to.not.have.been.called - }) }) describe('invoice state handling', () => { diff --git a/test/unit/controllers/invoices/get-invoice-controller.spec.ts b/test/unit/controllers/invoices/get-invoice-controller.spec.ts index 778e9325..9b76c06e 100644 --- a/test/unit/controllers/invoices/get-invoice-controller.spec.ts +++ b/test/unit/controllers/invoices/get-invoice-controller.spec.ts @@ -21,7 +21,7 @@ const disabledPaymentsSettings = { } const enabledPaymentsSettings = { - info: { name: 'Test Relay' }, + info: { name: 'Test Relay', relay_url: 'wss://relay.example.com' }, payments: { enabled: true, feeSchedules: { @@ -29,6 +29,7 @@ const enabledPaymentsSettings = { }, processor: 'lnbits', }, + network: {}, } describe('GetInvoiceController', () => { @@ -97,7 +98,7 @@ describe('GetInvoiceController', () => { describe('when payments and admission fee are enabled', () => { beforeEach(() => { createSettingsStub.returns(enabledPaymentsSettings) - getTemplateStub.returns('{{name}}|{{processor_json}}|{{amount}}|{{nonce}}') + getTemplateStub.returns('{{name}}|{{path_prefix}}|{{processor_json}}|{{amount}}|{{nonce}}') }) it('loads the get-invoice template', async () => { @@ -118,6 +119,7 @@ describe('GetInvoiceController', () => { const sent = res.send.firstCall.args[0] as string expect(sent).to.not.include('{{name}}') + expect(sent).to.not.include('{{path_prefix}}') expect(sent).to.not.include('{{processor_json}}') expect(sent).to.not.include('{{amount}}') expect(sent).to.not.include('{{nonce}}') @@ -164,5 +166,17 @@ describe('GetInvoiceController', () => { expect(res.send.firstCall.args[0]).to.equal('invoice-nonce') }) + + it('injects relay_url path prefix into form actions', async () => { + getTemplateStub.returns('{{path_prefix}}/invoices') + createSettingsStub.returns({ + ...enabledPaymentsSettings, + info: { ...enabledPaymentsSettings.info, relay_url: 'wss://relay.example.com/nostream' }, + }) + + await controller.handleRequest({ headers: {} } as any, res) + + expect(res.send.firstCall.args[0]).to.equal('/nostream/invoices') + }) }) }) diff --git a/test/unit/controllers/invoices/post-invoice-controller.spec.ts b/test/unit/controllers/invoices/post-invoice-controller.spec.ts index 1475087e..9607c0be 100644 --- a/test/unit/controllers/invoices/post-invoice-controller.spec.ts +++ b/test/unit/controllers/invoices/post-invoice-controller.spec.ts @@ -307,9 +307,9 @@ describe('PostInvoiceController', () => { it('leaves no unreplaced template variables in the output', async () => { getTemplateStub.returns( - '{{name}}{{relay_url_html}}{{invoice_html}}{{pubkey_html}}{{amount}}' + + '{{name}}{{relay_url_html}}{{invoice_html}}{{pubkey_html}}{{path_prefix}}{{amount}}' + '{{reference_json}}{{relay_url_json}}{{relay_pubkey_json}}' + - '{{invoice_json}}{{pubkey_json}}{{expires_at_json}}{{processor_json}}{{nonce}}', + '{{invoice_json}}{{pubkey_json}}{{expires_at_json}}{{path_prefix_json}}{{processor_json}}{{nonce}}', ) const controller = makeController() const res = makeRes() @@ -319,5 +319,26 @@ describe('PostInvoiceController', () => { const sent = res.send.firstCall.args[0] as string expect(sent).to.not.match(/\{\{[^}]+\}\}/) }) + + it('injects relay_url path prefix into form actions and status polling', async () => { + getTemplateStub.returns('{{path_prefix}}/invoices|{{path_prefix_json}}') + const settings = () => ({ + ...baseSettings, + info: { ...baseSettings.info, relay_url: 'wss://relay.example.com/nostream' }, + }) + const controller = makeController({ settings }) + const res = makeRes() + + await controller.handleRequest( + { + body: validBody, + headers: {}, + } as any, + res, + ) + + const sent = res.send.firstCall.args[0] as string + expect(sent).to.equal('/nostream/invoices|"/nostream"') + }) }) }) diff --git a/test/unit/factories/event-strategy-factory.spec.ts b/test/unit/factories/event-strategy-factory.spec.ts index 444c5e7b..e94e75dc 100644 --- a/test/unit/factories/event-strategy-factory.spec.ts +++ b/test/unit/factories/event-strategy-factory.spec.ts @@ -9,6 +9,7 @@ import { EventKinds } from '../../../src/constants/base' import { eventStrategyFactory } from '../../../src/factories/event-strategy-factory' import { Factory } from '../../../src/@types/base' import { GiftWrapEventStrategy } from '../../../src/handlers/event-strategies/gift-wrap-event-strategy' +import { GroupEventStrategy } from '../../../src/handlers/event-strategies/group-event-strategy' import { IEventStrategy } from '../../../src/@types/message-handlers' import { IWebSocketAdapter } from '../../../src/@types/adapters' import { ParameterizedReplaceableEventStrategy } from '../../../src/handlers/event-strategies/parameterized-replaceable-event-strategy' @@ -47,6 +48,11 @@ describe('eventStrategyFactory', () => { expect(factory([event, adapter])).to.be.an.instanceOf(ReplaceableEventStrategy) }) + it('returns ReplaceableEventStrategy given a relay_list event (NIP-65)', () => { + event.kind = EventKinds.RELAY_LIST + expect(factory([event, adapter])).to.be.an.instanceOf(ReplaceableEventStrategy) + }) + it('returns EphemeralEventStrategy given an ephemeral event', () => { event.kind = EventKinds.EPHEMERAL_FIRST expect(factory([event, adapter])).to.be.an.instanceOf(EphemeralEventStrategy) @@ -67,6 +73,26 @@ describe('eventStrategyFactory', () => { expect(factory([event, adapter])).to.be.an.instanceOf(GiftWrapEventStrategy) }) + it('returns GroupEventStrategy given a Marmot group event (kind 445)', () => { + event.kind = EventKinds.MARMOT_GROUP_EVENT + expect(factory([event, adapter])).to.be.an.instanceOf(GroupEventStrategy) + }) + + it('returns ParameterizedReplaceableEventStrategy given a Marmot KeyPackage event (kind 30443)', () => { + event.kind = EventKinds.MARMOT_KEY_PACKAGE + expect(factory([event, adapter])).to.be.an.instanceOf(ParameterizedReplaceableEventStrategy) + }) + + it('returns ReplaceableEventStrategy given a Marmot KeyPackage relay list (kind 10051)', () => { + event.kind = EventKinds.MARMOT_KEY_PACKAGE_RELAY_LIST + expect(factory([event, adapter])).to.be.an.instanceOf(ReplaceableEventStrategy) + }) + + it('returns DefaultEventStrategy given a legacy Marmot KeyPackage (kind 443)', () => { + event.kind = EventKinds.MARMOT_KEY_PACKAGE_LEGACY + expect(factory([event, adapter])).to.be.an.instanceOf(DefaultEventStrategy) + }) + it('returns TimestampEventStrategy given an opentimestamps (NIP-03) event', () => { event.kind = EventKinds.OPEN_TIMESTAMPS expect(factory([event, adapter])).to.be.an.instanceOf(TimestampEventStrategy) @@ -81,4 +107,14 @@ describe('eventStrategyFactory', () => { event.kind = EventKinds.TEXT_NOTE expect(factory([event, adapter])).to.be.an.instanceOf(DefaultEventStrategy) }) + + it('returns DefaultEventStrategy given a reaction event (NIP-25)', () => { + event.kind = EventKinds.REACTION + expect(factory([event, adapter])).to.be.an.instanceOf(DefaultEventStrategy) + }) + + it('returns DefaultEventStrategy given an external content reaction event (NIP-25)', () => { + event.kind = EventKinds.EXTERNAL_CONTENT_REACTION + expect(factory([event, adapter])).to.be.an.instanceOf(DefaultEventStrategy) + }) }) diff --git a/test/unit/factories/message-handler-factory.spec.ts b/test/unit/factories/message-handler-factory.spec.ts index 41124f4b..1878b703 100644 --- a/test/unit/factories/message-handler-factory.spec.ts +++ b/test/unit/factories/message-handler-factory.spec.ts @@ -2,6 +2,7 @@ import { expect } from 'chai' import { IEventRepository, INip05VerificationRepository, IUserRepository } from '../../../src/@types/repositories' import { IncomingMessage, MessageType } from '../../../src/@types/messages' +import { AuthMessageHandler } from '../../../src/handlers/auth-message-handler' import { Event } from '../../../src/@types/event' import { EventMessageHandler } from '../../../src/handlers/event-message-handler' import { IWebSocketAdapter } from '../../../src/@types/adapters' @@ -74,9 +75,16 @@ describe('messageHandlerFactory', () => { expect(factory([message, adapter])).to.be.an.instanceOf(CountMessageHandler) }) + it('returns AuthMessageHandler when given an AUTH message', () => { + message = [MessageType.AUTH, event] as any + + expect(factory([message, adapter])).to.be.an.instanceOf(AuthMessageHandler) + }) + it('throws when given an invalid message', () => { message = [] as any expect(() => factory([message, adapter])).to.throw(Error, 'Unknown message type: undefined') }) }) + diff --git a/test/unit/handlers/event-message-handler.spec.ts b/test/unit/handlers/event-message-handler.spec.ts index 531eee57..57ee66a9 100644 --- a/test/unit/handlers/event-message-handler.spec.ts +++ b/test/unit/handlers/event-message-handler.spec.ts @@ -721,6 +721,19 @@ describe('EventMessageHandler', () => { const reason = await (handler as any).isEventValid(giftWrap) expect(reason).to.be.undefined }) + + it('blocks kind 444 (Marmot Welcome rumor) with a clear rejection message', async () => { + const welcomeRumor = await makeValidEvent(EventKinds.MARMOT_WELCOME_RUMOR) + const reason = await (handler as any).isEventValid(welcomeRumor) + expect(reason).to.include('blocked') + expect(reason).to.include('444') + }) + + it('does not block a kind 445 (Marmot Group Event)', async () => { + const groupEvent = await makeValidEvent(EventKinds.MARMOT_GROUP_EVENT) + const reason = await (handler as any).isEventValid(groupEvent) + expect(reason).to.be.undefined + }) }) }) diff --git a/test/unit/handlers/request-handlers/get-privacy-request-handler.spec.ts b/test/unit/handlers/request-handlers/get-privacy-request-handler.spec.ts index 840e0f55..9718ea45 100644 --- a/test/unit/handlers/request-handlers/get-privacy-request-handler.spec.ts +++ b/test/unit/handlers/request-handlers/get-privacy-request-handler.spec.ts @@ -71,6 +71,15 @@ describe('getPrivacyRequestHandler', () => { expect(res.send.firstCall.args[0]).to.equal('privacy-nonce') }) + it('injects relay_url path prefix into links', () => { + createSettingsStub.returns({ info: { name: 'Test Relay', relay_url: 'wss://relay.example.com/nostream' } }) + getTemplateStub.returns('{{path_prefix}}/terms') + + getPrivacyRequestHandler({ headers: {} } as any, res, next) + + expect(res.send.firstCall.args[0]).to.equal('/nostream/terms') + }) + it('calls next with error when template read fails', () => { const err = new Error('template missing') getTemplateStub.throws(err) diff --git a/test/unit/routes/callbacks.spec.ts b/test/unit/routes/callbacks.spec.ts index 4d5e867c..d5195284 100644 --- a/test/unit/routes/callbacks.spec.ts +++ b/test/unit/routes/callbacks.spec.ts @@ -4,15 +4,21 @@ import express from 'express' import Sinon from 'sinon' import * as openNodeControllerFactory from '../../../src/factories/controllers/opennode-callback-controller-factory' +import * as settingsFactory from '../../../src/factories/settings-factory' describe('callbacks router', () => { let createOpenNodeCallbackControllerStub: Sinon.SinonStub + let createSettingsStub: Sinon.SinonStub let receivedBody: unknown let server: any beforeEach(async () => { receivedBody = undefined + createSettingsStub = Sinon.stub(settingsFactory, 'createSettings').returns({ + payments: { enabled: true, processor: 'opennode' }, + } as any) + createOpenNodeCallbackControllerStub = Sinon.stub(openNodeControllerFactory, 'createOpenNodeCallbackController').returns({ handleRequest: async (request: any, response: any) => { receivedBody = request.body @@ -35,6 +41,7 @@ describe('callbacks router', () => { afterEach(async () => { createOpenNodeCallbackControllerStub.restore() + createSettingsStub.restore() delete require.cache[require.resolve('../../../src/routes/callbacks')] if (server) { diff --git a/test/unit/schemas/event-schema.spec.ts b/test/unit/schemas/event-schema.spec.ts index 1587b154..d74722df 100644 --- a/test/unit/schemas/event-schema.spec.ts +++ b/test/unit/schemas/event-schema.spec.ts @@ -3,7 +3,7 @@ import { expect } from 'chai' import { Event } from '../../../src/@types/event' import { eventSchema } from '../../../src/schemas/event-schema' -import { EventTags } from '../../../src/constants/base' +import { EventKinds, EventTags } from '../../../src/constants/base' import { validateSchema } from '../../../src/utils/validation' describe('NIP-01', () => { @@ -109,6 +109,144 @@ describe('NIP-01', () => { }) }) +describe('NIP-25', () => { + const base: Event = { + id: 'fa4dd948576fe182f5d0e3120b9df42c83dffa1c884754d5e4d3b0a2f98a01c5', + pubkey: 'edfa27d49d2af37ee331e1225bb6ed1912c6d999281b36d8018ad99bc3573c29', + created_at: 1660306803, + kind: EventKinds.REACTION, + tags: [], + content: '+', + sig: '313a9b8cd68267a51da84e292c0937d1f3686c6757c4584f50fcedad2b13fad755e6226924f79880fb5aa9de95c04231a4823981513ac9e7092bad7488282a96', + } + + it('accepts reaction with e tag', () => { + const event = { ...base, tags: [[EventTags.Event, 'a'.repeat(64)]] } + expect(validateSchema(eventSchema)(event).error).to.be.undefined + }) + + it('rejects reaction missing e tag', () => { + expect(validateSchema(eventSchema)({ ...base, tags: [] }).error).to.not.be.undefined + }) + + it('accepts external content reaction with k and i tags', () => { + const event = { + ...base, + kind: EventKinds.EXTERNAL_CONTENT_REACTION, + tags: [[EventTags.Kind, 'web'], [EventTags.Index, 'https://example.com']], + } + expect(validateSchema(eventSchema)(event).error).to.be.undefined + }) + + it('rejects external content reaction missing k tag', () => { + const event = { + ...base, + kind: EventKinds.EXTERNAL_CONTENT_REACTION, + tags: [[EventTags.Index, 'https://example.com']], + } + expect(validateSchema(eventSchema)(event).error).to.not.be.undefined + }) + + it('rejects external content reaction missing i tag', () => { + const event = { + ...base, + kind: EventKinds.EXTERNAL_CONTENT_REACTION, + tags: [[EventTags.Kind, 'web']], + } + expect(validateSchema(eventSchema)(event).error).to.not.be.undefined + }) +}) + +describe('NIP-65', () => { + const relayListBase: Event = { + id: 'fa4dd948576fe182f5d0e3120b9df42c83dffa1c884754d5e4d3b0a2f98a01c5', + pubkey: 'edfa27d49d2af37ee331e1225bb6ed1912c6d999281b36d8018ad99bc3573c29', + created_at: 1660306803, + kind: EventKinds.RELAY_LIST, + tags: [], + content: '', + sig: '313a9b8cd68267a51da84e292c0937d1f3686c6757c4584f50fcedad2b13fad755e6226924f79880fb5aa9de95c04231a4823981513ac9e7092bad7488282a96', + } + + it('accepts relay_list event with valid wss relay URL', () => { + const event = { ...relayListBase, tags: [[EventTags.Relay, 'wss://relay.example.com']] } + const result = validateSchema(eventSchema)(event) + expect(result.error).to.be.undefined + }) + + it('accepts relay_list event with valid wss relay URL and read marker', () => { + const event = { ...relayListBase, tags: [[EventTags.Relay, 'wss://relay.example.com', 'read']] } + const result = validateSchema(eventSchema)(event) + expect(result.error).to.be.undefined + }) + + it('accepts relay_list event with valid wss relay URL and write marker', () => { + const event = { ...relayListBase, tags: [[EventTags.Relay, 'wss://relay.example.com', 'write']] } + const result = validateSchema(eventSchema)(event) + expect(result.error).to.be.undefined + }) + + it('accepts relay_list event with no relay tags', () => { + const event = { ...relayListBase, tags: [] } + const result = validateSchema(eventSchema)(event) + expect(result.error).to.be.undefined + }) + + it('rejects relay_list event with invalid relay URL', () => { + const event = { ...relayListBase, tags: [[EventTags.Relay, 'not-a-url']] } + const result = validateSchema(eventSchema)(event) + expect(result.error).to.not.be.undefined + }) + + it('rejects relay_list event with empty relay URL', () => { + const event = { ...relayListBase, tags: [[EventTags.Relay, '']] } + const result = validateSchema(eventSchema)(event) + expect(result.error).to.not.be.undefined + }) + + it('does not validate relay URL on non-relay_list events with r tags', () => { + const event = { ...relayListBase, kind: EventKinds.TEXT_NOTE, tags: [[EventTags.Relay, 'not-a-url']] } + const result = validateSchema(eventSchema)(event) + expect(result.error).to.be.undefined + }) +}) + +describe('NIP-12', () => { + const geohashBase: Event = { + id: 'fa4dd948576fe182f5d0e3120b9df42c83dffa1c884754d5e4d3b0a2f98a01c5', + pubkey: 'edfa27d49d2af37ee331e1225bb6ed1912c6d999281b36d8018ad99bc3573c29', + created_at: 1660306803, + kind: EventKinds.TEXT_NOTE, + tags: [], + content: '', + sig: '313a9b8cd68267a51da84e292c0937d1f3686c6757c4584f50fcedad2b13fad755e6226924f79880fb5aa9de95c04231a4823981513ac9e7092bad7488282a96', + } + + it('accepts event with valid base32 geohash tag', () => { + const event = { ...geohashBase, tags: [[EventTags.Geohash, 'u4pruydqqvj']] } + const result = validateSchema(eventSchema)(event) + expect(result.error).to.be.undefined + }) + + it('rejects event with non-base32 geohash characters', () => { + const event = { ...geohashBase, tags: [[EventTags.Geohash, 'u4pruyda']] } + const result = validateSchema(eventSchema)(event) + expect(result.error).to.not.be.undefined + }) + + it('rejects event with empty geohash', () => { + const event = { ...geohashBase, tags: [[EventTags.Geohash, '']] } + const result = validateSchema(eventSchema)(event) + expect(result.error).to.not.be.undefined + }) + + it('rejects event with uppercase geohash', () => { + const event = { ...geohashBase, tags: [[EventTags.Geohash, 'U4PRUYDQQVJ']] } + const result = validateSchema(eventSchema)(event) + expect(result.error).to.not.be.undefined + }) +}) + describe('NIP-14', () => { it('accepts subject tag on text note events', () => { const event: Event = { diff --git a/test/unit/schemas/message-schema.spec.ts b/test/unit/schemas/message-schema.spec.ts index 0b5e00a2..562e5b90 100644 --- a/test/unit/schemas/message-schema.spec.ts +++ b/test/unit/schemas/message-schema.spec.ts @@ -167,5 +167,42 @@ describe('NIP-01', () => { expect(result).to.have.property('error').that.is.not.undefined }) }) + + describe('AUTH', () => { + let events: Event[] + beforeEach(() => { + events = getEvents() + }) + + it('returns same message if valid', () => { + const event = events[0] + message = ['AUTH', event] as any + + const result = validateSchema(messageSchema)(message) + expect(result.error).to.be.undefined + expect(result).to.have.deep.property('value', message) + }) + + it('returns error if event is missing', () => { + message = ['AUTH'] as any + + const result = validateSchema(messageSchema)(message) + expect(result).to.have.property('error').that.is.not.undefined + }) + + it('returns error if event is not an object', () => { + message = ['AUTH', 'not-an-event'] as any + + const result = validateSchema(messageSchema)(message) + expect(result).to.have.property('error').that.is.not.undefined + }) + + it('returns error if event is null', () => { + message = ['AUTH', null] as any + + const result = validateSchema(messageSchema)(message) + expect(result).to.have.property('error').that.is.not.undefined + }) + }) }) }) diff --git a/test/unit/utils/http.spec.ts b/test/unit/utils/http.spec.ts index eddfa29a..ada27184 100644 --- a/test/unit/utils/http.spec.ts +++ b/test/unit/utils/http.spec.ts @@ -1,7 +1,7 @@ import { expect } from 'chai' import { IncomingMessage } from 'http' -import { getRemoteAddress } from '../../../src/utils/http' +import { getPublicPathPrefix, getRemoteAddress, joinPathPrefix } from '../../../src/utils/http' describe('getRemoteAddress', () => { const header = 'x-forwarded-for' @@ -21,16 +21,7 @@ describe('getRemoteAddress', () => { } as any }) - it('returns address using network.remote_ip_address when set', () => { - expect( - getRemoteAddress( - request, - { network: { 'remote_ip_header': header, trustedProxies: [socketAddress] } } as any, - ) - ).to.equal(address) - }) - - it('returns address using network.remoteIpAddress when set', () => { + it('returns address using network.remoteIpHeader when set', () => { expect( getRemoteAddress( request, @@ -86,3 +77,95 @@ describe('getRemoteAddress', () => { ).to.equal(address) }) }) + +describe('getPublicPathPrefix', () => { + it('returns the relay_url path prefix by default', () => { + expect( + getPublicPathPrefix({ headers: {}, socket: { remoteAddress: 'client' } } as any, { + info: { relay_url: 'wss://relay.example.com/nostream/' }, + network: {}, + } as any), + ).to.equal('/nostream') + }) + + it('uses trusted x-forwarded-prefix over relay_url', () => { + expect( + getPublicPathPrefix( + { + headers: { 'x-forwarded-prefix': '/relay, /other' }, + socket: { remoteAddress: '127.0.0.1' }, + } as any, + { + info: { relay_url: 'wss://relay.example.com/nostream' }, + network: { trustedProxies: ['127.0.0.1'] }, + } as any, + ), + ).to.equal('/relay') + }) + + it('ignores untrusted x-forwarded-prefix', () => { + expect( + getPublicPathPrefix( + { + headers: { 'x-forwarded-prefix': '/evil' }, + socket: { remoteAddress: 'client' }, + } as any, + { + info: { relay_url: 'wss://relay.example.com/nostream' }, + network: { trustedProxies: ['127.0.0.1'] }, + } as any, + ), + ).to.equal('/nostream') + }) + + it('ignores x-forwarded-prefix when trustedProxies is unset', () => { + expect( + getPublicPathPrefix( + { + headers: { 'x-forwarded-prefix': '/nostream' }, + socket: { remoteAddress: '127.0.0.1' }, + } as any, + { + info: { relay_url: 'wss://relay.example.com' }, + network: {}, + } as any, + ), + ).to.equal('') + }) + + it('rejects absolute or protocol-relative trusted prefixes', () => { + const settings = { + info: { relay_url: 'wss://relay.example.com/nostream' }, + network: { trustedProxies: ['127.0.0.1'] }, + } as any + + expect( + getPublicPathPrefix( + { + headers: { 'x-forwarded-prefix': 'https://example.com/other' }, + socket: { remoteAddress: '127.0.0.1' }, + } as any, + settings, + ), + ).to.equal('/nostream') + expect( + getPublicPathPrefix( + { + headers: { 'x-forwarded-prefix': '//example.com/other' }, + socket: { remoteAddress: '127.0.0.1' }, + } as any, + settings, + ), + ).to.equal('/nostream') + }) +}) + +describe('joinPathPrefix', () => { + it('joins an empty prefix with an absolute path', () => { + expect(joinPathPrefix('', '/invoices')).to.equal('/invoices') + }) + + it('joins a forwarded prefix with an absolute path', () => { + expect(joinPathPrefix('/nostream', '/invoices')).to.equal('/nostream/invoices') + }) +}) diff --git a/test/unit/utils/nip44.spec.ts b/test/unit/utils/nip44.spec.ts index 29ec4a50..a7d91a13 100644 --- a/test/unit/utils/nip44.spec.ts +++ b/test/unit/utils/nip44.spec.ts @@ -18,55 +18,56 @@ function pubkeyFromPrivkey(secHex: string): string { const SEC1 = '0000000000000000000000000000000000000000000000000000000000000001' const SEC2 = '0000000000000000000000000000000000000000000000000000000000000002' +const SEC3 = '0000000000000000000000000000000000000000000000000000000000000003' const KNOWN_CONVERSATION_KEY = 'c41c775356fd92eadc63ff5a0dc1da211b268cbea22316767095b2871ea1412d' const KNOWN_NONCE = '0000000000000000000000000000000000000000000000000000000000000001' const KNOWN_PLAINTEXT = 'a' const KNOWN_PAYLOAD = 'AgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABee0G5VSK0/9YypIObAtDKfYEAjD35uVkHyB0F4DwrcNaCXlCWZKaArsGrY6M9wnuTMxWfp1RTN9Xga8no+kF5Vsb' +let PUB1: string +let PUB2: string +let PUB3: string +let CONVERSATION_KEY: Buffer +let RECIPIENT_CONVERSATION_KEY: Buffer +let DIFFERENT_CONVERSATION_KEY: Buffer + // --------------------------------------------------------------------------- describe('NIP-44', () => { + before(() => { + PUB1 = pubkeyFromPrivkey(SEC1) + PUB2 = pubkeyFromPrivkey(SEC2) + PUB3 = pubkeyFromPrivkey(SEC3) + CONVERSATION_KEY = getConversationKey(SEC1, PUB2) + RECIPIENT_CONVERSATION_KEY = getConversationKey(SEC2, PUB1) + DIFFERENT_CONVERSATION_KEY = getConversationKey(SEC1, PUB3) + }) + describe('getConversationKey', () => { it('derives the correct conversation key from sec1 and pub2', () => { - const pub2 = pubkeyFromPrivkey(SEC2) - const key = getConversationKey(SEC1, pub2) - expect(key.toString('hex')).to.equal(KNOWN_CONVERSATION_KEY) + expect(CONVERSATION_KEY.toString('hex')).to.equal(KNOWN_CONVERSATION_KEY) }) it('is symmetric: conv(a, B) == conv(b, A)', () => { - const pub1 = pubkeyFromPrivkey(SEC1) - const pub2 = pubkeyFromPrivkey(SEC2) - const keyAB = getConversationKey(SEC1, pub2) - const keyBA = getConversationKey(SEC2, pub1) - expect(keyAB.toString('hex')).to.equal(keyBA.toString('hex')) + expect(CONVERSATION_KEY.toString('hex')).to.equal(RECIPIENT_CONVERSATION_KEY.toString('hex')) }) it('produces different keys for different key pairs', () => { - const sec3 = '0000000000000000000000000000000000000000000000000000000000000003' - const pub2 = pubkeyFromPrivkey(SEC2) - const pub3 = pubkeyFromPrivkey(sec3) - const key12 = getConversationKey(SEC1, pub2) - const key13 = getConversationKey(SEC1, pub3) - expect(key12.toString('hex')).to.not.equal(key13.toString('hex')) + expect(CONVERSATION_KEY.toString('hex')).to.not.equal(DIFFERENT_CONVERSATION_KEY.toString('hex')) }) }) describe('nip44Encrypt', () => { it('produces the canonical payload from the NIP-44 spec test vector', () => { - const pub2 = pubkeyFromPrivkey(SEC2) - const conversationKey = getConversationKey(SEC1, pub2) const nonce = Buffer.from(KNOWN_NONCE, 'hex') - const payload = nip44Encrypt(KNOWN_PLAINTEXT, conversationKey, nonce) + const payload = nip44Encrypt(KNOWN_PLAINTEXT, CONVERSATION_KEY, nonce) expect(payload).to.equal(KNOWN_PAYLOAD) }) it('produces a valid base64 string starting with version byte 0x02', () => { - const pub2 = pubkeyFromPrivkey(SEC2) - const conversationKey = getConversationKey(SEC1, pub2) - - const payload = nip44Encrypt('hello', conversationKey) + const payload = nip44Encrypt('hello', CONVERSATION_KEY) const decoded = Buffer.from(payload, 'base64') expect(decoded[0]).to.equal(2) // version byte @@ -74,99 +75,64 @@ describe('NIP-44', () => { }) it('produces different ciphertexts for the same plaintext (random nonce)', () => { - const pub2 = pubkeyFromPrivkey(SEC2) - const conversationKey = getConversationKey(SEC1, pub2) - - const payload1 = nip44Encrypt('same message', conversationKey) - const payload2 = nip44Encrypt('same message', conversationKey) + const payload1 = nip44Encrypt('same message', CONVERSATION_KEY) + const payload2 = nip44Encrypt('same message', CONVERSATION_KEY) expect(payload1).to.not.equal(payload2) }) it('throws for empty plaintext', () => { - const pub2 = pubkeyFromPrivkey(SEC2) - const conversationKey = getConversationKey(SEC1, pub2) - - expect(() => nip44Encrypt('', conversationKey)).to.throw('invalid plaintext length') + expect(() => nip44Encrypt('', CONVERSATION_KEY)).to.throw('invalid plaintext length') }) it('throws for plaintext exceeding 65535 bytes', () => { - const pub2 = pubkeyFromPrivkey(SEC2) - const conversationKey = getConversationKey(SEC1, pub2) - - expect(() => nip44Encrypt('x'.repeat(65536), conversationKey)).to.throw('invalid plaintext length') + expect(() => nip44Encrypt('x'.repeat(65536), CONVERSATION_KEY)).to.throw('invalid plaintext length') }) }) describe('nip44Decrypt', () => { it('decrypts the canonical NIP-44 spec test vector', () => { - const pub2 = pubkeyFromPrivkey(SEC2) - const conversationKey = getConversationKey(SEC1, pub2) - - const plaintext = nip44Decrypt(KNOWN_PAYLOAD, conversationKey) + const plaintext = nip44Decrypt(KNOWN_PAYLOAD, CONVERSATION_KEY) expect(plaintext).to.equal(KNOWN_PLAINTEXT) }) it('round-trips any plaintext through encrypt then decrypt', () => { - const pub2 = pubkeyFromPrivkey(SEC2) - const conversationKey = getConversationKey(SEC1, pub2) const original = 'Hola, que tal? 🌍' - const payload = nip44Encrypt(original, conversationKey) - const recovered = nip44Decrypt(payload, conversationKey) + const payload = nip44Encrypt(original, CONVERSATION_KEY) + const recovered = nip44Decrypt(payload, CONVERSATION_KEY) expect(recovered).to.equal(original) }) it('works with the symmetric key (recipient decrypts sender message)', () => { - const pub1 = pubkeyFromPrivkey(SEC1) - const pub2 = pubkeyFromPrivkey(SEC2) - - const senderKey = getConversationKey(SEC1, pub2) - const recipientKey = getConversationKey(SEC2, pub1) - - const payload = nip44Encrypt('secret message', senderKey) - const plaintext = nip44Decrypt(payload, recipientKey) + const payload = nip44Encrypt('secret message', CONVERSATION_KEY) + const plaintext = nip44Decrypt(payload, RECIPIENT_CONVERSATION_KEY) expect(plaintext).to.equal('secret message') }) it('throws when MAC is tampered', () => { - const pub2 = pubkeyFromPrivkey(SEC2) - const conversationKey = getConversationKey(SEC1, pub2) - const payload = nip44Encrypt('tamper me', conversationKey) + const payload = nip44Encrypt('tamper me', CONVERSATION_KEY) // Flip the last character of the base64 payload to corrupt the MAC const tampered = payload.slice(0, -4) + 'AAAA' - expect(() => nip44Decrypt(tampered, conversationKey)).to.throw() + expect(() => nip44Decrypt(tampered, CONVERSATION_KEY)).to.throw() }) it('throws for payload starting with # (unsupported future version)', () => { - const pub2 = pubkeyFromPrivkey(SEC2) - const conversationKey = getConversationKey(SEC1, pub2) - - expect(() => nip44Decrypt('#not-base64', conversationKey)).to.throw('unknown version') + expect(() => nip44Decrypt('#not-base64', CONVERSATION_KEY)).to.throw('unknown version') }) it('throws for payload that is too short', () => { - const pub2 = pubkeyFromPrivkey(SEC2) - const conversationKey = getConversationKey(SEC1, pub2) - - expect(() => nip44Decrypt('dG9vc2hvcnQ=', conversationKey)).to.throw('invalid payload size') + expect(() => nip44Decrypt('dG9vc2hvcnQ=', CONVERSATION_KEY)).to.throw('invalid payload size') }) it('throws for wrong conversation key', () => { - const sec3 = '0000000000000000000000000000000000000000000000000000000000000003' - const pub2 = pubkeyFromPrivkey(SEC2) - const pub3 = pubkeyFromPrivkey(sec3) - - const senderKey = getConversationKey(SEC1, pub2) - const wrongKey = getConversationKey(SEC1, pub3) - - const payload = nip44Encrypt('private', senderKey) + const payload = nip44Encrypt('private', CONVERSATION_KEY) - expect(() => nip44Decrypt(payload, wrongKey)).to.throw() + expect(() => nip44Decrypt(payload, DIFFERENT_CONVERSATION_KEY)).to.throw() }) }) @@ -176,9 +142,7 @@ describe('NIP-44', () => { }) it('returns undefined for a freshly encrypted payload', () => { - const pub2 = pubkeyFromPrivkey(SEC2) - const conversationKey = getConversationKey(SEC1, pub2) - const payload = nip44Encrypt('hello', conversationKey) + const payload = nip44Encrypt('hello', CONVERSATION_KEY) expect(validateNip44Payload(payload)).to.be.undefined }) @@ -228,11 +192,9 @@ describe('NIP-44', () => { for (const [unpaddedLen, expectedPaddedLen] of cases) { it(`pads ${unpaddedLen} bytes to ${expectedPaddedLen} bytes`, () => { - const pub2 = pubkeyFromPrivkey(SEC2) - const conversationKey = getConversationKey(SEC1, pub2) const plaintext = 'a'.repeat(unpaddedLen) - const payload = nip44Encrypt(plaintext, conversationKey) + const payload = nip44Encrypt(plaintext, CONVERSATION_KEY) const decoded = Buffer.from(payload, 'base64') // Layout: 1 (version) + 32 (nonce) + paddedLen + 2 (length prefix) + 32 (mac) diff --git a/test/unit/utils/settings.spec.ts b/test/unit/utils/settings.spec.ts index ca428bdf..dff99e6a 100644 --- a/test/unit/utils/settings.spec.ts +++ b/test/unit/utils/settings.spec.ts @@ -2,6 +2,8 @@ import { expect } from 'chai' import fs from 'fs' import { join } from 'path' import Sinon from 'sinon' +import { mergeDeepRight } from 'ramda' +import { Settings } from '../../../src/@types/settings' import { SettingsFileTypes, SettingsStatic } from '../../../src/utils/settings' @@ -258,4 +260,29 @@ describe('SettingsStatic', () => { ) }) }) + + describe('WoT settings defaults', () => { + it('default-settings.yaml contains a wot block with enabled: false', () => { + const defaults = SettingsStatic.loadAndParseYamlFile( + SettingsStatic.getDefaultSettingsFilePath() + ) + expect(defaults).to.have.nested.property('wot.enabled', false) + expect(defaults).to.have.nested.property('wot.seedPubkey', '') + expect(defaults).to.have.nested.property('wot.minimumFollowers', 1) + expect(defaults).to.have.nested.property('wot.refreshIntervalHours', 24) + }) + + it('user config wot block overrides defaults', () => { + const defaults = SettingsStatic.loadAndParseYamlFile( + SettingsStatic.getDefaultSettingsFilePath() + ) + const userConfig = { wot: { enabled: true, seedPubkey: 'abc123', minimumFollowers: 3 } } + const merged = mergeDeepRight(defaults, userConfig) as Settings + expect(merged.wot?.enabled).to.equal(true) + expect(merged.wot?.seedPubkey).to.equal('abc123') + expect(merged.wot?.minimumFollowers).to.equal(3) + // non-overridden fields stay as defaults + expect(merged.wot?.refreshIntervalHours).to.equal(24) + }) + }) }) diff --git a/test/unit/utils/sliding-window-rate-limiter.spec.ts b/test/unit/utils/sliding-window-rate-limiter.spec.ts index 87cb75a4..a864a93d 100644 --- a/test/unit/utils/sliding-window-rate-limiter.spec.ts +++ b/test/unit/utils/sliding-window-rate-limiter.spec.ts @@ -17,6 +17,7 @@ describe('SlidingWindowRateLimiter', () => { let getKeyStub: Sinon.SinonStub let hasKeyStub: Sinon.SinonStub let setKeyStub: Sinon.SinonStub + let evalStub: Sinon.SinonStub let sandbox: Sinon.SinonSandbox @@ -30,6 +31,7 @@ describe('SlidingWindowRateLimiter', () => { getKeyStub = sandbox.stub() hasKeyStub = sandbox.stub() setKeyStub = sandbox.stub() + evalStub = sandbox.stub() cache = { removeRangeByScoreFromSortedSet: removeRangeByScoreFromSortedSetStub, addToSortedSet: addToSortedSetStub, @@ -38,7 +40,10 @@ describe('SlidingWindowRateLimiter', () => { getKey: getKeyStub, hasKey: hasKeyStub, setKey: setKeyStub, + eval: evalStub, } as unknown as ICacheAdapter + + rateLimiter = new SlidingWindowRateLimiter(cache) }) @@ -48,20 +53,32 @@ describe('SlidingWindowRateLimiter', () => { }) it('returns true if rate limited', async () => { - const now = Date.now() - getRangeFromSortedSetStub.resolves([`${now}:6`, `${now}:4`, `${now}:1`]) + evalStub.resolves(1) const actualResult = await rateLimiter.hit('key', 1, { period: 60000, rate: 10 }) expect(actualResult).to.be.true + expect(evalStub).to.have.been.calledOnce + const args = evalStub.firstCall.args + expect(args[1]).to.deep.equal(['key']) + expect(args[2][1]).to.equal('60000') // period + expect(args[2][2]).to.equal('1') // step + expect(args[2][3]).to.equal('10') // max_rate }) it('returns false if not rate limited', async () => { - const now = Date.now() - getRangeFromSortedSetStub.resolves([`${now}:10`]) + evalStub.resolves(0) const actualResult = await rateLimiter.hit('key', 1, { period: 60000, rate: 10 }) expect(actualResult).to.be.false }) + + it('robustly handles string return types from Redis', async () => { + evalStub.resolves('1') + + const actualResult = await rateLimiter.hit('key', 1, { period: 60000, rate: 10 }) + + expect(actualResult).to.be.true + }) }) From 200804851de6fcc0a09c4470dfe398f30870fcfe Mon Sep 17 00:00:00 2001 From: anshumancanrock Date: Sun, 21 Jun 2026 22:23:29 +0530 Subject: [PATCH 05/12] feat(nip-50): add full-text search support wire up NIP-50 search filter through subscription handler, event repository, and filter schema. add ts_rank relevance sorting, maxQueryLength truncation, and GIN index migration. parameterize tsConfig as ?::regconfig to prevent SQL injection. trim search input before truncation so whitespace doesn't waste the query length budget. advertise search_supported in NIP-11. --- resources/default-settings.yaml | 16 + src/@types/settings.ts | 20 ++ .../request-handlers/root-request-handler.ts | 39 +- src/handlers/subscribe-message-handler.ts | 8 + src/repositories/event-repository.ts | 334 ++++++++++-------- src/schemas/filter-schema.ts | 20 +- src/utils/event.ts | 32 +- src/utils/filter.ts | 12 + 8 files changed, 322 insertions(+), 159 deletions(-) diff --git a/resources/default-settings.yaml b/resources/default-settings.yaml index 85354d64..138b1288 100755 --- a/resources/default-settings.yaml +++ b/resources/default-settings.yaml @@ -67,6 +67,17 @@ nip50: # 'simple' (no stemming) or a language name like 'english', 'spanish' language: simple maxQueryLength: 256 +wot: + # Web of Trust filtering. When enabled, only events from pubkeys within + # the relay owner's 2-hop follow graph are accepted. + enabled: false + # The relay owner's pubkey in hex. This is the root of the trust graph. + # Required when enabled is true. + seedPubkey: "" + # A pubkey must be followed by at least this many 1-hop accounts to be trusted. + minimumFollowers: 1 + # Hours between full trust graph rebuilds. + refreshIntervalHours: 24 network: maxPayloadSize: 524288 # Uncomment only when using a trusted reverse proxy and configuring trustedProxies. @@ -186,6 +197,11 @@ limits: - 39999 period: 60000 rate: 24 + - description: 60 events/min for Marmot group events (kind 445) + kinds: + - 445 + period: 60000 + rate: 60 - description: 60 events/min for ephemeral events kinds: - - 20000 diff --git a/src/@types/settings.ts b/src/@types/settings.ts index 5af4889b..db2e060e 100644 --- a/src/@types/settings.ts +++ b/src/@types/settings.ts @@ -272,6 +272,25 @@ export interface Nip05Settings { domainBlacklist?: string[] } +export interface WoTSettings { + enabled: boolean + /** + * The relay owner's pubkey (hex). The trust graph is rooted here. + * Required when enabled is true. + */ + seedPubkey: Pubkey + /** + * Minimum number of 1-hop follows a pubkey must have to enter the trust filter. + * Defaults to 1. + */ + minimumFollowers: number + /** + * How many hours between full trust graph rebuilds. + * Defaults to 24. + */ + refreshIntervalHours: number +} + export interface Settings { info: Info payments?: Payments @@ -283,4 +302,5 @@ export interface Settings { nip05?: Nip05Settings nip45?: Nip45Settings nip50?: Nip50Settings + wot?: WoTSettings } diff --git a/src/handlers/request-handlers/root-request-handler.ts b/src/handlers/request-handlers/root-request-handler.ts index 20a5a60a..f322057f 100644 --- a/src/handlers/request-handlers/root-request-handler.ts +++ b/src/handlers/request-handlers/root-request-handler.ts @@ -1,4 +1,3 @@ -import accepts from 'accepts' import { NextFunction, Request, Response } from 'express' import { path, pathEq } from 'ramda' import { createSettings } from '../../factories/settings-factory' @@ -7,19 +6,51 @@ import { FeeSchedule } from '../../@types/settings' 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 packageJson from '../../../package.json' +export const hasExplicitNostrJsonAcceptHeader = (request: Request): boolean => { + const acceptHeader = request.headers.accept + + if (!acceptHeader) { + return false + } + + const acceptHeaderValue = Array.isArray(acceptHeader) ? acceptHeader.join(',') : acceptHeader + + return acceptHeaderValue.split(',').some((token) => { + const [mediaType, ...params] = token + .split(';') + .map((value) => value.trim().toLowerCase()) + + if (mediaType !== 'application/nostr+json') { + return false + } + + const quality = params.find((param) => param.startsWith('q=')) + + if (!quality) { + return true + } + + const qValue = Number.parseFloat(quality.slice(2)) + + return !Number.isNaN(qValue) && qValue > 0 + }) +} + export const rootRequestHandler = (request: Request, response: Response, next: NextFunction) => { const settings = createSettings() + const pathPrefix = getPublicPathPrefix(request, settings) - if (accepts(request).type(['application/nostr+json'])) { + if (hasExplicitNostrJsonAcceptHeader(request)) { const { 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' + paymentsUrl.pathname = joinPathPrefix(pathPrefix, '/invoices') const content = settings.limits?.event?.content const eventLimits = settings.limits?.event @@ -49,6 +80,7 @@ export const rootRequestHandler = (request: Request, response: Response, next: N contact, supported_nips: packageJson.supportedNips, supported_nip_extensions: packageJson.supportedNipExtensions, + supported_mips: packageJson.supportedMips, software: packageJson.repository.url, version: packageJson.version, ...(terms_of_service !== undefined ? { terms_of_service } : {}), @@ -113,6 +145,7 @@ export const rootRequestHandler = (request: Request, response: Response, next: N .replaceAll('{{description}}', escapeHtml(settings.info.description ?? '')) .replaceAll('{{relay_url}}', escapeHtml(settings.info.relay_url)) .replaceAll('{{amount}}', amount) + .replaceAll('{{path_prefix}}', escapeHtml(pathPrefix)) .replaceAll('{{payments_section_class}}', admissionFeeEnabled ? '' : 'd-none') .replaceAll('{{no_payments_section_class}}', admissionFeeEnabled ? 'd-none' : '') .replaceAll('{{nonce}}', response.locals.nonce) diff --git a/src/handlers/subscribe-message-handler.ts b/src/handlers/subscribe-message-handler.ts index 5df64aca..e9fac71a 100644 --- a/src/handlers/subscribe-message-handler.ts +++ b/src/handlers/subscribe-message-handler.ts @@ -120,6 +120,14 @@ export class SubscribeMessageHandler implements IMessageHandler, IAbortable { } } + const maxLimit = subscriptionLimits?.maxLimit ?? 0 + if (maxLimit > 0) { + const hasExcessiveLimit = filters.some((filter) => filter.limit !== undefined && filter.limit > maxLimit) + if (hasExcessiveLimit) { + return `Limit too high: Filter limit must be less than or equal to ${maxLimit}` + } + } + if ( typeof subscriptionLimits?.maxSubscriptionIdLength === 'number' && subscriptionId.length > subscriptionLimits.maxSubscriptionIdLength diff --git a/src/repositories/event-repository.ts b/src/repositories/event-repository.ts index 1e1f9903..10e250a4 100644 --- a/src/repositories/event-repository.ts +++ b/src/repositories/event-repository.ts @@ -1,32 +1,18 @@ import { - __, always, applySpec, - complement, - cond, - equals, - evolve, - filter, - forEach, - forEachObjIndexed, - groupBy, ifElse, - invoker, is, - isEmpty, isNil, map, - modulo, - nth, omit, path, paths, pipe, prop, propSatisfies, - T, - toPairs, } from 'ramda' +import { Settings } from '../@types/settings' import { ContextMetadataKey, @@ -40,23 +26,18 @@ import { DBEvent, Event } from '../@types/event' import { EventPurgeCounts, EventRetentionOptions, IEventRepository, IQueryResult } from '../@types/repositories' import { toBuffer, toJSON } from '../utils/transform' import { createLogger } from '../factories/logger-factory' -import { isGenericTagQuery } from '../utils/filter' +import { isGenericTagQuery, isGeohashPrefixCriterion, stripGeohashPrefixWildcard } from '../utils/filter' import { SubscriptionFilter } from '../@types/subscription' -const even = pipe(modulo(__, 2), equals(0)) - -const groupByLengthSpec = groupBy( - pipe( - prop('length'), - cond([ - [equals(64), always('exact')], - [even, always('even')], - [T, always('odd')], - ]), - ), -) - const logger = createLogger('event-repository') +const RETENTION_BATCH_SIZE = 1000 +const SECONDS_PER_DAY = 86400 + +type HexCriterionGroups = { + exact: string[] + even: string[] + odd: string[] +} /** Default text-search configuration when nip50.language is unset. */ const DEFAULT_TS_CONFIG = 'simple' @@ -72,7 +53,7 @@ export class EventRepository implements IEventRepository { public constructor( private readonly masterDbClient: DatabaseClient, private readonly readReplicaDbClient: DatabaseClient, - private readonly settings?: () => { nip50?: { enabled?: boolean; language?: string; maxQueryLength?: number } }, + private readonly settings?: () => Settings, ) {} public findByFilters(filters: SubscriptionFilter[]): IQueryResult { @@ -88,12 +69,15 @@ export class EventRepository implements IEventRepository { if (isSearchQuery) { // NIP-50: sort by relevance (ts_rank) descending, then by event_id for stability const tsConfig = this.getNip50Language() + const nip50Settings = this.settings?.() + const maxLen = nip50Settings?.nip50?.maxQueryLength ?? DEFAULT_MAX_SEARCH_QUERY_LENGTH + const searchQuery = currentFilter.search.trim().slice(0, maxLen) const limit = typeof currentFilter.limit === 'number' ? currentFilter.limit : DEFAULT_FILTER_LIMIT builder .select( this.readReplicaDbClient.raw( - `events.*, ts_rank(to_tsvector('${tsConfig}', event_content), plainto_tsquery('${tsConfig}', ?)) AS search_rank`, - [currentFilter.search], + 'events.*, ts_rank(to_tsvector(?::regconfig, event_content), plainto_tsquery(?::regconfig, ?)) AS search_rank', + [tsConfig, tsConfig, searchQuery], ), ) .limit(limit) @@ -160,37 +144,7 @@ export class EventRepository implements IEventRepository { } private applyFilterConditions(builder: any, currentFilter: SubscriptionFilter): FilterConditionFlags { - forEachObjIndexed((tableFields: string[], filterName: string | number) => { - builder.andWhere((bd) => { - cond([ - [isEmpty, () => void bd.whereRaw('1 = 0')], - [ - complement(isNil), - pipe( - groupByLengthSpec, - evolve({ - exact: (pubkeys: string[]) => - tableFields.forEach((tableField) => bd.orWhereIn(tableField, pubkeys.map(toBuffer))), - even: forEach((prefix: string) => - tableFields.forEach((tableField) => - bd.orWhereRaw(`substring("${tableField}" from 1 for ?) = ?`, [prefix.length >> 1, toBuffer(prefix)]), - ), - ), - odd: forEach((prefix: string) => - tableFields.forEach((tableField) => - bd.orWhereRaw(`substring("${tableField}" from 1 for ?) BETWEEN ? AND ?`, [ - (prefix.length >> 1) + 1, - `\\x${prefix}0`, - `\\x${prefix}f`, - ]), - ), - ), - } as any), - ), - ], - ])(currentFilter[filterName] as string[]) - }) - })({ authors: ['event_pubkey'], ids: ['event_id'] }) + this.applyHexFilterConditions(builder, currentFilter) if (Array.isArray(currentFilter.kinds)) { builder.whereIn('event_kind', currentFilter.kinds) @@ -206,41 +160,21 @@ export class EventRepository implements IEventRepository { // NIP-50: full-text search condition let isSearchQuery = false - if (typeof currentFilter.search === 'string' && currentFilter.search.length > 0) { + if (typeof currentFilter.search === 'string' && currentFilter.search.trim().length > 0) { const nip50Settings = this.settings?.() if (nip50Settings?.nip50?.enabled) { const tsConfig = this.getNip50Language() const maxLen = nip50Settings.nip50.maxQueryLength ?? DEFAULT_MAX_SEARCH_QUERY_LENGTH - const searchQuery = currentFilter.search.slice(0, maxLen) + const searchQuery = currentFilter.search.trim().slice(0, maxLen) builder.andWhereRaw( - `to_tsvector('${tsConfig}', event_content) @@ plainto_tsquery('${tsConfig}', ?)`, - [searchQuery], + 'to_tsvector(?::regconfig, event_content) @@ plainto_tsquery(?::regconfig, ?)', + [tsConfig, tsConfig, searchQuery], ) isSearchQuery = true } } - const andWhereRaw = invoker(1, 'andWhereRaw') - const orWhereRaw = invoker(2, 'orWhereRaw') - - let isTagQuery = false - pipe( - toPairs, - filter(pipe(nth(0) as () => string, isGenericTagQuery)) as any, - forEach(([filterName, criteria]: [string, string[]]) => { - isTagQuery = true - builder.andWhere((bd) => { - ifElse( - isEmpty, - () => andWhereRaw('1 = 0', bd), - forEach( - (criterion: string) => - void orWhereRaw('event_tags.tag_name = ? AND event_tags.tag_value = ?', [filterName[1], criterion], bd), - ), - )(criteria) - }) - }), - )(currentFilter as any) + const isTagQuery = this.applyGenericTagFilterConditions(builder, currentFilter) if (isTagQuery) { builder.leftJoin('event_tags', 'events.event_id', 'event_tags.event_id') @@ -254,6 +188,99 @@ export class EventRepository implements IEventRepository { return this.settings?.()?.nip50?.language ?? DEFAULT_TS_CONFIG } + private applyHexFilterConditions(builder: any, currentFilter: SubscriptionFilter): void { + builder.andWhere((bd) => { + this.applyHexCriteria(bd, ['event_pubkey'], currentFilter.authors) + }) + + builder.andWhere((bd) => { + this.applyHexCriteria(bd, ['event_id'], currentFilter.ids) + }) + } + + private applyHexCriteria(builder: any, tableFields: string[], criteria?: string[]): void { + if (typeof criteria === 'undefined') { + return + } + + if (!criteria.length) { + builder.whereRaw('1 = 0') + return + } + + const groups = this.groupHexCriteria(criteria) + + tableFields.forEach((tableField) => { + if (groups.exact.length) { + builder.orWhereIn(tableField, groups.exact.map(toBuffer)) + } + + groups.even.forEach((prefix) => { + builder.orWhereRaw(`substring("${tableField}" from 1 for ?) = ?`, [prefix.length >> 1, toBuffer(prefix)]) + }) + + groups.odd.forEach((prefix) => { + builder.orWhereRaw(`substring("${tableField}" from 1 for ?) BETWEEN ? AND ?`, [ + (prefix.length >> 1) + 1, + `\\x${prefix}0`, + `\\x${prefix}f`, + ]) + }) + }) + } + + private groupHexCriteria(criteria: string[]): HexCriterionGroups { + return criteria.reduce( + (groups, criterion) => { + if (criterion.length === 64) { + groups.exact.push(criterion) + } else if (criterion.length % 2 === 0) { + groups.even.push(criterion) + } else { + groups.odd.push(criterion) + } + + return groups + }, + { + exact: [], + even: [], + odd: [], + }, + ) + } + + private applyGenericTagFilterConditions(builder: any, currentFilter: SubscriptionFilter): boolean { + const tagFilters = Object.entries(currentFilter).filter(([filterName]) => isGenericTagQuery(filterName)) + + tagFilters.forEach(([filterName, criteria]) => { + this.applyGenericTagCriteria(builder, filterName, criteria as string[]) + }) + + return tagFilters.length > 0 + } + + private applyGenericTagCriteria(builder: any, filterName: string, criteria: string[]): void { + builder.andWhere((bd) => { + if (!criteria.length) { + bd.andWhereRaw('1 = 0') + return + } + + criteria.forEach((criterion) => { + if (isGeohashPrefixCriterion(filterName, criterion)) { + bd.orWhereRaw('event_tags.tag_name = ? AND event_tags.tag_value LIKE ?', [ + filterName[1], + `${stripGeohashPrefixWildcard(criterion)}%`, + ]) + return + } + + bd.orWhereRaw('event_tags.tag_name = ? AND event_tags.tag_value = ?', [filterName[1], criterion]) + }) + }) + } + public async create(event: Event): Promise { return this.insert(event).then(prop('rowCount') as () => number, () => 0) } @@ -436,92 +463,97 @@ export class EventRepository implements IEventRepository { }) } - const retentionLimit = now - maxDays * 86400 - const batchSize = 1000 + const retentionLimit = now - maxDays * SECONDS_PER_DAY logger( 'deleting expired and retained events (retentionLimit: %d, now: %d, batchSize: %d)', retentionLimit, now, - batchSize, + RETENTION_BATCH_SIZE, ) - const kindWhitelist = [ - ...(Array.isArray(options?.kindWhitelist) ? options.kindWhitelist : []), - EventKinds.REQUEST_TO_VANISH, - ].reduce<(number | [number, number])[]>((result, item) => { - const key = Array.isArray(item) ? `range:${item[0]}-${item[1]}` : `kind:${item}` + const candidates = this.buildRetentionCandidateQuery(now, retentionLimit, options) - if ( - !result.some((existing) => { - const existingKey = Array.isArray(existing) ? `range:${existing[0]}-${existing[1]}` : `kind:${existing}` - return existingKey === key - }) - ) { - result.push(item) - } + const query = this.masterDbClient('events') + .whereIn('event_id', candidates) + .del(['deleted_at', 'expires_at', 'event_created_at']) - return result - }, []) + const getPromise = () => query.then((rows: any) => this.mapToPurgeCounts(rows, now, retentionLimit)) + + return { + then: ( + onfulfilled?: ((value: EventPurgeCounts) => T1 | PromiseLike) | null, + onrejected?: ((reason: any) => T2 | PromiseLike) | null, + ) => getPromise().then(onfulfilled as any, onrejected as any), + catch: (onrejected?: ((reason: any) => T | PromiseLike) | null) => getPromise().catch(onrejected as any), + finally: (onfinally?: (() => void) | null) => getPromise().finally(onfinally as any), + toString: (): string => query.toString(), + } as Promise & { toString(): string } + } - const candidates = this.masterDbClient('events') + private buildRetentionCandidateQuery( + now: number, + retentionLimit: number, + options?: EventRetentionOptions, + ): any { + return this.masterDbClient('events') .select('event_id') .where(function () { this.where('expires_at', '<', now).orWhereNotNull('deleted_at').orWhere('event_created_at', '<', retentionLimit) }) .modify((query) => { - query.whereNot((builder) => { - kindWhitelist.forEach((kindOrRange) => { - if (Array.isArray(kindOrRange)) { - builder.orWhereBetween('event_kind', kindOrRange) - } else { - builder.orWhere('event_kind', kindOrRange) - } - }) - }) + this.applyRetentionKindWhitelist(query, options?.kindWhitelist) if (Array.isArray(options?.pubkeyWhitelist) && options.pubkeyWhitelist.length > 0) { query.whereNotIn('event_pubkey', map(toBuffer)(options.pubkeyWhitelist)) } }) - .limit(batchSize) + .limit(RETENTION_BATCH_SIZE) + } - const query = this.masterDbClient('events') - .whereIn('event_id', candidates) - .del(['deleted_at', 'expires_at', 'event_created_at']) + private applyRetentionKindWhitelist(query: any, kindWhitelist?: EventRetentionOptions['kindWhitelist']): void { + const seen = new Set() + const configuredWhitelist = Array.isArray(kindWhitelist) ? kindWhitelist : [] + const dedupedWhitelist = [...configuredWhitelist, EventKinds.REQUEST_TO_VANISH].filter((item) => { + const key = Array.isArray(item) ? `range:${item[0]}-${item[1]}` : `kind:${item}` - const mapToCounts = ( - deletedRows: Pick[], - ): EventPurgeCounts => - deletedRows.reduce( - (counts, row) => { - if (row.deleted_at) { - counts.deleted += 1 - } else if (typeof row.expires_at === 'number' && row.expires_at < now) { - counts.expired += 1 - } else if (row.event_created_at < retentionLimit) { - counts.retained += 1 - } - - return counts - }, - { - deleted: 0, - expired: 0, - retained: 0, - }, - ) + if (seen.has(key)) { + return false + } - const getPromise = () => query.then((rows: any) => mapToCounts(rows)) + seen.add(key) + return true + }) + query.whereNot((builder) => { + dedupedWhitelist.forEach((kindOrRange) => { + if (Array.isArray(kindOrRange)) { + builder.orWhereBetween('event_kind', kindOrRange) + } else { + builder.orWhere('event_kind', kindOrRange) + } + }) + }) + } - return { - then: ( - onfulfilled?: ((value: EventPurgeCounts) => T1 | PromiseLike) | null, - onrejected?: ((reason: any) => T2 | PromiseLike) | null, - ) => getPromise().then(onfulfilled as any, onrejected as any), - catch: (onrejected?: ((reason: any) => T | PromiseLike) | null) => getPromise().catch(onrejected as any), - finally: (onfinally?: (() => void) | null) => getPromise().finally(onfinally as any), - toString: (): string => query.toString(), - } as Promise & { toString(): string } + private mapToPurgeCounts( + deletedRows: Pick[], + now: number, + retentionLimit: number, + ): EventPurgeCounts { + return deletedRows.reduce((counts, row) => { + if (row.deleted_at) { + counts.deleted += 1 + } else if (typeof row.expires_at === 'number' && row.expires_at < now) { + counts.expired += 1 + } else if (row.event_created_at < retentionLimit) { + counts.retained += 1 + } + + return counts + }, { + deleted: 0, + expired: 0, + retained: 0, + }) } } diff --git a/src/schemas/filter-schema.ts b/src/schemas/filter-schema.ts index cfa81194..b2fbbb3c 100644 --- a/src/schemas/filter-schema.ts +++ b/src/schemas/filter-schema.ts @@ -1,7 +1,7 @@ import { z } from 'zod' -import { createdAtSchema, kindSchema, prefixSchema } from './base-schema' -import { isGenericTagQuery } from '../utils/filter' +import { createdAtSchema, geohashFilterValueSchema, kindSchema, prefixSchema } from './base-schema' +import { isGenericTagQuery, isGeohashTagQuery } from '../utils/filter' const knownFilterKeys = new Set(['ids', 'authors', 'kinds', 'since', 'until', 'limit', 'search']) @@ -18,13 +18,27 @@ export const filterSchema = z }) .catchall(z.array(z.string().max(1024))) .superRefine((data, ctx) => { - for (const key of Object.keys(data)) { + for (const [key, value] of Object.entries(data)) { if (!knownFilterKeys.has(key) && !isGenericTagQuery(key)) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: `Unknown key: ${key}`, path: [key], }) + continue + } + + // Validate #g filter values: NIP-12 geohash with optional single trailing '*' + if (isGeohashTagQuery(key) && Array.isArray(value)) { + value.forEach((criterion, index) => { + if (typeof criterion === 'string' && !geohashFilterValueSchema.safeParse(criterion).success) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Invalid geohash filter', + path: [key, index], + }) + } + }) } } }) diff --git a/src/utils/event.ts b/src/utils/event.ts index 9f4e4b1a..78b012b0 100644 --- a/src/utils/event.ts +++ b/src/utils/event.ts @@ -8,7 +8,7 @@ import { deriveFromSecret } from './secret' import { EventKindsRange } from '../@types/settings' import { fromBuffer } from './transform' import { getLeadingZeroBits } from './proof-of-work' -import { isGenericTagQuery } from './filter' +import { isGenericTagQuery, isGeohashPrefixCriterion, stripGeohashPrefixWildcard } from './filter' import { SubscriptionFilter } from '../@types/subscription' import { WebSocketServerAdapterEvent } from '../constants/adapter' @@ -40,6 +40,18 @@ export const isEventMatchingFilter = (filter: SubscriptionFilter) => (event: Event): boolean => { const startsWith = (input: string) => (prefix: string) => input.startsWith(prefix) + const isMatchingGenericTagCriterion = (key: string, criterion: string) => (tag: Tag): boolean => { + const [, tagName] = key + if (tag[0] !== tagName) { + return false + } + + if (isGeohashPrefixCriterion(key, criterion)) { + return tag[1].startsWith(stripGeohashPrefixWildcard(criterion)) + } + + return tag[1] === criterion + } // NIP-01: Basic protocol flow description @@ -84,7 +96,7 @@ export const isEventMatchingFilter = Object.entries(filter) .filter(([key, criteria]) => isGenericTagQuery(key) && Array.isArray(criteria)) .some(([key, criteria]) => { - return !event.tags.some((tag) => tag[0] === key[1] && criteria.includes(tag[1])) + return !event.tags.some((tag) => criteria.some((criterion) => isMatchingGenericTagCriterion(key, criterion)(tag))) }) ) { return false @@ -276,3 +288,19 @@ export const isFileMessageEvent = (event: Event): boolean => { export const isOpenTimestampsEvent = (event: Event): boolean => { return event.kind === EventKinds.OPEN_TIMESTAMPS } + +// Marmot Protocol helpers + +export const isWelcomeRumorEvent = (event: Event): boolean => { + return event.kind === EventKinds.MARMOT_WELCOME_RUMOR +} + +export const isMarmotGroupEvent = (event: Event): boolean => { + return event.kind === EventKinds.MARMOT_GROUP_EVENT +} + +// NIP-70: Protected Events + +export const isProtectedEvent = (event: Event): boolean => { + return event.tags.some((tag) => tag[0] === EventTags.Protected) +} diff --git a/src/utils/filter.ts b/src/utils/filter.ts index 2e1d5c94..243a5e49 100644 --- a/src/utils/filter.ts +++ b/src/utils/filter.ts @@ -1 +1,13 @@ +import { EventTags } from '../constants/base' + export const isGenericTagQuery = (key: string) => /^#[a-zA-Z]$/.test(key) + +// NIP-12 geohash filter helpers +export const geohashTagQuery = `#${EventTags.Geohash}` + +export const isGeohashTagQuery = (key: string): boolean => key === geohashTagQuery + +export const isGeohashPrefixCriterion = (key: string, criterion: string): boolean => + isGeohashTagQuery(key) && criterion.endsWith('*') + +export const stripGeohashPrefixWildcard = (criterion: string): string => criterion.slice(0, -1) From f411a594cccc8b75aba5bfcf7d982f07dc42ebd4 Mon Sep 17 00:00:00 2001 From: anshumancanrock Date: Sun, 21 Jun 2026 22:23:43 +0530 Subject: [PATCH 06/12] fix(nodeless): harden webhook verification use logger.error for security rejection paths (missing secret, invalid signature format, signature mismatch). guard all callback routes with requireProcessor middleware. --- .../callbacks/nodeless-callback-controller.ts | 10 ++++---- src/routes/callbacks/index.ts | 25 +++++++++++-------- 2 files changed, 20 insertions(+), 15 deletions(-) diff --git a/src/controllers/callbacks/nodeless-callback-controller.ts b/src/controllers/callbacks/nodeless-callback-controller.ts index b08de354..90d4ac47 100644 --- a/src/controllers/callbacks/nodeless-callback-controller.ts +++ b/src/controllers/callbacks/nodeless-callback-controller.ts @@ -31,9 +31,9 @@ export class NodelessCallbackController implements IController { return } - const nodelessWebhookSecret = process.env.NODELESS_WEBHOOK_SECRET - if (!nodelessWebhookSecret) { - logger.error('NODELESS_WEBHOOK_SECRET is not configured') + const webhookSecret = process.env.NODELESS_WEBHOOK_SECRET + if (!webhookSecret) { + logger.error('NODELESS_WEBHOOK_SECRET is not configured; unable to verify Nodeless callback') response .status(500) .setHeader('content-type', 'application/json; charset=utf8') @@ -43,7 +43,7 @@ export class NodelessCallbackController implements IController { const signatureValidation = validateSchema(nodelessSignatureSchema)(request.headers['nodeless-signature']) if (signatureValidation.error) { - logger('nodeless callback request rejected: invalid signature format') + logger.error('nodeless callback request rejected: invalid signature format') response .status(400) .setHeader('content-type', 'application/json; charset=utf8') @@ -51,7 +51,7 @@ export class NodelessCallbackController implements IController { return } - const expectedBuf = hmacSha256(nodelessWebhookSecret, (request as any).rawBody) + const expectedBuf = hmacSha256(webhookSecret, (request as any).rawBody) const actualBuf = Buffer.from(signatureValidation.value, 'hex') if (!timingSafeEqual(expectedBuf, actualBuf)) { diff --git a/src/routes/callbacks/index.ts b/src/routes/callbacks/index.ts index eed0ea49..b18d19ff 100644 --- a/src/routes/callbacks/index.ts +++ b/src/routes/callbacks/index.ts @@ -1,4 +1,4 @@ -import { json, Router, urlencoded } from 'express' +import { json, NextFunction, Request, Response, Router, urlencoded } from 'express' import { createLNbitsCallbackController } from '../../factories/controllers/lnbits-callback-controller-factory' import { createNodelessCallbackController } from '../../factories/controllers/nodeless-callback-controller-factory' @@ -9,17 +9,23 @@ import { withController } from '../../handlers/request-handlers/with-controller- const router: Router = Router() -const settings = createSettings() -const processor = settings.payments?.processor +const requireProcessor = (name: string) => + (_req: Request, res: Response, next: NextFunction) => { + const settings = createSettings() + if (!settings.payments?.enabled || settings.payments.processor !== name) { + res.status(403).send('Forbidden') + return + } + next() + } router - .post('/zebedee', json(), withController(createZebedeeCallbackController)) - .post('/lnbits', json(), withController(createLNbitsCallbackController)) - .post('/opennode', urlencoded({ extended: false }), json(), withController(createOpenNodeCallbackController)) - -if (processor === 'nodeless') { - router.post( + .post('/zebedee', requireProcessor('zebedee'), json(), withController(createZebedeeCallbackController)) + .post('/lnbits', requireProcessor('lnbits'), json(), withController(createLNbitsCallbackController)) + .post('/opennode', requireProcessor('opennode'), urlencoded({ extended: false }), json(), withController(createOpenNodeCallbackController)) + .post( '/nodeless', + requireProcessor('nodeless'), json({ verify(req, _res, buf) { ;(req as any).rawBody = buf @@ -27,6 +33,5 @@ if (processor === 'nodeless') { }), withController(createNodelessCallbackController), ) -} export default router From ed666e231d38d94ac88b5fd769049d2022c38845 Mon Sep 17 00:00:00 2001 From: anshumancanrock Date: Sun, 21 Jun 2026 22:23:59 +0530 Subject: [PATCH 07/12] test: cover NIP-50 search, nodeless security, and NIP-11 fields add search filter tests for event-repository (parameterized SQL, truncation, relevance ranking), filter-schema (validation bounds), subscribe-handler (search stripping when disabled), event utils (in-memory matching), and root-request-handler (search_supported). update nodeless controller specs for logger.error assertions. --- .../nodeless-callback-controller.spec.ts | 35 ++++- .../root-request-handler.spec.ts | 73 ++++++++++- .../subscribe-message-handler.spec.ts | 17 +++ .../repositories/event-repository.spec.ts | 69 ++++++++++ test/unit/schemas/filter-schema.spec.ts | 44 +++++++ test/unit/utils/event.spec.ts | 122 ++++++++++++++++++ 6 files changed, 357 insertions(+), 3 deletions(-) diff --git a/test/unit/controllers/callbacks/nodeless-callback-controller.spec.ts b/test/unit/controllers/callbacks/nodeless-callback-controller.spec.ts index 6f2fe993..8cd5db10 100644 --- a/test/unit/controllers/callbacks/nodeless-callback-controller.spec.ts +++ b/test/unit/controllers/callbacks/nodeless-callback-controller.spec.ts @@ -122,17 +122,48 @@ describe('NodelessCallbackController', () => { expect(paymentsService.updateInvoiceStatus).to.not.have.been.called }) - it('returns 403 when callback signature does not match', async () => { + it('returns 400 when callback signature has wrong length', async () => { const { controller, paymentsService } = makeController() const res = makeRes() - await controller.handleRequest(makeReq({ signature: 'b'.repeat(64) }), res) + await controller.handleRequest(makeReq({ signature: '0'.repeat(63) }), res) + + expect(res.status).to.have.been.calledWith(400) + expect(res.send).to.have.been.calledWith('{"status":"error","message":"Invalid signature"}') + expect(paymentsService.updateInvoiceStatus).to.not.have.been.called + }) + + it('returns 403 when callback signature is a valid-length hex string but does not match', async () => { + const { controller, paymentsService } = makeController() + const res = makeRes() + + await controller.handleRequest(makeReq({ signature: '0'.repeat(64) }), res) expect(res.status).to.have.been.calledWith(403) expect(res.send).to.have.been.calledWith('Forbidden') expect(paymentsService.updateInvoiceStatus).to.not.have.been.called }) + + it('returns 500 when NODELESS_WEBHOOK_SECRET is not configured', async () => { + delete process.env.NODELESS_WEBHOOK_SECRET + const { controller, paymentsService } = makeController() + const res = makeRes() + const rawBody = Buffer.from(JSON.stringify(validBody)) + const req = { + headers: { 'nodeless-signature': 'does-not-matter' }, + body: validBody, + rawBody, + } + + await controller.handleRequest(req as any, res) + + expect(res.status).to.have.been.calledWith(500) + expect(res.setHeader).to.have.been.calledWith('content-type', 'application/json; charset=utf8') + expect(res.send).to.have.been.calledWith('{"status":"error","message":"Internal Server Error"}') + expect(paymentsService.updateInvoiceStatus).to.not.have.been.called + }) + }) describe('invoice state handling', () => { 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 183d2fdf..a72bec27 100644 --- a/test/unit/handlers/request-handlers/root-request-handler.spec.ts +++ b/test/unit/handlers/request-handlers/root-request-handler.spec.ts @@ -7,8 +7,11 @@ const { expect } = chai import * as settingsFactory from '../../../../src/factories/settings-factory' import * as templateCache from '../../../../src/utils/template-cache' +import { + hasExplicitNostrJsonAcceptHeader, + rootRequestHandler, +} from '../../../../src/handlers/request-handlers/root-request-handler' import { DEFAULT_FILTER_LIMIT } from '../../../../src/constants/base' -import { rootRequestHandler } from '../../../../src/handlers/request-handlers/root-request-handler' const baseSettings = { info: { @@ -41,6 +44,23 @@ const settingsWithFee = { }, } +describe('hasExplicitNostrJsonAcceptHeader', () => { + it('returns true for explicit application/nostr+json', () => { + expect(hasExplicitNostrJsonAcceptHeader({ headers: { accept: 'application/nostr+json' } } as any)).to.equal(true) + }) + + it('returns false for typical browser Accept header', () => { + const browserAcceptHeader = + 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8' + + expect(hasExplicitNostrJsonAcceptHeader({ headers: { accept: browserAcceptHeader } } as any)).to.equal(false) + }) + + it('returns false when q=0 for application/nostr+json', () => { + expect(hasExplicitNostrJsonAcceptHeader({ headers: { accept: 'application/nostr+json;q=0' } } as any)).to.equal(false) + }) +}) + describe('rootRequestHandler', () => { let createSettingsStub: sinon.SinonStub let getTemplateStub: sinon.SinonStub @@ -105,6 +125,19 @@ describe('rootRequestHandler', () => { expect(getTemplateStub).to.not.have.been.called }) + it('includes relay_url path prefix in payments_url', () => { + createSettingsStub.returns({ + ...baseSettings, + info: { ...baseSettings.info, relay_url: 'wss://relay.example.com/nostream' }, + }) + + rootRequestHandler(req, res, next) + + const doc = res.send.firstCall.args[0] + expect(doc.payments_url).to.equal('https://relay.example.com/nostream/invoices') + }) + + it('includes optional NIP-11 fields when configured', () => { createSettingsStub.returns({ ...baseSettings, @@ -258,6 +291,44 @@ describe('rootRequestHandler', () => { expect(res.send.firstCall.args[0]).to.equal('test-nonce') }) + it('injects relay_url path prefix into links', () => { + createSettingsStub.returns({ + ...baseSettings, + info: { ...baseSettings.info, relay_url: 'wss://relay.example.com/nostream' }, + }) + getTemplateStub.returns('{{path_prefix}}/invoices|{{path_prefix}}/terms') + + rootRequestHandler(req, res, next) + + expect(res.send.firstCall.args[0]).to.equal('/nostream/invoices|/nostream/terms') + }) + + it('uses trusted forwarded path prefix over relay_url path', () => { + createSettingsStub.returns({ + ...baseSettings, + info: { ...baseSettings.info, relay_url: 'wss://relay.example.com/nostream' }, + network: { ...baseSettings.network, trustedProxies: ['127.0.0.1'] }, + }) + getTemplateStub.returns('{{path_prefix}}/invoices') + req.headers['x-forwarded-prefix'] = '/proxy' + req.socket = { remoteAddress: '127.0.0.1' } + + rootRequestHandler(req, res, next) + + expect(res.send.firstCall.args[0]).to.equal('/proxy/invoices') + }) + + it('ignores forwarded path prefix when proxy is not trusted', () => { + createSettingsStub.returns(baseSettings) + getTemplateStub.returns('{{path_prefix}}/invoices') + req.headers['x-forwarded-prefix'] = '/nostream' + req.socket = { remoteAddress: '127.0.0.1' } + + rootRequestHandler(req, res, next) + + expect(res.send.firstCall.args[0]).to.equal('/invoices') + }) + it('shows amount in sats when admission fee is enabled', () => { createSettingsStub.returns(settingsWithFee) getTemplateStub.returns('{{amount}}') diff --git a/test/unit/handlers/subscribe-message-handler.spec.ts b/test/unit/handlers/subscribe-message-handler.spec.ts index 4ff024d8..35f1bb2b 100644 --- a/test/unit/handlers/subscribe-message-handler.spec.ts +++ b/test/unit/handlers/subscribe-message-handler.spec.ts @@ -367,6 +367,23 @@ describe('SubscribeMessageHandler', () => { ) }) + it('returns reason if filter limit exceeds max limit', () => { + settingsFactory.returns({ + limits: { + client: { + subscription: { + maxLimit: 50, + }, + }, + }, + }) + filters = [{ limit: 100 }] + + expect((handler as any).canSubscribe(subscriptionId, filters)).to.equal( + 'Limit too high: Filter limit must be less than or equal to 50', + ) + }) + it('returns reason if subscription id is too long', () => { settingsFactory.returns({ limits: { diff --git a/test/unit/repositories/event-repository.spec.ts b/test/unit/repositories/event-repository.spec.ts index a1dd6160..84cc201e 100644 --- a/test/unit/repositories/event-repository.spec.ts +++ b/test/unit/repositories/event-repository.spec.ts @@ -320,6 +320,28 @@ describe('EventRepository', () => { }) }) + describe('#g', () => { + it('selects geohash tags by prefix when criterion ends with wildcard', () => { + const filters = [{ '#g': ['u4pruyd*'] }] + + const query = repository.findByFilters(filters).toString() + + expect(query).to.equal( + 'select "events".* from "events" left join "event_tags" on "events"."event_id" = "event_tags"."event_id" where (event_tags.tag_name = \'g\' AND event_tags.tag_value LIKE \'u4pruyd%\') order by "event_created_at" asc, "event_id" asc limit 500', + ) + }) + + it('keeps geohash tags exact when criterion has no wildcard', () => { + const filters = [{ '#g': ['u4pruyd'] }] + + const query = repository.findByFilters(filters).toString() + + expect(query).to.equal( + 'select "events".* from "events" left join "event_tags" on "events"."event_id" = "event_tags"."event_id" where (event_tags.tag_name = \'g\' AND event_tags.tag_value = \'u4pruyd\') order by "event_created_at" asc, "event_id" asc limit 500', + ) + }) + }) + describe('#p', () => { it('selects no events given empty list of #p tags', () => { const filters = [{ '#p': [] }] @@ -714,6 +736,53 @@ describe('EventRepository', () => { }) }) + describe('deleteExpiredAndRetained', () => { + it('returns zero counts when retention is disabled', async () => { + const result = await repository.deleteExpiredAndRetained({ maxDays: 0 }) + + expect(result).to.deep.equal({ + deleted: 0, + expired: 0, + retained: 0, + }) + }) + + it('builds a purge query with deduplicated kind and pubkey whitelists', () => { + sandbox.useFakeTimers(new Date('2023-11-14T22:13:20.000Z')) + + const query = repository + .deleteExpiredAndRetained({ + maxDays: 7, + kindWhitelist: [1, [10000, 19999], 1], + pubkeyWhitelist: ['001122'], + }) + .toString() + + expect(query).to.equal( + 'delete from "events" where "event_id" in (select "event_id" from "events" where ("expires_at" < 1700000000 or "deleted_at" is not null or "event_created_at" < 1699395200) and not ("event_kind" = 1 or "event_kind" between 10000 and 19999 or "event_kind" = 62) and "event_pubkey" not in (X\'001122\') limit 1000) returning "deleted_at", "expires_at", "event_created_at"', + ) + }) + + it('maps purged rows to deleted, expired, and retained counts', () => { + const result = (repository as any).mapToPurgeCounts( + [ + { deleted_at: new Date(), expires_at: null, event_created_at: 90 }, + { deleted_at: null, expires_at: 95, event_created_at: 90 }, + { deleted_at: null, expires_at: null, event_created_at: 40 }, + { deleted_at: null, expires_at: null, event_created_at: 80 }, + ], + 100, + 50, + ) + + expect(result).to.deep.equal({ + deleted: 1, + expired: 1, + retained: 1, + }) + }) + }) + describe('upsert', () => { it('replaces event based on event_pubkey and event_kind', () => { const event: Event = { diff --git a/test/unit/schemas/filter-schema.spec.ts b/test/unit/schemas/filter-schema.spec.ts index eb34731c..b6fee41a 100644 --- a/test/unit/schemas/filter-schema.spec.ts +++ b/test/unit/schemas/filter-schema.spec.ts @@ -186,3 +186,47 @@ describe('NIP-01', () => { }) }) }) + +describe('NIP-12', () => { + describe('#g filter validation', () => { + it('accepts a valid base32 geohash', () => { + const result = validateSchema(filterSchema)({ '#g': ['u4pruydqqvj'] }) + expect(result.error).to.be.undefined + }) + + it('accepts a valid geohash prefix with trailing wildcard', () => { + const result = validateSchema(filterSchema)({ '#g': ['u4pruyd*'] }) + expect(result.error).to.be.undefined + }) + + it('rejects an empty criterion', () => { + const result = validateSchema(filterSchema)({ '#g': [''] }) + expect(result.error).to.not.be.undefined + }) + + it('rejects a bare wildcard', () => { + const result = validateSchema(filterSchema)({ '#g': ['*'] }) + expect(result.error).to.not.be.undefined + }) + + it('rejects non-base32 characters', () => { + const result = validateSchema(filterSchema)({ '#g': ['u4pruyda'] }) + expect(result.error).to.not.be.undefined + }) + + it('rejects uppercase characters', () => { + const result = validateSchema(filterSchema)({ '#g': ['U4PRUYDQQVJ'] }) + expect(result.error).to.not.be.undefined + }) + + it('rejects wildcard not at the end', () => { + const result = validateSchema(filterSchema)({ '#g': ['u4*pruyd'] }) + expect(result.error).to.not.be.undefined + }) + + it('rejects multiple wildcards', () => { + const result = validateSchema(filterSchema)({ '#g': ['u4pruyd**'] }) + expect(result.error).to.not.be.undefined + }) + }) +}) diff --git a/test/unit/utils/event.spec.ts b/test/unit/utils/event.spec.ts index fbd23684..53cc0f6c 100644 --- a/test/unit/utils/event.spec.ts +++ b/test/unit/utils/event.spec.ts @@ -11,10 +11,13 @@ import { isExpiredEvent, isFileMessageEvent, isGiftWrapEvent, + isMarmotGroupEvent, isParameterizedReplaceableEvent, + isProtectedEvent, isReplaceableEvent, isRequestToVanishEvent, isSealEvent, + isWelcomeRumorEvent, serializeEvent, } from '../../../src/utils/event' import { expect } from 'chai' @@ -355,6 +358,33 @@ describe('NIP-12', () => { expect(isEventMatchingFilter({ '#r': ['something else'] })(event)).to.be.false }) }) + + describe('#g filter', () => { + beforeEach(() => { + event = { + id: 'cf8de9db67a1d7203512d1d81e6190f5e53abfdc0ac90275f67172b65a5b09a0', + pubkey: 'e8b487c079b0f67c695ae6c4c2552a47f38adfa2533cc5926bd2c102942fdcb7', + created_at: 1645030752, + kind: 1, + tags: [['g', 'u4pruydqqvj']], + content: 'g', + sig: '53d12018d036092794366283eca36df4e0cabd014b6e91bbf684c8bb9bbbe9dedafa77b6b928587e11e05e036227598dded8713e8da17d55076e12242b361542', + } + }) + + it('returns true if #g filter contains a matching geohash prefix wildcard', () => { + expect(isEventMatchingFilter({ '#g': ['u4pruyd*'] })(event)).to.be.true + }) + + it('returns false if #g filter contains a non-matching geohash prefix wildcard', () => { + expect(isEventMatchingFilter({ '#g': ['u4pruz*'] })(event)).to.be.false + }) + + it('keeps #g filter exact when criterion has no wildcard', () => { + expect(isEventMatchingFilter({ '#g': ['u4pruyd'] })(event)).to.be.false + expect(isEventMatchingFilter({ '#g': ['u4pruydqqvj'] })(event)).to.be.true + }) + }) }) describe('NIP-16', () => { @@ -441,6 +471,45 @@ describe('NIP-17', () => { }) }) +describe('Marmot Protocol', () => { + describe('isWelcomeRumorEvent', () => { + it('returns true for kind 444', () => { + expect(isWelcomeRumorEvent({ kind: EventKinds.MARMOT_WELCOME_RUMOR } as any)).to.be.true + }) + + it('returns false for kind 445 (group event)', () => { + expect(isWelcomeRumorEvent({ kind: EventKinds.MARMOT_GROUP_EVENT } as any)).to.be.false + }) + + it('returns false for kind 1059 (gift wrap)', () => { + expect(isWelcomeRumorEvent({ kind: EventKinds.GIFT_WRAP } as any)).to.be.false + }) + + it('returns false for any unrelated kind', () => { + expect(isWelcomeRumorEvent({ kind: EventKinds.TEXT_NOTE } as any)).to.be.false + }) + }) + + describe('isMarmotGroupEvent', () => { + it('returns true for kind 445', () => { + expect(isMarmotGroupEvent({ kind: EventKinds.MARMOT_GROUP_EVENT } as any)).to.be.true + }) + + it('returns false for kind 444 (welcome rumor)', () => { + expect(isMarmotGroupEvent({ kind: EventKinds.MARMOT_WELCOME_RUMOR } as any)).to.be.false + }) + + it('returns false for kind 443 (legacy key package)', () => { + expect(isMarmotGroupEvent({ kind: EventKinds.MARMOT_KEY_PACKAGE_LEGACY } as any)).to.be.false + }) + + it('returns false for any unrelated kind', () => { + expect(isMarmotGroupEvent({ kind: EventKinds.TEXT_NOTE } as any)).to.be.false + expect(isMarmotGroupEvent({ kind: EventKinds.GIFT_WRAP } as any)).to.be.false + }) + }) +}) + // describe('NIP-27', () => { // describe('isEventMatchingFilter', () => { // describe('#m filter', () => { @@ -640,3 +709,56 @@ describe('NIP-40', () => { }) }) }) + +describe('NIP-70', () => { + describe('isProtectedEvent', () => { + it('returns true if event has a ["-"] tag', () => { + const event: Event = { + tags: [['-']], + } as any + expect(isProtectedEvent(event)).to.be.true + }) + + it('returns true if protected tag has extra values', () => { + const event: Event = { + tags: [['-', 'some-reason']], + } as any + expect(isProtectedEvent(event)).to.be.true + }) + + it('returns false if event has no tags', () => { + const event: Event = { + tags: [], + } as any + expect(isProtectedEvent(event)).to.be.false + }) + + it('returns false if event has unrelated tags', () => { + const event: Event = { + tags: [ + ['e', '7377fa81fc6c7ae7f7f4ef8938d4a603f7bf98183b35ab128235cc92d4bebf96'], + ['p', '22e804d26ed16b68db5259e78449e96dab5d464c8f470bda3eb1a70467f2c793'], + ], + } as any + expect(isProtectedEvent(event)).to.be.false + }) + + it('returns false if "-" appears as a tag value, not a tag name', () => { + const event: Event = { + tags: [['e', '-']], + } as any + expect(isProtectedEvent(event)).to.be.false + }) + + it('returns true when protected tag is among other tags', () => { + const event: Event = { + tags: [ + ['e', '7377fa81fc6c7ae7f7f4ef8938d4a603f7bf98183b35ab128235cc92d4bebf96'], + ['-'], + ['p', '22e804d26ed16b68db5259e78449e96dab5d464c8f470bda3eb1a70467f2c793'], + ], + } as any + expect(isProtectedEvent(event)).to.be.true + }) + }) +}) From cecdc408901318b6fe6754ef4eb248db5dda0a0c Mon Sep 17 00:00:00 2001 From: anshumancanrock Date: Mon, 22 Jun 2026 00:59:22 +0530 Subject: [PATCH 08/12] docs: fix GIN index name in nip50.language note the migration creates events_content_fts_idx, not idx_events_content_fts. operators following the old instructions would create a duplicate index. --- CONFIGURATION.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CONFIGURATION.md b/CONFIGURATION.md index fe8d9e30..473edb9b 100644 --- a/CONFIGURATION.md +++ b/CONFIGURATION.md @@ -180,7 +180,7 @@ The settings below are listed in alphabetical order by name. Please keep this ta | nip05.verifyUpdateFrequency | Minimum interval in milliseconds between re-verification attempts for a given author. Defaults to 86400000 (24 hours). | | nip45.enabled | Enable or disable NIP-45 COUNT handling. Defaults to true. | | nip50.enabled | Enable or disable NIP-50 full-text search. Defaults to false. When enabled, clients can include a `search` field in REQ filters to perform text queries against event content. Requires the GIN full-text index migration. | -| nip50.language | PostgreSQL text-search configuration name. Defaults to `simple` (language-agnostic tokenization). Set to `english`, `spanish`, etc. for stemming support. See [PostgreSQL text search configurations](https://www.postgresql.org/docs/current/textsearch-configuration.html). | +| nip50.language | PostgreSQL text-search configuration name. Defaults to `simple` (language-agnostic tokenization). Set to `english`, `spanish`, etc. for stemming support. See [PostgreSQL text search configurations](https://www.postgresql.org/docs/current/textsearch-configuration.html). **Note:** The GIN index migration is built with the `simple` configuration. If you change this value, you must manually rebuild the index: `DROP INDEX CONCURRENTLY events_content_fts_idx; CREATE INDEX CONCURRENTLY events_content_fts_idx ON events USING gin (to_tsvector('', event_content));` — otherwise the planner cannot use the index and queries fall back to sequential scans. | | nip50.maxQueryLength | Maximum length of the search query string. Queries exceeding this are truncated. Defaults to 256. | | paymentProcessors.lnbits.baseURL | Base URL of your Lnbits instance. | | paymentProcessors.lnbits.callbackBaseURL | Public-facing Nostream's Lnbits Callback URL. (e.g. https://relay.your-domain.com/callbacks/lnbits) | From 361434377b0c84a04d751302617dc881429c4bbb Mon Sep 17 00:00:00 2001 From: anshumancanrock Date: Mon, 22 Jun 2026 00:59:30 +0530 Subject: [PATCH 09/12] test: update assertions for parameterized regconfig the ?::regconfig bindings serialize as 'simple'::regconfig in knex query strings, not 'simple' inside a template literal. --- .../unit/repositories/event-repository.spec.ts | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/test/unit/repositories/event-repository.spec.ts b/test/unit/repositories/event-repository.spec.ts index 84cc201e..52de1f10 100644 --- a/test/unit/repositories/event-repository.spec.ts +++ b/test/unit/repositories/event-repository.spec.ts @@ -456,7 +456,7 @@ describe('EventRepository', () => { beforeEach(() => { searchEnabledRepository = new EventRepository(dbClient, rrDbClient, () => ({ nip50: { enabled: true, language: 'simple', maxQueryLength: 256 }, - })) + }) as any) }) it('adds tsvector/tsquery WHERE clause when search is provided and enabled', () => { @@ -464,7 +464,7 @@ describe('EventRepository', () => { const query = searchEnabledRepository.findByFilters(filters).toString() - expect(query).to.include("to_tsvector('simple', event_content) @@ plainto_tsquery('simple', 'bitcoin lightning')") + expect(query).to.include("to_tsvector('simple'::regconfig, event_content) @@ plainto_tsquery('simple'::regconfig, 'bitcoin lightning')") }) it('orders results by search_rank DESC when search is active', () => { @@ -497,14 +497,14 @@ describe('EventRepository', () => { const query = searchEnabledRepository.findByFilters(filters).toString() - expect(query).to.include("plainto_tsquery('simple', 'bitcoin')") + expect(query).to.include("plainto_tsquery('simple'::regconfig, 'bitcoin')") expect(query).to.include('"event_kind" in (1)') }) it('ignores search filter when NIP-50 is disabled', () => { const disabledRepository = new EventRepository(dbClient, rrDbClient, () => ({ nip50: { enabled: false }, - })) + }) as any) const filters = [{ search: 'bitcoin' }] const query = disabledRepository.findByFilters(filters).toString() @@ -527,24 +527,24 @@ describe('EventRepository', () => { it('uses configured language for text search', () => { const englishRepository = new EventRepository(dbClient, rrDbClient, () => ({ nip50: { enabled: true, language: 'english' }, - })) + }) as any) const filters = [{ search: 'running' }] const query = englishRepository.findByFilters(filters).toString() - expect(query).to.include("to_tsvector('english', event_content)") - expect(query).to.include("plainto_tsquery('english', 'running')") + expect(query).to.include("to_tsvector('english'::regconfig, event_content)") + expect(query).to.include("plainto_tsquery('english'::regconfig, 'running')") }) it('truncates search query to maxQueryLength', () => { const shortMaxRepository = new EventRepository(dbClient, rrDbClient, () => ({ nip50: { enabled: true, language: 'simple', maxQueryLength: 5 }, - })) + }) as any) const filters = [{ search: 'bitcoinlightning' }] const query = shortMaxRepository.findByFilters(filters).toString() - expect(query).to.include("plainto_tsquery('simple', 'bitco')") + expect(query).to.include("plainto_tsquery('simple'::regconfig, 'bitco')") }) }) }) From 9f8efb421f25724d993169865a16da765911edae Mon Sep 17 00:00:00 2001 From: anshumancanrock Date: Mon, 22 Jun 2026 00:59:39 +0530 Subject: [PATCH 10/12] chore: add new files from upstream main includes changesets, husky install script, auth handler, group event strategy, geohash utils, NIP-25/NIP-65 utils, integration and performance test scaffolding. --- .changeset/brown-bears-pay.md | 5 + .changeset/callback-check-payments-enabled.md | 5 + ...allback-routes-conditional-registration.md | 5 + .changeset/dark-places-tickle.md | 5 + .changeset/dependabot-pr-613.md | 5 + .changeset/dependabot-pr-617.md | 5 + .changeset/dependabot-pr-618.md | 5 + .changeset/dependabot-pr-620.md | 5 + .changeset/dependabot-pr-630.md | 5 + .changeset/fix-serialize-javascript-cve.md | 5 + .changeset/funky-coins-know.md | 5 + .changeset/geohash-prefix-filters.md | 9 + .changeset/huge-trains-nail.md | 5 + .changeset/husky-fallback.md | 5 + .changeset/jolly-canyons-glow.md | 5 + .changeset/lemon-views-know.md | 5 + .changeset/marmot-protocol-support.md | 14 ++ .changeset/metal-snails-prove.md | 5 + .changeset/nip-25-reactions.md | 5 + .changeset/nip-65-relay-list-metadata.md | 5 + .changeset/nip70-protected-event-util.md | 5 + .../normalize-run-command-with-output.md | 5 + .changeset/quick-cats-agree.md | 5 + .../remove-deprecated-remote-ip-header.md | 5 + .../response-types-integration-tests.md | 5 + .changeset/tall-mangos-hear.md | 5 + .changeset/test-maintenance-worker-factory.md | 5 + .changeset/thirty-turtles-design.md | 5 + .changeset/vast-friends-march.md | 5 + .changeset/vast-signs-melt.md | 2 + .husky/install.mjs | 18 ++ src/handlers/auth-message-handler.ts | 86 +++++++ .../event-strategies/group-event-strategy.ts | 56 +++++ src/utils/geohash.ts | 9 + src/utils/nip25.ts | 39 +++ src/utils/nip65.ts | 12 + .../features/nip-25/nip-25.feature | 24 ++ .../features/nip-25/nip-25.feature.ts | 49 ++++ .../features/nip-65/nip-65.feature | 27 ++ .../features/nip-65/nip-65.feature.ts | 104 ++++++++ .../response-types/response-types.feature | 37 +++ .../response-types/response-types.feature.ts | 112 +++++++++ test/performance/connection-limiting-k6.ts | 83 +++++++ test/performance/message-limiting-k6.ts | 106 ++++++++ test/unit/cache/client.spec.ts | 72 ++++++ test/unit/cli/run-command.spec.ts | 44 ++++ .../maintenance-service-factory.spec.ts | 10 + .../maintenance-worker-factory.spec.ts | 10 + .../handlers/auth-message-handler.spec.ts | 233 ++++++++++++++++++ .../group-event-strategy.spec.ts | 232 +++++++++++++++++ test/unit/husky/install.spec.ts | 57 +++++ test/unit/utils/nip25.spec.ts | 90 +++++++ test/unit/utils/nip65.spec.ts | 90 +++++++ 53 files changed, 1760 insertions(+) create mode 100644 .changeset/brown-bears-pay.md create mode 100644 .changeset/callback-check-payments-enabled.md create mode 100644 .changeset/callback-routes-conditional-registration.md create mode 100644 .changeset/dark-places-tickle.md create mode 100644 .changeset/dependabot-pr-613.md create mode 100644 .changeset/dependabot-pr-617.md create mode 100644 .changeset/dependabot-pr-618.md create mode 100644 .changeset/dependabot-pr-620.md create mode 100644 .changeset/dependabot-pr-630.md create mode 100644 .changeset/fix-serialize-javascript-cve.md create mode 100644 .changeset/funky-coins-know.md create mode 100644 .changeset/geohash-prefix-filters.md create mode 100644 .changeset/huge-trains-nail.md create mode 100644 .changeset/husky-fallback.md create mode 100644 .changeset/jolly-canyons-glow.md create mode 100644 .changeset/lemon-views-know.md create mode 100644 .changeset/marmot-protocol-support.md create mode 100644 .changeset/metal-snails-prove.md create mode 100644 .changeset/nip-25-reactions.md create mode 100644 .changeset/nip-65-relay-list-metadata.md create mode 100644 .changeset/nip70-protected-event-util.md create mode 100644 .changeset/normalize-run-command-with-output.md create mode 100644 .changeset/quick-cats-agree.md create mode 100644 .changeset/remove-deprecated-remote-ip-header.md create mode 100644 .changeset/response-types-integration-tests.md create mode 100644 .changeset/tall-mangos-hear.md create mode 100644 .changeset/test-maintenance-worker-factory.md create mode 100644 .changeset/thirty-turtles-design.md create mode 100644 .changeset/vast-friends-march.md create mode 100644 .changeset/vast-signs-melt.md create mode 100644 .husky/install.mjs create mode 100644 src/handlers/auth-message-handler.ts create mode 100644 src/handlers/event-strategies/group-event-strategy.ts create mode 100644 src/utils/geohash.ts create mode 100644 src/utils/nip25.ts create mode 100644 src/utils/nip65.ts create mode 100644 test/integration/features/nip-25/nip-25.feature create mode 100644 test/integration/features/nip-25/nip-25.feature.ts create mode 100644 test/integration/features/nip-65/nip-65.feature create mode 100644 test/integration/features/nip-65/nip-65.feature.ts create mode 100644 test/integration/features/response-types/response-types.feature create mode 100644 test/integration/features/response-types/response-types.feature.ts create mode 100644 test/performance/connection-limiting-k6.ts create mode 100644 test/performance/message-limiting-k6.ts create mode 100644 test/unit/cache/client.spec.ts create mode 100644 test/unit/cli/run-command.spec.ts create mode 100644 test/unit/factories/maintenance-service-factory.spec.ts create mode 100644 test/unit/factories/maintenance-worker-factory.spec.ts create mode 100644 test/unit/handlers/auth-message-handler.spec.ts create mode 100644 test/unit/handlers/event-strategies/group-event-strategy.spec.ts create mode 100644 test/unit/husky/install.spec.ts create mode 100644 test/unit/utils/nip25.spec.ts create mode 100644 test/unit/utils/nip65.spec.ts diff --git a/.changeset/brown-bears-pay.md b/.changeset/brown-bears-pay.md new file mode 100644 index 00000000..2b1997c8 --- /dev/null +++ b/.changeset/brown-bears-pay.md @@ -0,0 +1,5 @@ +--- +"nostream": patch +--- + +fix: maxLimit checks added to subscription message handler diff --git a/.changeset/callback-check-payments-enabled.md b/.changeset/callback-check-payments-enabled.md new file mode 100644 index 00000000..560cf24f --- /dev/null +++ b/.changeset/callback-check-payments-enabled.md @@ -0,0 +1,5 @@ +--- +"nostream": patch +--- + +fix: check payments.enabled in callback route middleware diff --git a/.changeset/callback-routes-conditional-registration.md b/.changeset/callback-routes-conditional-registration.md new file mode 100644 index 00000000..eb5db6ab --- /dev/null +++ b/.changeset/callback-routes-conditional-registration.md @@ -0,0 +1,5 @@ +--- +"nostream": patch +--- + +refactor: only register OpenNode, LNbits, and Zebedee callback routes when their processor is active diff --git a/.changeset/dark-places-tickle.md b/.changeset/dark-places-tickle.md new file mode 100644 index 00000000..6ed7f8f4 --- /dev/null +++ b/.changeset/dark-places-tickle.md @@ -0,0 +1,5 @@ +--- +"nostream": patch +--- + +Fix root HTML negotiation and subpath-aware template links behind trusted proxies. diff --git a/.changeset/dependabot-pr-613.md b/.changeset/dependabot-pr-613.md new file mode 100644 index 00000000..0e6f02ab --- /dev/null +++ b/.changeset/dependabot-pr-613.md @@ -0,0 +1,5 @@ +--- +"nostream": patch +--- + +chore(deps): bump axios from 1.15.1 to 1.15.2 diff --git a/.changeset/dependabot-pr-617.md b/.changeset/dependabot-pr-617.md new file mode 100644 index 00000000..2b611bfd --- /dev/null +++ b/.changeset/dependabot-pr-617.md @@ -0,0 +1,5 @@ +--- +"nostream": patch +--- + +chore(deps): bump fast-uri from 3.1.0 to 3.1.2 diff --git a/.changeset/dependabot-pr-618.md b/.changeset/dependabot-pr-618.md new file mode 100644 index 00000000..5d1f5c9f --- /dev/null +++ b/.changeset/dependabot-pr-618.md @@ -0,0 +1,5 @@ +--- +"nostream": patch +--- + +chore(deps): bump ws from 8.20.0 to 8.20.1 diff --git a/.changeset/dependabot-pr-620.md b/.changeset/dependabot-pr-620.md new file mode 100644 index 00000000..3c25c816 --- /dev/null +++ b/.changeset/dependabot-pr-620.md @@ -0,0 +1,5 @@ +--- +"nostream": patch +--- + +chore(deps): bump uuid from 8.3.2 to 14.0.0 diff --git a/.changeset/dependabot-pr-630.md b/.changeset/dependabot-pr-630.md new file mode 100644 index 00000000..fff3fcf7 --- /dev/null +++ b/.changeset/dependabot-pr-630.md @@ -0,0 +1,5 @@ +--- +"nostream": patch +--- + +chore(deps): bump axios from 1.15.2 to 1.16.0 diff --git a/.changeset/fix-serialize-javascript-cve.md b/.changeset/fix-serialize-javascript-cve.md new file mode 100644 index 00000000..224eef2b --- /dev/null +++ b/.changeset/fix-serialize-javascript-cve.md @@ -0,0 +1,5 @@ +--- +"nostream": patch +--- + +Security: override serialize-javascript to >=7.0.3 (CVE RCE, GHSA-5c6j-r48x-rmvq) diff --git a/.changeset/funky-coins-know.md b/.changeset/funky-coins-know.md new file mode 100644 index 00000000..aa0e10bc --- /dev/null +++ b/.changeset/funky-coins-know.md @@ -0,0 +1,5 @@ +--- +"nostream": minor +--- + +feat: NIP-42 AUTH handler and WebSocket session wiring diff --git a/.changeset/geohash-prefix-filters.md b/.changeset/geohash-prefix-filters.md new file mode 100644 index 00000000..7f14c99a --- /dev/null +++ b/.changeset/geohash-prefix-filters.md @@ -0,0 +1,9 @@ +--- +"nostream": patch +--- + +Implement geohash wildcard/prefix behavior for `#g` filters (closes #265): a +criterion ending in `*` matches any event `g` tag whose value starts with the +prefix before `*`; exact matching (no `*`) is unchanged. Only normal geohash +prefixes are intended as input. This is a Nostream extension, not part of +NIP-12. diff --git a/.changeset/huge-trains-nail.md b/.changeset/huge-trains-nail.md new file mode 100644 index 00000000..9b093e72 --- /dev/null +++ b/.changeset/huge-trains-nail.md @@ -0,0 +1,5 @@ +--- +"nostream": patch +--- + +Use timingSafeEqual for Nodeless webhook HMAC verification and guard against missing NODELESS_WEBHOOK_SECRET diff --git a/.changeset/husky-fallback.md b/.changeset/husky-fallback.md new file mode 100644 index 00000000..7f63319e --- /dev/null +++ b/.changeset/husky-fallback.md @@ -0,0 +1,5 @@ +--- +"nostream": patch +--- + +fix: add husky install fallback for non-dev environments diff --git a/.changeset/jolly-canyons-glow.md b/.changeset/jolly-canyons-glow.md new file mode 100644 index 00000000..891fcf16 --- /dev/null +++ b/.changeset/jolly-canyons-glow.md @@ -0,0 +1,5 @@ +--- +"nostream": minor +--- + +perf: added k6 performance tests for connection and message rate limiting \ No newline at end of file diff --git a/.changeset/lemon-views-know.md b/.changeset/lemon-views-know.md new file mode 100644 index 00000000..bf4e8fc4 --- /dev/null +++ b/.changeset/lemon-views-know.md @@ -0,0 +1,5 @@ +--- +"nostream": minor +--- + +new user-facing config field diff --git a/.changeset/marmot-protocol-support.md b/.changeset/marmot-protocol-support.md new file mode 100644 index 00000000..298b6de9 --- /dev/null +++ b/.changeset/marmot-protocol-support.md @@ -0,0 +1,14 @@ +--- +"nostream": minor +--- + +Add relay support for the Marmot Protocol (E2EE group messaging over Nostr). + +Supported MIPs: 00 (KeyPackages), 01 (Group Construction), 02 (Welcome Events), 03 (Group Messages). + +- kind 443 (legacy KeyPackage): stored as a regular event +- kind 10051 (KeyPackage relay list): stored as a replaceable event +- kind 30443 (KeyPackage): stored as a parameterized-replaceable event with `d`-tag deduplication +- kind 444 (Welcome rumor): blocked from direct publishing; must travel inside a kind 1059 gift wrap +- kind 445 (Group Event): dedicated strategy validates the required `h` tag (nostr_group_id) before storing; `#h` tag subscriptions work via the existing generic tag index +- NIP-11 relay info now advertises `supported_mips: [0, 1, 2, 3]` diff --git a/.changeset/metal-snails-prove.md b/.changeset/metal-snails-prove.md new file mode 100644 index 00000000..d1f972c1 --- /dev/null +++ b/.changeset/metal-snails-prove.md @@ -0,0 +1,5 @@ +--- +"nostream": patch +--- + +fix: resolve TOCTOU race condition and key collisions in SlidingWindowRateLimiter diff --git a/.changeset/nip-25-reactions.md b/.changeset/nip-25-reactions.md new file mode 100644 index 00000000..c0941f2c --- /dev/null +++ b/.changeset/nip-25-reactions.md @@ -0,0 +1,5 @@ +--- +"nostream": minor +--- + +Add NIP-25 Reactions support for kind 7 and kind 17 events: reaction utility helpers (`isReactionEvent`, `isExternalContentReactionEvent`, `isLikeReaction`, `isDislikeReaction`, `parseReaction`), schema validation enforcing required `e` tag on kind 7 and required `k`/`i` tags on kind 17, unit tests, and integration tests. \ No newline at end of file diff --git a/.changeset/nip-65-relay-list-metadata.md b/.changeset/nip-65-relay-list-metadata.md new file mode 100644 index 00000000..35cec677 --- /dev/null +++ b/.changeset/nip-65-relay-list-metadata.md @@ -0,0 +1,5 @@ +--- +"nostream": minor +--- + +Add NIP-65 Relay List Metadata support for kind 10002 events: relay list utility with `isRelayListEvent` and `parseRelayList` helpers, unit tests, and relay information document updated to advertise NIP-65 (#577). diff --git a/.changeset/nip70-protected-event-util.md b/.changeset/nip70-protected-event-util.md new file mode 100644 index 00000000..7d82848d --- /dev/null +++ b/.changeset/nip70-protected-event-util.md @@ -0,0 +1,5 @@ +--- +"nostream": patch +--- + +feat: add NIP-70 protected event detection utility diff --git a/.changeset/normalize-run-command-with-output.md b/.changeset/normalize-run-command-with-output.md new file mode 100644 index 00000000..d086daa9 --- /dev/null +++ b/.changeset/normalize-run-command-with-output.md @@ -0,0 +1,5 @@ +--- +"nostream": patch +--- + +Normalize runCommandWithOutput to return a CommandResult discriminated union instead of rejecting on spawn errors, fixing a crash in `info --json` when Docker is not installed. diff --git a/.changeset/quick-cats-agree.md b/.changeset/quick-cats-agree.md new file mode 100644 index 00000000..4595cbae --- /dev/null +++ b/.changeset/quick-cats-agree.md @@ -0,0 +1,5 @@ +--- +"nostream": patch +--- + +Refactor EventRepository query construction to reduce method complexity. diff --git a/.changeset/remove-deprecated-remote-ip-header.md b/.changeset/remove-deprecated-remote-ip-header.md new file mode 100644 index 00000000..eda7c7a6 --- /dev/null +++ b/.changeset/remove-deprecated-remote-ip-header.md @@ -0,0 +1,5 @@ +--- +"nostream": minor +--- + +refactor(http): remove deprecated network.remote_ip_header fallback and rely on network.remoteIpHeader diff --git a/.changeset/response-types-integration-tests.md b/.changeset/response-types-integration-tests.md new file mode 100644 index 00000000..70c5126e --- /dev/null +++ b/.changeset/response-types-integration-tests.md @@ -0,0 +1,5 @@ +--- +"nostream": patch +--- + +test(integration): verify response content-type across core HTTP paths diff --git a/.changeset/tall-mangos-hear.md b/.changeset/tall-mangos-hear.md new file mode 100644 index 00000000..e2ecabd1 --- /dev/null +++ b/.changeset/tall-mangos-hear.md @@ -0,0 +1,5 @@ +--- +"nostream": patch +--- + +Add unit tests for maintenance service factory instantiation and dependency wiring. diff --git a/.changeset/test-maintenance-worker-factory.md b/.changeset/test-maintenance-worker-factory.md new file mode 100644 index 00000000..9d73b1e1 --- /dev/null +++ b/.changeset/test-maintenance-worker-factory.md @@ -0,0 +1,5 @@ +--- +"nostream": patch +--- + +Add unit tests for maintenance-worker-factory with 100% code coverage diff --git a/.changeset/thirty-turtles-design.md b/.changeset/thirty-turtles-design.md new file mode 100644 index 00000000..41a85fc7 --- /dev/null +++ b/.changeset/thirty-turtles-design.md @@ -0,0 +1,5 @@ +--- +"nostream": patch +--- + +Fix Redis cache connection config to skip AUTH when `REDIS_PASSWORD` is unset diff --git a/.changeset/vast-friends-march.md b/.changeset/vast-friends-march.md new file mode 100644 index 00000000..dd536abf --- /dev/null +++ b/.changeset/vast-friends-march.md @@ -0,0 +1,5 @@ +--- +"nostream": minor +--- + +feat: add NIP-42 types, schemas and constants diff --git a/.changeset/vast-signs-melt.md b/.changeset/vast-signs-melt.md new file mode 100644 index 00000000..a845151c --- /dev/null +++ b/.changeset/vast-signs-melt.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/.husky/install.mjs b/.husky/install.mjs new file mode 100644 index 00000000..2f196fc5 --- /dev/null +++ b/.husky/install.mjs @@ -0,0 +1,18 @@ +import { existsSync } from 'node:fs'; +import { execSync } from 'node:child_process'; + +// Skip Husky installation in environments where hooks are not needed +if ( + process.env.NODE_ENV === 'production' || + process.env.CI === 'true' || + process.env.HUSKY === '0' || + !existsSync('.git') +) { + process.exit(0); +} + +try { + execSync('npx husky install', { stdio: 'ignore' }); +} catch { + process.exit(0); +} diff --git a/src/handlers/auth-message-handler.ts b/src/handlers/auth-message-handler.ts new file mode 100644 index 00000000..e3f4f0c1 --- /dev/null +++ b/src/handlers/auth-message-handler.ts @@ -0,0 +1,86 @@ +import { EventKinds, EventTags } from '../constants/base' +import { isEventIdValid, isEventSignatureValid } from '../utils/event' +import { AuthMessage } from '../@types/messages' +import { createCommandResult } from '../utils/messages' +import { createLogger } from '../factories/logger-factory' +import { IMessageHandler } from '../@types/message-handlers' +import { IWebSocketAdapter } from '../@types/adapters' +import { Settings } from '../@types/settings' +import { WebSocketAdapterEvent } from '../constants/adapter' + +const logger = createLogger('auth-message-handler') + +const AUTH_EVENT_KIND = EventKinds.AUTH // 22242 +const MAX_TIMESTAMP_DELTA_SECONDS = 600 // 10 minutes + +export class AuthMessageHandler implements IMessageHandler { + public constructor( + private readonly webSocket: IWebSocketAdapter, + private readonly settings: () => Settings, + ) {} + + public async handleMessage(message: AuthMessage): Promise { + const event = message[1] + + if (event.kind !== AUTH_EVENT_KIND) { + this.sendResult(event.id, false, 'invalid: auth event must be kind 22242') + return + } + + if (!(await isEventIdValid(event))) { + this.sendResult(event.id, false, 'invalid: event id does not match') + return + } + + if (!(await isEventSignatureValid(event))) { + this.sendResult(event.id, false, 'invalid: event signature verification failed') + return + } + + const now = Math.floor(Date.now() / 1000) + const delta = Math.abs(now - event.created_at) + if (delta > MAX_TIMESTAMP_DELTA_SECONDS) { + this.sendResult(event.id, false, 'invalid: created_at is too far from the current time') + return + } + + const challengeTag = event.tags.find( + (tag) => tag.length >= 2 && tag[0] === EventTags.Challenge, + ) + if (!challengeTag || challengeTag[1] !== this.webSocket.getChallenge()) { + this.sendResult(event.id, false, 'invalid: challenge does not match') + return + } + + const relayTag = event.tags.find( + (tag) => tag.length >= 2 && tag[0] === EventTags.AuthRelay, + ) + const relayUrl = this.settings().info.relay_url + if (!relayTag || !this.isRelayUrlMatch(relayTag[1], relayUrl)) { + this.sendResult(event.id, false, 'invalid: relay url does not match') + return + } + + logger('client %s authenticated as %s', this.webSocket.getClientId(), event.pubkey) + this.webSocket.addAuthenticatedPubkey(event.pubkey) + this.sendResult(event.id, true, '') + } + + private sendResult(eventId: string, success: boolean, message: string): void { + this.webSocket.emit( + WebSocketAdapterEvent.Message, + createCommandResult(eventId, success, message), + ) + } + + // NIP-42 says domain-match is sufficient for relay URL comparison + private isRelayUrlMatch(clientRelay: string, serverRelay: string): boolean { + try { + const clientHost = new URL(clientRelay).hostname.toLowerCase() + const serverHost = new URL(serverRelay).hostname.toLowerCase() + return clientHost === serverHost + } catch { + return false + } + } +} diff --git a/src/handlers/event-strategies/group-event-strategy.ts b/src/handlers/event-strategies/group-event-strategy.ts new file mode 100644 index 00000000..29e65b97 --- /dev/null +++ b/src/handlers/event-strategies/group-event-strategy.ts @@ -0,0 +1,56 @@ +import { createCommandResult } from '../../utils/messages' +import { createLogger } from '../../factories/logger-factory' +import { Event } from '../../@types/event' +import { EventTags } from '../../constants/base' +import { IEventRepository } from '../../@types/repositories' +import { IEventStrategy } from '../../@types/message-handlers' +import { IWebSocketAdapter } from '../../@types/adapters' +import { WebSocketAdapterEvent } from '../../constants/adapter' + +const logger = createLogger('group-event-strategy') + +export class GroupEventStrategy implements IEventStrategy> { + public constructor( + private readonly webSocket: IWebSocketAdapter, + private readonly eventRepository: IEventRepository, + ) {} + + public async execute(event: Event): Promise { + logger('received group event: %o', event) + + const reason = this.validateGroupEvent(event) + if (reason) { + this.webSocket.emit(WebSocketAdapterEvent.Message, createCommandResult(event.id, false, `invalid: ${reason}`)) + return + } + + const count = await this.eventRepository.create(event) + this.webSocket.emit(WebSocketAdapterEvent.Message, createCommandResult(event.id, true, count ? '' : 'duplicate:')) + + if (count) { + this.webSocket.emit(WebSocketAdapterEvent.Broadcast, event) + } + } + + // MIP-03: kind:445 Group Events MUST carry exactly one `h` tag whose value is the + // 64-character lowercase hex-encoded nostr_group_id from the Marmot Group Data Extension. + // The relay enforces this so that #h tag subscriptions always work correctly. + private validateGroupEvent(event: Event): string | undefined { + const groupTags = event.tags.filter((tag) => tag.length >= 2 && tag[0] === EventTags.Group) + + if (groupTags.length === 0) { + return 'group event (kind 445) must have an h tag identifying the group' + } + + if (groupTags.length > 1) { + return 'group event (kind 445) must have exactly one h tag' + } + + const groupId = groupTags[0][1] + if (!/^[0-9a-f]{64}$/.test(groupId)) { + return 'group event (kind 445) h tag must contain a valid 64-character lowercase hex group id' + } + + return undefined + } +} diff --git a/src/utils/geohash.ts b/src/utils/geohash.ts new file mode 100644 index 00000000..a6e39d3f --- /dev/null +++ b/src/utils/geohash.ts @@ -0,0 +1,9 @@ +// Geohash base32 alphabet (excludes 'a', 'i', 'l', 'o') +export const GEOHASH_BASE32 = '0123456789bcdefghjkmnpqrstuvwxyz' + +// Matches a complete geohash (one or more base32 chars) +export const GEOHASH_PATTERN = /^[0123456789bcdefghjkmnpqrstuvwxyz]+$/ + +// Matches a geohash filter criterion: one or more base32 chars, with an +// optional single trailing '*' wildcard (NIP-12 prefix matching) +export const GEOHASH_FILTER_PATTERN = /^[0123456789bcdefghjkmnpqrstuvwxyz]+\*?$/ diff --git a/src/utils/nip25.ts b/src/utils/nip25.ts new file mode 100644 index 00000000..0986c0a8 --- /dev/null +++ b/src/utils/nip25.ts @@ -0,0 +1,39 @@ +import { Event, ReactionEntry } from '../@types/event' +import { EventKinds, EventTags } from '../constants/base' + +export const isReactionEvent = (event: { kind?: number }): boolean => event.kind === EventKinds.REACTION + +export const isExternalContentReactionEvent = (event: { kind?: number }): boolean => + event.kind === EventKinds.EXTERNAL_CONTENT_REACTION + +export const isLikeReaction = (event: { kind?: number; content?: string }): boolean => + isReactionEvent(event) && (event.content === '+' || event.content === '') + +export const isDislikeReaction = (event: { kind?: number; content?: string }): boolean => + isReactionEvent(event) && event.content === '-' + +export const parseReaction = (event: Event): ReactionEntry => { + let lastETag: string[] | undefined + let lastPTag: string[] | undefined + let lastATag: string[] | undefined + let firstKTag: string[] | undefined + + for (const tag of event.tags) { + switch (tag[0]) { + case EventTags.Event: lastETag = tag; break + case EventTags.Pubkey: lastPTag = tag; break + case EventTags.Address: lastATag = tag; break + case EventTags.Kind: if (!firstKTag) { firstKTag = tag } break + } + } + + const kTagValue = firstKTag && firstKTag.length > 1 ? firstKTag[1] : undefined + const parsedKind = kTagValue !== undefined ? Number(kTagValue) : undefined + return { + targetEventId: lastETag?.[1], + targetPubkey: lastPTag?.[1], + targetAddress: lastATag?.[1], + targetKind: parsedKind !== undefined && Number.isFinite(parsedKind) ? parsedKind : undefined, + content: event.content, + } +} \ No newline at end of file diff --git a/src/utils/nip65.ts b/src/utils/nip65.ts new file mode 100644 index 00000000..11eb553c --- /dev/null +++ b/src/utils/nip65.ts @@ -0,0 +1,12 @@ +import { Event, RelayListEntry } from '../@types/event' +import { EventKinds, EventTags } from '../constants/base' + +export const isRelayListEvent = (event: Event): boolean => event.kind === EventKinds.RELAY_LIST + +export const parseRelayList = (event: Event): RelayListEntry[] => + event.tags + .filter((tag) => tag[0] === EventTags.Relay && tag.length >= 2) + .map((tag) => ({ + url: tag[1], + marker: tag[2] === 'read' || tag[2] === 'write' ? tag[2] : undefined, + })) diff --git a/test/integration/features/nip-25/nip-25.feature b/test/integration/features/nip-25/nip-25.feature new file mode 100644 index 00000000..53a1b1e3 --- /dev/null +++ b/test/integration/features/nip-25/nip-25.feature @@ -0,0 +1,24 @@ +Feature: NIP-25 Reactions + Scenario: Alice likes Bob's note + Given someone called Alice + And someone called Bob + When Bob sends a text_note event with content "hello world" + And Alice reacts to Bob's note with "+" + And Alice subscribes to her reaction events + Then Alice receives a reaction event with content "+" + + Scenario: Alice dislikes Bob's note + Given someone called Alice + And someone called Bob + When Bob sends a text_note event with content "hello world" + And Alice reacts to Bob's note with "-" + And Alice subscribes to her reaction events + Then Alice receives a reaction event with content "-" + + Scenario: Alice reacts with an emoji + Given someone called Alice + And someone called Bob + When Bob sends a text_note event with content "hello world" + And Alice reacts to Bob's note with "🤙" + And Alice subscribes to her reaction events + Then Alice receives a reaction event with content "🤙" \ No newline at end of file diff --git a/test/integration/features/nip-25/nip-25.feature.ts b/test/integration/features/nip-25/nip-25.feature.ts new file mode 100644 index 00000000..475e215a --- /dev/null +++ b/test/integration/features/nip-25/nip-25.feature.ts @@ -0,0 +1,49 @@ +import { Then, When, World } from '@cucumber/cucumber' +import { expect } from 'chai' +import WebSocket from 'ws' +import { Event } from '../../../../src/@types/event' +import { EventKinds } from '../../../../src/constants/base' +import { createEvent, createSubscription, sendEvent, waitForNextEvent } from '../helpers' + +When(/^(\w+) reacts to (\w+)'s note with "([^"]+)"$/, async function (reactor: string, author: string, content: string) { + const ws = this.parameters.clients[reactor] as WebSocket + const { pubkey, privkey } = this.parameters.identities[reactor] + const targetEvent = this.parameters.events[author][this.parameters.events[author].length - 1] as Event + + const event: Event = await createEvent( + { + pubkey, + kind: EventKinds.REACTION, + content, + tags: [ + ['e', targetEvent.id], + ['p', targetEvent.pubkey], + ], + }, + privkey, + ) + + await sendEvent(ws, event) + this.parameters.events[reactor].push(event) +}) + +When(/^(\w+) subscribes to (?:her|his|their) reaction events$/, async function (this: World>, name: string) { + const ws = this.parameters.clients[name] as WebSocket + const { pubkey } = this.parameters.identities[name] + const subscription = { + name: `test-${Math.random()}`, + filters: [{ kinds: [EventKinds.REACTION], authors: [pubkey] }], + } + this.parameters.subscriptions[name].push(subscription) + + await createSubscription(ws, subscription.name, subscription.filters) +}) + +Then(/^(\w+) receives a reaction event with content "([^"]+)"$/, async function (name: string, content: string) { + const ws = this.parameters.clients[name] as WebSocket + const subscription = this.parameters.subscriptions[name][this.parameters.subscriptions[name].length - 1] + const receivedEvent = await waitForNextEvent(ws, subscription.name) + + expect(receivedEvent.kind).to.equal(EventKinds.REACTION) + expect(receivedEvent.content).to.equal(content) +}) \ No newline at end of file diff --git a/test/integration/features/nip-65/nip-65.feature b/test/integration/features/nip-65/nip-65.feature new file mode 100644 index 00000000..14b45615 --- /dev/null +++ b/test/integration/features/nip-65/nip-65.feature @@ -0,0 +1,27 @@ +Feature: NIP-65 Relay List Metadata + Scenario: Alice publishes a relay list and retrieves it + Given someone called Alice + When Alice sends a relay_list event with relays "wss://alice.relay.com" + And Alice subscribes to her relay_list events + Then Alice receives a relay_list event with relays "wss://alice.relay.com" + + Scenario: Alice updates her relay list and only the latest is kept + Given someone called Alice + When Alice sends a relay_list event with relays "wss://old.relay.com" + And Alice sends a relay_list event with relays "wss://new.relay.com" + And Alice subscribes to her relay_list events + Then Alice receives 1 relay_list event and EOSE + And the relay_list event has relays "wss://new.relay.com" + + Scenario: Bob can query Alice's relay list + Given someone called Alice + And someone called Bob + When Alice sends a relay_list event with relays "wss://alice.relay.com" + And Bob subscribes to author Alice + Then Bob receives a relay_list event with relays "wss://alice.relay.com" + + Scenario: Alice publishes a relay list with read and write markers + Given someone called Alice + When Alice sends a relay_list event with a read relay "wss://read.relay.com" and a write relay "wss://write.relay.com" + And Alice subscribes to her relay_list events + Then Alice receives a relay_list event with a read relay "wss://read.relay.com" and a write relay "wss://write.relay.com" diff --git a/test/integration/features/nip-65/nip-65.feature.ts b/test/integration/features/nip-65/nip-65.feature.ts new file mode 100644 index 00000000..7680cff6 --- /dev/null +++ b/test/integration/features/nip-65/nip-65.feature.ts @@ -0,0 +1,104 @@ +import { Then, When, World } from '@cucumber/cucumber' +import { expect } from 'chai' +import WebSocket from 'ws' +import { Event } from '../../../../src/@types/event' +import { EventKinds } from '../../../../src/constants/base' +import { createEvent, createSubscription, sendEvent, waitForEventCount, waitForNextEvent } from '../helpers' + +When(/^(\w+) sends a relay_list event with relays "([^"]+)"$/, async function (name: string, relayUrl: string) { + const ws = this.parameters.clients[name] as WebSocket + const { pubkey, privkey } = this.parameters.identities[name] + + const event: Event = await createEvent( + { + pubkey, + kind: EventKinds.RELAY_LIST, + content: '', + tags: [['r', relayUrl]], + }, + privkey, + ) + + await sendEvent(ws, event) + this.parameters.events[name].push(event) +}) + +When( + /^(\w+) sends a relay_list event with a read relay "([^"]+)" and a write relay "([^"]+)"$/, + async function (name: string, readRelay: string, writeRelay: string) { + const ws = this.parameters.clients[name] as WebSocket + const { pubkey, privkey } = this.parameters.identities[name] + + const event: Event = await createEvent( + { + pubkey, + kind: EventKinds.RELAY_LIST, + content: '', + tags: [ + ['r', readRelay, 'read'], + ['r', writeRelay, 'write'], + ], + }, + privkey, + ) + + await sendEvent(ws, event) + this.parameters.events[name].push(event) + }, +) + +When( + /^(\w+) subscribes to (?:her|his|their) relay_list events$/, + async function (this: World>, name: string) { + const ws = this.parameters.clients[name] as WebSocket + const { pubkey } = this.parameters.identities[name] + const subscription = { + name: `test-${Math.random()}`, + filters: [{ kinds: [EventKinds.RELAY_LIST], authors: [pubkey] }], + } + this.parameters.subscriptions[name].push(subscription) + + await createSubscription(ws, subscription.name, subscription.filters) + }, +) + +Then(/^(\w+) receives a relay_list event with relays "([^"]+)"$/, async function (name: string, relayUrl: string) { + const ws = this.parameters.clients[name] as WebSocket + const subscription = this.parameters.subscriptions[name][this.parameters.subscriptions[name].length - 1] + const receivedEvent = await waitForNextEvent(ws, subscription.name) + + expect(receivedEvent.kind).to.equal(EventKinds.RELAY_LIST) + expect(receivedEvent.tags).to.deep.include(['r', relayUrl]) +}) + +Then( + /^(\w+) receives a relay_list event with a read relay "([^"]+)" and a write relay "([^"]+)"$/, + async function (name: string, readRelay: string, writeRelay: string) { + const ws = this.parameters.clients[name] as WebSocket + const subscription = this.parameters.subscriptions[name][this.parameters.subscriptions[name].length - 1] + const receivedEvent = await waitForNextEvent(ws, subscription.name) + + expect(receivedEvent.kind).to.equal(EventKinds.RELAY_LIST) + expect(receivedEvent.tags).to.deep.include(['r', readRelay, 'read']) + expect(receivedEvent.tags).to.deep.include(['r', writeRelay, 'write']) + }, +) + +Then(/^(\w+) receives (\d+) relay_list event(?:s)? and EOSE$/, async function (name: string, count: string) { + const ws = this.parameters.clients[name] as WebSocket + const subscription = this.parameters.subscriptions[name][this.parameters.subscriptions[name].length - 1] + const events = await waitForEventCount(ws, subscription.name, Number(count), true) + + expect(events.length).to.equal(Number(count)) + expect(events[0].kind).to.equal(EventKinds.RELAY_LIST) + + this.parameters.lastRelayListEvents = events +}) + +Then( + /^the relay_list event has relays "([^"]+)"$/, + async function (this: World>, relayUrl: string) { + const events: Event[] = this.parameters.lastRelayListEvents + expect(events[0].tags).to.deep.include(['r', relayUrl]) + }, +) diff --git a/test/integration/features/response-types/response-types.feature b/test/integration/features/response-types/response-types.feature new file mode 100644 index 00000000..30476579 --- /dev/null +++ b/test/integration/features/response-types/response-types.feature @@ -0,0 +1,37 @@ +@response-types +Feature: HTTP response types + Scenario Outline: GET path returns expected response Content-Type + When a client requests path "" with Accept header "" + Then the HTTP response status is + And the HTTP response Content-Type includes "" + + Examples: + | path | acceptHeader | statusCode | contentType | + | / | application/nostr+json | 200 | application/nostr+json | + | / | text/html | 200 | text/html | + | /healthz | */* | 200 | text/plain | + | /terms | */* | 200 | text/html | + | /.well-known/nodeinfo | */* | 200 | application/json | + | /nodeinfo/2.1 | */* | 200 | application/json | + | /nodeinfo/2.0 | */* | 200 | application/json | + + Scenario Outline: dynamic GET path returns expected response Content-Type + When a client requests dynamic path "" + Then the HTTP response status is + And the HTTP response Content-Type includes "" + + Examples: + | path | statusCode | contentType | + | /admissions/check/0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef | 200 | application/json | + | /invoices/non-existent-invoice/status | 404 | application/json | + + Scenario Outline: POST path returns expected response Content-Type + Given payments are enabled with processor "" + When a client posts "" to path "" with Content-Type "" + Then the HTTP response status is + And the HTTP response Content-Type includes "" + + Examples: + | path | processor | contentTypeHeader | body | statusCode | contentType | + | /invoices | lnurl | application/x-www-form-urlencoded | | 400 | text/plain | + | /callbacks/lnbits | lnbits | application/json | {} | 403 | text/html | diff --git a/test/integration/features/response-types/response-types.feature.ts b/test/integration/features/response-types/response-types.feature.ts new file mode 100644 index 00000000..a973578e --- /dev/null +++ b/test/integration/features/response-types/response-types.feature.ts @@ -0,0 +1,112 @@ +import { After, Given, Then, When, World } from '@cucumber/cucumber' +import { expect } from 'chai' +import axios, { AxiosResponse } from 'axios' +import { assocPath, pipe } from 'ramda' +import { SettingsStatic } from '../../../../src/utils/settings' + +const BASE_URL = 'http://localhost:18808' + +Given( + 'payments are enabled with processor {string}', + function (this: World>, processor: string) { + const settings = SettingsStatic._settings as any + if (!this.parameters.previousResponseTypesSettings) { + this.parameters.previousResponseTypesSettings = structuredClone(settings) + } + + const baseSettings = pipe( + assocPath(['payments', 'enabled'], true), + assocPath(['payments', 'processor'], processor), + )(settings) as any + + if (processor === 'zebedee') { + SettingsStatic._settings = assocPath(['paymentsProcessors', 'zebedee', 'ipWhitelist'], [], baseSettings) as any + return + } + + if (processor === 'lnbits') { + this.parameters.lnbitsApiKeyModified = true + if (typeof this.parameters.previousLnbitsApiKey === 'undefined') { + this.parameters.previousLnbitsApiKey = process.env.LNBITS_API_KEY + } + process.env.LNBITS_API_KEY = 'integration-lnbits-api-key' + + SettingsStatic._settings = assocPath( + ['paymentsProcessors', 'lnbits', 'callbackBaseURL'], + 'http://localhost:18808/callbacks/lnbits', + baseSettings, + ) as any + return + } + + SettingsStatic._settings = baseSettings + }, +) + +When( + 'a client requests path {string} with Accept header {string}', + async function (this: World>, requestPath: string, acceptHeader: string) { + const response: AxiosResponse = await axios.get(`${BASE_URL}${requestPath}`, { + headers: { Accept: acceptHeader }, + validateStatus: () => true, + }) + + this.parameters.httpResponse = response + }, +) + +When('a client requests dynamic path {string}', async function (this: World>, requestPath: string) { + const response: AxiosResponse = await axios.get(`${BASE_URL}${requestPath}`, { + validateStatus: () => true, + }) + + this.parameters.httpResponse = response +}) + +When( + 'a client posts {string} to path {string} with Content-Type {string}', + async function ( + this: World>, + body: string, + requestPath: string, + contentTypeHeader: string, + ) { + const response: AxiosResponse = await axios.post(`${BASE_URL}${requestPath}`, body, { + headers: { 'content-type': contentTypeHeader }, + validateStatus: () => true, + }) + + this.parameters.httpResponse = response + }, +) + +Then('the HTTP response status is {int}', function (this: World>, statusCode: number) { + expect(this.parameters.httpResponse.status).to.equal(statusCode) +}) + +Then( + 'the HTTP response Content-Type includes {string}', + function (this: World>, contentType: string) { + const contentTypeHeader = this.parameters.httpResponse.headers['content-type'] + const headerValue = Array.isArray(contentTypeHeader) ? contentTypeHeader.join(';') : contentTypeHeader + const normalizedHeader = typeof headerValue === 'string' ? headerValue.toLowerCase() : '' + expect(normalizedHeader).to.include(contentType.toLowerCase()) + }, +) + +After({ tags: '@response-types' }, function (this: World>) { + if (this.parameters.previousResponseTypesSettings) { + SettingsStatic._settings = this.parameters.previousResponseTypesSettings + this.parameters.previousResponseTypesSettings = undefined + } + + if (this.parameters.lnbitsApiKeyModified) { + if (typeof this.parameters.previousLnbitsApiKey === 'undefined') { + delete process.env.LNBITS_API_KEY + } else { + process.env.LNBITS_API_KEY = this.parameters.previousLnbitsApiKey + } + this.parameters.previousLnbitsApiKey = undefined + this.parameters.lnbitsApiKeyModified = false + } +}) diff --git a/test/performance/connection-limiting-k6.ts b/test/performance/connection-limiting-k6.ts new file mode 100644 index 00000000..2ee7272c --- /dev/null +++ b/test/performance/connection-limiting-k6.ts @@ -0,0 +1,83 @@ +import { check, sleep } from 'k6'; +import { Counter } from 'k6/metrics'; +import ws from 'k6/ws'; + +const relayUrl = __ENV.RELAY_URL || 'ws://127.0.0.1:8008'; +const connectionSuccess = new Counter('connection_success'); +const connectionRateLimited = new Counter('connection_rate_limited'); + +export const options = { + stages: [ + { duration: '10s', target: 3 }, + { duration: '10s', target: 6 }, + { duration: '10s', target: 12 }, + { duration: '10s', target: 18 }, + { duration: '5s', target: 0 }, + ], + thresholds: { + 'ws_connecting': ['p(95)<2000'], + }, +}; + +export default function () { + + const res = ws.connect(relayUrl, {}, function (socket) { + let intentionalClose = false + socket.on('close', () => { + if(!intentionalClose) { + connectionRateLimited.add(1); + } + }); + + socket.on('open', () => { + connectionSuccess.add(1); + }); + + socket.setTimeout(() => { + intentionalClose = true; + socket.close(); + }, 3000); + }); + + check(res, { + 'status is 101': (r) => r && r.status === 101, + }); + + sleep(0.5); +} + +export function handleSummary(data: any) { + const connSuccess = data.metrics?.connection_success?.values?.count || 0; + const connRateLimited = data.metrics?.connection_rate_limited?.values?.count || 0; + const iterations = data.metrics?.iterations?.values?.count || 0; + const checks = data.metrics?.checks?.values?.passes || 0; + const wsSessions = data.metrics?.ws_sessions?.values?.count || 0; + + const totalConnections = connSuccess + connRateLimited; + const successRate = totalConnections > 0 ? ((connSuccess / totalConnections) * 100).toFixed(2) : 0; + const rate = parseFloat(successRate as string); + const successStatus = rate >= 80 ? '✓ GOOD' : rate >= 50 ? '⚠ MODERATE' : '✗ POOR'; + + console.log(` + ╔════════════════════════════════════════════════════════════════╗ + ║ CONNECTION RATE LIMITER TEST RESULTS ║ + ╚════════════════════════════════════════════════════════════════╝ + + EXECUTION: + Iterations: ${iterations} + WebSocket Sessions: ${wsSessions} + Checks Passed: ${checks} + + CONNECTIONS: + ✓ Success (stayed open): ${connSuccess} + ✗ Rate Limited (closed): ${connRateLimited} + ───────────────────── + Total: ${totalConnections} + + PERFORMANCE: + Success Rate: ${successStatus} ${successRate}% + + ═══════════════════════════════════════════════════════════════════ + `); + return {}; +} \ No newline at end of file diff --git a/test/performance/message-limiting-k6.ts b/test/performance/message-limiting-k6.ts new file mode 100644 index 00000000..64ca8c4f --- /dev/null +++ b/test/performance/message-limiting-k6.ts @@ -0,0 +1,106 @@ +import { check } from 'k6'; +import { Counter } from 'k6/metrics'; +import ws from 'k6/ws'; + +const relayUrl = __ENV.RELAY_URL || 'ws://127.0.0.1:8008'; +const noticeCounter = new Counter('notice_messages'); +const eoseCounter = new Counter('eose_messages'); +const eventCounter = new Counter('event_messages'); +const errorCounter = new Counter('error_messages'); + +export const options = { + stages: [ + { duration: '10s', target: 1 }, + { duration: '10s', target: 2 }, + { duration: '10s', target: 4 }, + { duration: '5s', target: 0 }, + ], +}; + +export default function () { + const res = ws.connect(relayUrl, {}, function (socket) { + socket.on('open', function () { + let msgCount = 0; + socket.setInterval(function () { + msgCount++; + const text = JSON.stringify(['REQ', `sub-${Date.now()}-${msgCount}`, {limit: 10}]); + socket.send(text); + }, 1000); + }); + + socket.on('message', function (data) { + try { + const parsed = JSON.parse(data); + const msgType = parsed[0]; + + if (msgType === 'NOTICE') { + noticeCounter.add(1); + } else if (msgType === 'EOSE') { + eoseCounter.add(1); + } else if (msgType === 'EVENT') { + eventCounter.add(1); + } + } catch (e: any) { + errorCounter.add(1); + console.error('Failed to parse message:', e.message); + } + }); + + socket.setTimeout(function () { + socket.close(); + }, 9000); + }); + + check(res, { + 'status 101': (r) => r && r.status === 101, + }); +} + +export function handleSummary(data: any) { + const notices = data.metrics?.notice_messages?.values?.count || 0; + const eoses = data.metrics?.eose_messages?.values?.count || 0; + const events = data.metrics?.event_messages?.values?.count || 0; + const iterations = data.metrics?.iterations?.values?.count || 0; + const wsSessions = data.metrics?.ws_sessions?.values?.count || 0; + const msgsSent = data.metrics?.ws_msgs_sent?.values?.count || 0; + const msgsReceived = data.metrics?.ws_msgs_received?.values?.count || 0; + const dataReceived = data.metrics?.data_received?.values?.count || 0; + const checks = data.metrics?.checks?.values?.passes || 0; + + const totalMessages = notices + eoses + events; + const successRate = totalMessages > 0 ? ((eoses + events) / totalMessages * 100).toFixed(2) : 0; + + const rate = parseFloat(successRate as string); + const successStatus = rate >= 80 ? '✓ GOOD' : rate >= 50 ? '⚠ MODERATE' : '✗ POOR'; + const rateLimitStatus = notices > 0 ? '⚠ ACTIVE' : '✓ INACTIVE'; + + console.log(` +╔════════════════════════════════════════════════════════════════╗ +║ MESSAGE RATE LIMITER TEST RESULTS ║ +╚════════════════════════════════════════════════════════════════╝ + +EXECUTION: + Iterations: ${iterations} + WebSocket Sessions: ${wsSessions} + Checks Passed: ${checks} + +MESSAGES: + Sent: ${msgsSent} + Received: ${msgsReceived} + +MESSAGE TYPES: + ✗ NOTICE (rate limited): ${notices} + ✓ EOSE (query complete): ${eoses} + ◆ EVENT (results): ${events} + ───────────────────── + Total: ${totalMessages} + +PERFORMANCE: + Success Rate: ${successStatus} ${successRate}% + Data Received: ${dataReceived} bytes + Rate Limiter: ${rateLimitStatus} + +═══════════════════════════════════════════════════════════════════ + `); + return {}; +} \ No newline at end of file diff --git a/test/unit/cache/client.spec.ts b/test/unit/cache/client.spec.ts new file mode 100644 index 00000000..17564c1a --- /dev/null +++ b/test/unit/cache/client.spec.ts @@ -0,0 +1,72 @@ +import { expect } from 'chai' + +import { getCacheConfig } from '../../../src/cache/client' + +describe('getCacheConfig', () => { + const originalEnv = process.env + + beforeEach(() => { + process.env = { ...originalEnv } + delete process.env.REDIS_URI + delete process.env.REDIS_USER + delete process.env.REDIS_PASSWORD + delete process.env.REDIS_HOST + delete process.env.REDIS_PORT + }) + + afterEach(() => { + process.env = originalEnv + }) + + it('builds unauthenticated redis url when REDIS_URI and REDIS_PASSWORD are unset', () => { + process.env.REDIS_HOST = 'localhost' + process.env.REDIS_PORT = '6379' + + const config = getCacheConfig() + + expect(config).to.deep.equal({ + url: 'redis://localhost:6379', + }) + }) + + it('builds authenticated redis config when REDIS_PASSWORD is set', () => { + process.env.REDIS_HOST = 'localhost' + process.env.REDIS_PORT = '6379' + process.env.REDIS_USER = 'default' + process.env.REDIS_PASSWORD = 'secret' + + const config = getCacheConfig() + + expect(config).to.deep.equal({ + url: 'redis://localhost:6379', + username: 'default', + password: 'secret', + }) + }) + + it('defaults REDIS_USER to default when REDIS_PASSWORD is set and REDIS_USER is unset', () => { + process.env.REDIS_HOST = 'localhost' + process.env.REDIS_PORT = '6379' + process.env.REDIS_PASSWORD = 'secret' + + const config = getCacheConfig() + + expect(config).to.deep.equal({ + url: 'redis://localhost:6379', + username: 'default', + password: 'secret', + }) + }) + + it('prefers REDIS_URI over host/port settings', () => { + process.env.REDIS_URI = 'redis://cache.internal:6380' + process.env.REDIS_PASSWORD = 'secret' + + const config = getCacheConfig() + + expect(config).to.deep.equal({ + url: 'redis://cache.internal:6380', + password: 'secret', + }) + }) +}) \ No newline at end of file diff --git a/test/unit/cli/run-command.spec.ts b/test/unit/cli/run-command.spec.ts new file mode 100644 index 00000000..98e6d190 --- /dev/null +++ b/test/unit/cli/run-command.spec.ts @@ -0,0 +1,44 @@ +const { expect } = require('chai') + +const { runCommandWithOutput } = require('../../../dist/src/cli/utils/process.js') + +describe('runCommandWithOutput', () => { + it('resolves ok:true with captured stdout, stderr and exit code 0', async () => { + const result = await runCommandWithOutput('sh', ['-c', 'echo out; echo err >&2']) + + expect(result).to.deep.equal({ ok: true, code: 0, stdout: 'out\n', stderr: 'err\n' }) + }) + + it('resolves ok:true with non-zero exit code', async () => { + const result = await runCommandWithOutput('sh', ['-c', 'exit 2']) + + expect(result.ok).to.equal(true) + expect(result.code).to.equal(2) + }) + + it('resolves ok:false reason:not-found when command does not exist (ENOENT)', async () => { + const result = await runCommandWithOutput('__nostream_nonexistent_cmd__', []) + + expect(result).to.deep.equal({ ok: false, reason: 'not-found', stdout: '', stderr: '' }) + }) + + it('resolves ok:false reason:timeout when the process exceeds timeoutMs', async () => { + const result = await runCommandWithOutput('sleep', ['10'], { timeoutMs: 100 }) + + expect(result).to.deep.equal({ ok: false, reason: 'timeout', stdout: '', stderr: '' }) + }) + + it('resolves ok:false reason:signal when the process is killed by a signal', async () => { + const result = await runCommandWithOutput('sh', ['-c', 'kill -9 $$']) + + expect(result.ok).to.equal(false) + expect(result.reason).to.equal('signal') + }) + + it('does not double-settle when ENOENT fires both error and close', async () => { + const result = await runCommandWithOutput('__nostream_nonexistent_cmd__', []) + + expect(result.ok).to.equal(false) + expect(result.reason).to.equal('not-found') + }) +}) diff --git a/test/unit/factories/maintenance-service-factory.spec.ts b/test/unit/factories/maintenance-service-factory.spec.ts new file mode 100644 index 00000000..dd66cfcf --- /dev/null +++ b/test/unit/factories/maintenance-service-factory.spec.ts @@ -0,0 +1,10 @@ +import { expect } from 'chai' + +import { MaintenanceService } from '../../../src/services/maintenance-service' +import { createMaintenanceService } from '../../../src/factories/maintenance-service-factory' + +describe('createMaintenanceService', () => { + it('returns a MaintenanceService', () => { + expect(createMaintenanceService()).to.be.an.instanceOf(MaintenanceService) + }) +}) diff --git a/test/unit/factories/maintenance-worker-factory.spec.ts b/test/unit/factories/maintenance-worker-factory.spec.ts new file mode 100644 index 00000000..7517d151 --- /dev/null +++ b/test/unit/factories/maintenance-worker-factory.spec.ts @@ -0,0 +1,10 @@ +import { expect } from 'chai' + +import { MaintenanceWorker } from '../../../src/app/maintenance-worker' +import { maintenanceWorkerFactory } from '../../../src/factories/maintenance-worker-factory' + +describe('maintenanceWorkerFactory', () => { + it('returns a MaintenanceWorker', () => { + expect(maintenanceWorkerFactory()).to.be.an.instanceOf(MaintenanceWorker) + }) +}) diff --git a/test/unit/handlers/auth-message-handler.spec.ts b/test/unit/handlers/auth-message-handler.spec.ts new file mode 100644 index 00000000..66edd38b --- /dev/null +++ b/test/unit/handlers/auth-message-handler.spec.ts @@ -0,0 +1,233 @@ +import { expect } from 'chai' +import Sinon from 'sinon' +import sinonChai from 'sinon-chai' +import chai from 'chai' + +chai.use(sinonChai) + +import { AuthMessage, MessageType } from '../../../src/@types/messages' +import { AuthMessageHandler } from '../../../src/handlers/auth-message-handler' +import { Tag } from '../../../src/@types/base' +import { EventKinds, EventTags } from '../../../src/constants/base' +import { getPublicKey, identifyEvent, signEvent } from '../../../src/utils/event' +import { IMessageHandler } from '../../../src/@types/message-handlers' +import { IWebSocketAdapter } from '../../../src/@types/adapters' +import { Settings } from '../../../src/@types/settings' +import { WebSocketAdapterEvent } from '../../../src/constants/adapter' + +describe('AuthMessageHandler', () => { + let handler: IMessageHandler + let webSocket: IWebSocketAdapter + let emitStub: Sinon.SinonStub + let settingsFactory: Sinon.SinonStub + + const challenge = 'test-challenge-string-abc123' + const relayUrl = 'wss://relay.example.com' + const privkey = 'a'.repeat(64) + const pubkey = getPublicKey(privkey) + + async function createAuthEvent(overrides: { + kind?: number + challenge?: string + relayUrl?: string + created_at?: number + invalidId?: boolean + invalidSig?: boolean + } = {}): Promise { + const kind = overrides.kind ?? EventKinds.AUTH + const now = overrides.created_at ?? Math.floor(Date.now() / 1000) + const tags = [ + [EventTags.AuthRelay, overrides.relayUrl ?? relayUrl], + [EventTags.Challenge, overrides.challenge ?? challenge], + ] as Tag[] + + const identified = await identifyEvent({ + pubkey, + created_at: now, + kind, + tags, + content: '', + }) + + if (overrides.invalidId) { + identified.id = 'f'.repeat(64) + } + + const signed = overrides.invalidSig + ? { ...identified, sig: '0'.repeat(128) } + : await signEvent(privkey)(identified) + + return [ + MessageType.AUTH, + { + id: signed.id, + pubkey, + created_at: now, + kind, + tags, + content: '', + sig: signed.sig, + }, + ] as AuthMessage + } + + beforeEach(() => { + emitStub = Sinon.stub() + webSocket = { + emit: emitStub, + getClientId: Sinon.stub().returns('test-client-id'), + getClientAddress: Sinon.stub().returns('127.0.0.1'), + getSubscriptions: Sinon.stub().returns(new Map()), + getChallenge: Sinon.stub().returns(challenge), + getAuthenticatedPubkeys: Sinon.stub().returns(new Set()), + addAuthenticatedPubkey: Sinon.stub(), + } as any as IWebSocketAdapter + + settingsFactory = Sinon.stub().returns({ + info: { relay_url: relayUrl }, + } as Partial) + + handler = new AuthMessageHandler(webSocket, settingsFactory) + }) + + afterEach(() => { + Sinon.restore() + }) + + describe('handleMessage()', () => { + it('authenticates successfully with a valid AUTH event', async () => { + const message = await createAuthEvent() + + await handler.handleMessage(message) + + expect((webSocket.addAuthenticatedPubkey as Sinon.SinonStub)).to.have.been.calledOnceWithExactly(pubkey) + expect(emitStub).to.have.been.calledOnce + const args = emitStub.firstCall.args + expect(args[0]).to.equal(WebSocketAdapterEvent.Message) + expect(args[1][0]).to.equal('OK') + expect(args[1][2]).to.equal(true) + }) + + it('rejects when kind is not 22242', async () => { + const message = await createAuthEvent({ kind: 1 }) + + await handler.handleMessage(message) + + expect((webSocket.addAuthenticatedPubkey as Sinon.SinonStub)).not.to.have.been.called + expect(emitStub).to.have.been.calledOnce + const args = emitStub.firstCall.args + expect(args[1][2]).to.equal(false) + expect(args[1][3]).to.include('kind 22242') + }) + + it('rejects when event ID does not match', async () => { + const message = await createAuthEvent({ invalidId: true }) + + await handler.handleMessage(message) + + expect((webSocket.addAuthenticatedPubkey as Sinon.SinonStub)).not.to.have.been.called + expect(emitStub).to.have.been.calledOnce + const args = emitStub.firstCall.args + expect(args[1][2]).to.equal(false) + expect(args[1][3]).to.include('event id does not match') + }) + + it('rejects when signature is invalid', async () => { + const message = await createAuthEvent({ invalidSig: true }) + + await handler.handleMessage(message) + + expect((webSocket.addAuthenticatedPubkey as Sinon.SinonStub)).not.to.have.been.called + expect(emitStub).to.have.been.calledOnce + const args = emitStub.firstCall.args + expect(args[1][2]).to.equal(false) + expect(args[1][3]).to.include('signature verification failed') + }) + + it('rejects when created_at is too far in the past', async () => { + const tooOld = Math.floor(Date.now() / 1000) - 700 + const message = await createAuthEvent({ created_at: tooOld }) + + await handler.handleMessage(message) + + expect((webSocket.addAuthenticatedPubkey as Sinon.SinonStub)).not.to.have.been.called + expect(emitStub).to.have.been.calledOnce + const args = emitStub.firstCall.args + expect(args[1][2]).to.equal(false) + expect(args[1][3]).to.include('created_at is too far') + }) + + it('rejects when created_at is too far in the future', async () => { + const tooNew = Math.floor(Date.now() / 1000) + 700 + const message = await createAuthEvent({ created_at: tooNew }) + + await handler.handleMessage(message) + + expect((webSocket.addAuthenticatedPubkey as Sinon.SinonStub)).not.to.have.been.called + expect(emitStub).to.have.been.calledOnce + const args = emitStub.firstCall.args + expect(args[1][2]).to.equal(false) + expect(args[1][3]).to.include('created_at is too far') + }) + + it('rejects when challenge tag does not match', async () => { + const message = await createAuthEvent({ challenge: 'wrong-challenge' }) + + await handler.handleMessage(message) + + expect((webSocket.addAuthenticatedPubkey as Sinon.SinonStub)).not.to.have.been.called + expect(emitStub).to.have.been.calledOnce + const args = emitStub.firstCall.args + expect(args[1][2]).to.equal(false) + expect(args[1][3]).to.include('challenge does not match') + }) + + it('rejects when relay tag does not match', async () => { + const message = await createAuthEvent({ relayUrl: 'wss://wrong-relay.example.com' }) + + await handler.handleMessage(message) + + expect((webSocket.addAuthenticatedPubkey as Sinon.SinonStub)).not.to.have.been.called + expect(emitStub).to.have.been.calledOnce + const args = emitStub.firstCall.args + expect(args[1][2]).to.equal(false) + expect(args[1][3]).to.include('relay url does not match') + }) + + it('rejects when relay tag has invalid URL', async () => { + const message = await createAuthEvent({ relayUrl: 'not-a-url' }) + + await handler.handleMessage(message) + + expect((webSocket.addAuthenticatedPubkey as Sinon.SinonStub)).not.to.have.been.called + expect(emitStub).to.have.been.calledOnce + const args = emitStub.firstCall.args + expect(args[1][2]).to.equal(false) + expect(args[1][3]).to.include('relay url does not match') + }) + + it('accepts relay URL with different path but same domain', async () => { + const message = await createAuthEvent({ relayUrl: 'wss://relay.example.com/v1' }) + + await handler.handleMessage(message) + + expect((webSocket.addAuthenticatedPubkey as Sinon.SinonStub)).to.have.been.calledOnceWithExactly(pubkey) + const args = emitStub.firstCall.args + expect(args[1][2]).to.equal(true) + }) + + it('accepts timestamp exactly at the 10-minute boundary', async () => { + const clock = Sinon.useFakeTimers(Date.now()) + try { + const exactBoundary = Math.floor(Date.now() / 1000) - 600 + const message = await createAuthEvent({ created_at: exactBoundary }) + + await handler.handleMessage(message) + + expect((webSocket.addAuthenticatedPubkey as Sinon.SinonStub)).to.have.been.calledOnceWithExactly(pubkey) + } finally { + clock.restore() + } + }) + }) +}) diff --git a/test/unit/handlers/event-strategies/group-event-strategy.spec.ts b/test/unit/handlers/event-strategies/group-event-strategy.spec.ts new file mode 100644 index 00000000..b89647ff --- /dev/null +++ b/test/unit/handlers/event-strategies/group-event-strategy.spec.ts @@ -0,0 +1,232 @@ +import chai from 'chai' +import chaiAsPromised from 'chai-as-promised' +import Sinon from 'sinon' +import sinonChai from 'sinon-chai' + +chai.use(sinonChai) +chai.use(chaiAsPromised) + +import { DatabaseClient } from '../../../../src/@types/base' +import { Event } from '../../../../src/@types/event' +import { EventKinds } from '../../../../src/constants/base' +import { EventRepository } from '../../../../src/repositories/event-repository' +import { GroupEventStrategy } from '../../../../src/handlers/event-strategies/group-event-strategy' +import { IEventRepository } from '../../../../src/@types/repositories' +import { IEventStrategy } from '../../../../src/@types/message-handlers' +import { IWebSocketAdapter } from '../../../../src/@types/adapters' +import { MessageType } from '../../../../src/@types/messages' +import { WebSocketAdapterEvent } from '../../../../src/constants/adapter' + +const { expect } = chai + +const VALID_GROUP_ID = 'a'.repeat(64) + +describe('GroupEventStrategy', () => { + let event: Event + let webSocket: IWebSocketAdapter + let eventRepository: IEventRepository + let webSocketEmitStub: Sinon.SinonStub + let eventRepositoryCreateStub: Sinon.SinonStub + let strategy: IEventStrategy> + let sandbox: Sinon.SinonSandbox + + beforeEach(() => { + sandbox = Sinon.createSandbox() + + eventRepositoryCreateStub = sandbox.stub(EventRepository.prototype, 'create') + + webSocketEmitStub = sandbox.stub() + webSocket = { emit: webSocketEmitStub } as any + + const masterClient: DatabaseClient = {} as any + const readReplicaClient: DatabaseClient = {} as any + eventRepository = new EventRepository(masterClient, readReplicaClient) + + event = { + id: 'group-event-id', + pubkey: 'b'.repeat(64), // ephemeral per MIP-03 + created_at: 1700000000, + kind: EventKinds.MARMOT_GROUP_EVENT, + tags: [['h', VALID_GROUP_ID]], + content: 'base64encodedencryptedcontent', + sig: 'c'.repeat(128), + } as any + + strategy = new GroupEventStrategy(webSocket, eventRepository) + }) + + afterEach(() => { + sandbox.restore() + }) + + describe('execute', () => { + describe('valid group event', () => { + it('creates the event in the repository', async () => { + eventRepositoryCreateStub.resolves(1) + + await strategy.execute(event) + + expect(eventRepositoryCreateStub).to.have.been.calledOnceWithExactly(event) + }) + + it('sends OK and broadcasts when the event is new', async () => { + eventRepositoryCreateStub.resolves(1) + + await strategy.execute(event) + + expect(webSocketEmitStub).to.have.been.calledTwice + expect(webSocketEmitStub).to.have.been.calledWithExactly(WebSocketAdapterEvent.Message, [ + MessageType.OK, + event.id, + true, + '', + ]) + expect(webSocketEmitStub).to.have.been.calledWithExactly(WebSocketAdapterEvent.Broadcast, event) + }) + + it('sends OK with duplicate marker and does not broadcast when event already exists', async () => { + eventRepositoryCreateStub.resolves(0) + + await strategy.execute(event) + + expect(webSocketEmitStub).to.have.been.calledOnceWithExactly(WebSocketAdapterEvent.Message, [ + MessageType.OK, + event.id, + true, + 'duplicate:', + ]) + }) + + it('accepts an optional expiration tag alongside the h tag', async () => { + event.tags = [['h', VALID_GROUP_ID], ['expiration', '9999999999']] + eventRepositoryCreateStub.resolves(1) + + await strategy.execute(event) + + expect(eventRepositoryCreateStub).to.have.been.calledOnce + expect(webSocketEmitStub).to.have.been.calledWithExactly(WebSocketAdapterEvent.Message, [ + MessageType.OK, + event.id, + true, + '', + ]) + }) + }) + + describe('invalid group event — h tag missing', () => { + it('rejects when the h tag is absent', async () => { + event.tags = [] + + await strategy.execute(event) + + expect(eventRepositoryCreateStub).not.to.have.been.called + expect(webSocketEmitStub).to.have.been.calledOnceWithExactly(WebSocketAdapterEvent.Message, [ + MessageType.OK, + event.id, + false, + Sinon.match(/invalid:.*h tag/), + ]) + }) + + it('rejects when the only tag is not an h tag', async () => { + event.tags = [['p', 'a'.repeat(64)]] + + await strategy.execute(event) + + expect(eventRepositoryCreateStub).not.to.have.been.called + expect(webSocketEmitStub).to.have.been.calledOnceWithExactly(WebSocketAdapterEvent.Message, [ + MessageType.OK, + event.id, + false, + Sinon.match(/invalid:.*h tag/), + ]) + }) + }) + + describe('invalid group event — multiple h tags', () => { + it('rejects when there are two h tags', async () => { + event.tags = [ + ['h', VALID_GROUP_ID], + ['h', 'b'.repeat(64)], + ] + + await strategy.execute(event) + + expect(eventRepositoryCreateStub).not.to.have.been.called + expect(webSocketEmitStub).to.have.been.calledOnceWithExactly(WebSocketAdapterEvent.Message, [ + MessageType.OK, + event.id, + false, + Sinon.match(/invalid:.*exactly one h tag/), + ]) + }) + }) + + describe('invalid group event — h tag value format', () => { + it('rejects when the group id is too short', async () => { + event.tags = [['h', 'a'.repeat(63)]] + + await strategy.execute(event) + + expect(eventRepositoryCreateStub).not.to.have.been.called + expect(webSocketEmitStub).to.have.been.calledOnceWithExactly(WebSocketAdapterEvent.Message, [ + MessageType.OK, + event.id, + false, + Sinon.match(/invalid:.*64-character/), + ]) + }) + + it('rejects when the group id is too long', async () => { + event.tags = [['h', 'a'.repeat(65)]] + + await strategy.execute(event) + + expect(eventRepositoryCreateStub).not.to.have.been.called + expect(webSocketEmitStub).to.have.been.calledOnceWithExactly(WebSocketAdapterEvent.Message, [ + MessageType.OK, + event.id, + false, + Sinon.match(/invalid:.*64-character/), + ]) + }) + + it('rejects when the group id contains uppercase hex chars', async () => { + event.tags = [['h', 'A'.repeat(64)]] + + await strategy.execute(event) + + expect(eventRepositoryCreateStub).not.to.have.been.called + expect(webSocketEmitStub).to.have.been.calledOnceWithExactly(WebSocketAdapterEvent.Message, [ + MessageType.OK, + event.id, + false, + Sinon.match(/invalid:.*64-character/), + ]) + }) + + it('rejects when the group id contains non-hex characters', async () => { + event.tags = [['h', 'g'.repeat(64)]] + + await strategy.execute(event) + + expect(eventRepositoryCreateStub).not.to.have.been.called + expect(webSocketEmitStub).to.have.been.calledOnceWithExactly(WebSocketAdapterEvent.Message, [ + MessageType.OK, + event.id, + false, + Sinon.match(/invalid:.*64-character/), + ]) + }) + + it('accepts all valid lowercase hex characters (0-9 and a-f)', async () => { + event.tags = [['h', '0123456789abcdef'.repeat(4)]] + eventRepositoryCreateStub.resolves(1) + + await strategy.execute(event) + + expect(eventRepositoryCreateStub).to.have.been.calledOnce + }) + }) + }) +}) diff --git a/test/unit/husky/install.spec.ts b/test/unit/husky/install.spec.ts new file mode 100644 index 00000000..210d1361 --- /dev/null +++ b/test/unit/husky/install.spec.ts @@ -0,0 +1,57 @@ +import { expect } from 'chai' +import fs from 'fs' +import os from 'os' +import path from 'path' +import { spawnSync } from 'child_process' + +const projectRoot = process.cwd() +const installScriptPath = path.join(projectRoot, '.husky', 'install.mjs') + +const runInstall = (cwd: string, env: NodeJS.ProcessEnv = {}) => { + return spawnSync('node', [installScriptPath], { + cwd, + env: { + ...process.env, + ...env, + }, + encoding: 'utf-8', + timeout: 10_000, + }) +} + +describe('husky install script', () => { + it('exits successfully when .git is missing', () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'nostream-husky-no-git-')) + + try { + const result = runInstall(tmpDir) + expect(result.status).to.equal(0) + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }) + } + }) + + it('exits successfully when HUSKY is disabled', () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'nostream-husky-disabled-')) + + try { + const result = runInstall(tmpDir, { HUSKY: '0' }) + expect(result.status).to.equal(0) + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }) + } + }) + + it('exits successfully when husky package is unavailable even if .git exists', function () { + this.timeout(15_000) + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'nostream-husky-missing-package-')) + + try { + fs.mkdirSync(path.join(tmpDir, '.git')) + const result = runInstall(tmpDir) + expect(result.status).to.equal(0) + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }) + } + }) +}) diff --git a/test/unit/utils/nip25.spec.ts b/test/unit/utils/nip25.spec.ts new file mode 100644 index 00000000..33f73f82 --- /dev/null +++ b/test/unit/utils/nip25.spec.ts @@ -0,0 +1,90 @@ +import { expect } from 'chai' +import { Event } from '../../../src/@types/event' +import { EventKinds } from '../../../src/constants/base' +import { + isDislikeReaction, + isExternalContentReactionEvent, + isLikeReaction, + isReactionEvent, + parseReaction, +} from '../../../src/utils/nip25' + +const baseEvent = (): Partial => ({ tags: [], content: '+' }) + +describe('NIP-25', () => { + describe('isReactionEvent', () => { + it('returns true for kind 7', () => + expect(isReactionEvent({ ...baseEvent(), kind: EventKinds.REACTION } as Event)).to.equal(true)) + + it('returns false for other kinds', () => + expect(isReactionEvent({ ...baseEvent(), kind: EventKinds.TEXT_NOTE } as Event)).to.equal(false)) + }) + + describe('isExternalContentReactionEvent', () => { + it('returns true for kind 17', () => + expect( + isExternalContentReactionEvent({ ...baseEvent(), kind: EventKinds.EXTERNAL_CONTENT_REACTION } as Event), + ).to.equal(true)) + + it('returns false for kind 7', () => + expect( + isExternalContentReactionEvent({ ...baseEvent(), kind: EventKinds.REACTION } as Event), + ).to.equal(false)) + }) + + describe('isLikeReaction', () => { + it('returns true for "+"', () => + expect(isLikeReaction({ ...baseEvent(), kind: EventKinds.REACTION, content: '+' } as Event)).to.equal(true)) + + it('returns true for empty content', () => + expect(isLikeReaction({ ...baseEvent(), kind: EventKinds.REACTION, content: '' } as Event)).to.equal(true)) + + it('returns false for "-"', () => + expect(isLikeReaction({ ...baseEvent(), kind: EventKinds.REACTION, content: '-' } as Event)).to.equal(false)) + }) + + describe('isDislikeReaction', () => { + it('returns true for "-"', () => + expect(isDislikeReaction({ ...baseEvent(), kind: EventKinds.REACTION, content: '-' } as Event)).to.equal(true)) + + it('returns false for "+"', () => + expect(isDislikeReaction({ ...baseEvent(), kind: EventKinds.REACTION, content: '+' } as Event)).to.equal(false)) + }) + + describe('parseReaction', () => { + it('picks the last e tag as targetEventId', () => { + const event = { + ...baseEvent(), + kind: EventKinds.REACTION, + tags: [['e', 'aaa'], ['e', 'bbb']], + } as unknown as Event + expect(parseReaction(event).targetEventId).to.equal('bbb') + }) + + it('picks the last p tag as targetPubkey', () => { + const event = { + ...baseEvent(), + kind: EventKinds.REACTION, + tags: [['p', 'pk1'], ['p', 'pk2']], + } as unknown as Event + expect(parseReaction(event).targetPubkey).to.equal('pk2') + }) + + it('parses k tag as targetKind number', () => { + const event = { + ...baseEvent(), + kind: EventKinds.REACTION, + tags: [['k', '1']], + } as unknown as Event + expect(parseReaction(event).targetKind).to.equal(1) + }) + + it('returns undefined fields when tags are absent', () => { + const event = { ...baseEvent(), kind: EventKinds.REACTION, tags: [] } as unknown as Event + const result = parseReaction(event) + expect(result.targetEventId).to.be.undefined + expect(result.targetPubkey).to.be.undefined + expect(result.targetKind).to.be.undefined + }) + }) +}) \ No newline at end of file diff --git a/test/unit/utils/nip65.spec.ts b/test/unit/utils/nip65.spec.ts new file mode 100644 index 00000000..939ecd15 --- /dev/null +++ b/test/unit/utils/nip65.spec.ts @@ -0,0 +1,90 @@ +import { expect } from 'chai' +import { Event } from '../../../src/@types/event' +import { isRelayListEvent, parseRelayList } from '../../../src/utils/nip65' + +const baseEvent = (): Partial => ({ + kind: 10002, + tags: [], + content: '', +}) + +describe('NIP-65', () => { + describe('isRelayListEvent', () => { + it('returns true for kind 10002', () => { + expect(isRelayListEvent({ ...baseEvent(), kind: 10002 } as Event)).to.equal(true) + }) + + it('returns false for kind 0 (set_metadata)', () => { + expect(isRelayListEvent({ ...baseEvent(), kind: 0 } as Event)).to.equal(false) + }) + + it('returns false for kind 3 (contact_list)', () => { + expect(isRelayListEvent({ ...baseEvent(), kind: 3 } as Event)).to.equal(false) + }) + + it('returns false for kind 1 (text_note)', () => { + expect(isRelayListEvent({ ...baseEvent(), kind: 1 } as Event)).to.equal(false) + }) + }) + + describe('parseRelayList', () => { + it('returns empty array when tags is empty', () => { + const event = { ...baseEvent(), tags: [] } as unknown as Event + expect(parseRelayList(event)).to.deep.equal([]) + }) + + it('parses a relay tag with no marker as read+write', () => { + const event = { ...baseEvent(), tags: [['r', 'wss://relay.example.com']] } as unknown as Event + expect(parseRelayList(event)).to.deep.equal([{ url: 'wss://relay.example.com', marker: undefined }]) + }) + + it('parses a relay tag with read marker', () => { + const event = { ...baseEvent(), tags: [['r', 'wss://relay.example.com', 'read']] } as unknown as Event + expect(parseRelayList(event)).to.deep.equal([{ url: 'wss://relay.example.com', marker: 'read' }]) + }) + + it('parses a relay tag with write marker', () => { + const event = { ...baseEvent(), tags: [['r', 'wss://relay.example.com', 'write']] } as unknown as Event + expect(parseRelayList(event)).to.deep.equal([{ url: 'wss://relay.example.com', marker: 'write' }]) + }) + + it('sets marker to undefined when tag[2] is an unrecognized string', () => { + const event = { ...baseEvent(), tags: [['r', 'wss://relay.example.com', 'both']] } as unknown as Event + expect(parseRelayList(event)).to.deep.equal([{ url: 'wss://relay.example.com', marker: undefined }]) + }) + + it('ignores tags where tag[0] is not "r"', () => { + const event = { + ...baseEvent(), + tags: [ + ['p', 'somepubkey'], + ['e', 'someeventid'], + ], + } as unknown as Event + expect(parseRelayList(event)).to.deep.equal([]) + }) + + it('ignores tags shorter than 2 elements', () => { + const event = { ...baseEvent(), tags: [['r']] } as unknown as Event + expect(parseRelayList(event)).to.deep.equal([]) + }) + + it('parses a mixed list correctly', () => { + const event = { + ...baseEvent(), + tags: [ + ['r', 'wss://alice.relay.com'], + ['r', 'wss://bob.relay.com', 'write'], + ['r', 'wss://carol.relay.com', 'read'], + ['p', 'somepubkey'], + ], + } as unknown as Event + + expect(parseRelayList(event)).to.deep.equal([ + { url: 'wss://alice.relay.com', marker: undefined }, + { url: 'wss://bob.relay.com', marker: 'write' }, + { url: 'wss://carol.relay.com', marker: 'read' }, + ]) + }) + }) +}) From f29aa0dc0986f5310c6b4c14431b9d5470fdfeb0 Mon Sep 17 00:00:00 2001 From: anshumancanrock Date: Mon, 22 Jun 2026 19:51:51 +0530 Subject: [PATCH 11/12] refactor: remove unrelated nodeless changes from NIP-50 PR revert logger.error and route guard changes to match main. these will be submitted as a separate PR. --- .changeset/nip-50-search.md | 2 +- src/controllers/callbacks/nodeless-callback-controller.ts | 4 ++-- src/routes/callbacks/index.ts | 1 + .../callbacks/nodeless-callback-controller.spec.ts | 1 - 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.changeset/nip-50-search.md b/.changeset/nip-50-search.md index fcf4e998..57d54ef7 100644 --- a/.changeset/nip-50-search.md +++ b/.changeset/nip-50-search.md @@ -1,5 +1,5 @@ --- -"nostream": major +"nostream": minor --- Add NIP-50 full-text search support with PostgreSQL `tsvector`/`GIN` indexing. diff --git a/src/controllers/callbacks/nodeless-callback-controller.ts b/src/controllers/callbacks/nodeless-callback-controller.ts index 90d4ac47..7aca3a92 100644 --- a/src/controllers/callbacks/nodeless-callback-controller.ts +++ b/src/controllers/callbacks/nodeless-callback-controller.ts @@ -43,7 +43,7 @@ export class NodelessCallbackController implements IController { const signatureValidation = validateSchema(nodelessSignatureSchema)(request.headers['nodeless-signature']) if (signatureValidation.error) { - logger.error('nodeless callback request rejected: invalid signature format') + logger('nodeless callback request rejected: invalid signature format') response .status(400) .setHeader('content-type', 'application/json; charset=utf8') @@ -55,7 +55,7 @@ export class NodelessCallbackController implements IController { const actualBuf = Buffer.from(signatureValidation.value, 'hex') if (!timingSafeEqual(expectedBuf, actualBuf)) { - logger.error('nodeless callback request rejected: signature mismatch') + logger('nodeless callback request rejected: signature mismatch') response.status(403).send('Forbidden') return } diff --git a/src/routes/callbacks/index.ts b/src/routes/callbacks/index.ts index b18d19ff..32d8b3e4 100644 --- a/src/routes/callbacks/index.ts +++ b/src/routes/callbacks/index.ts @@ -35,3 +35,4 @@ router ) export default router + diff --git a/test/unit/controllers/callbacks/nodeless-callback-controller.spec.ts b/test/unit/controllers/callbacks/nodeless-callback-controller.spec.ts index 8cd5db10..341ff75c 100644 --- a/test/unit/controllers/callbacks/nodeless-callback-controller.spec.ts +++ b/test/unit/controllers/callbacks/nodeless-callback-controller.spec.ts @@ -144,7 +144,6 @@ describe('NodelessCallbackController', () => { expect(paymentsService.updateInvoiceStatus).to.not.have.been.called }) - it('returns 500 when NODELESS_WEBHOOK_SECRET is not configured', async () => { delete process.env.NODELESS_WEBHOOK_SECRET const { controller, paymentsService } = makeController() From 09d171a5caf528f64e25272ec083b25772999631 Mon Sep 17 00:00:00 2001 From: anshumancanrock Date: Tue, 23 Jun 2026 00:18:24 +0530 Subject: [PATCH 12/12] fix(ci): enable NIP-50 in integration test settings the test container uses default-settings.yaml which has nip50.enabled: false. without this override the relay strips the search filter and nip-50.feature scenarios fail. --- test/integration/docker-compose.yml | 1 + test/integration/settings.yaml | 7 +++++++ 2 files changed, 8 insertions(+) create mode 100644 test/integration/settings.yaml diff --git a/test/integration/docker-compose.yml b/test/integration/docker-compose.yml index 790a87b4..9baea88a 100644 --- a/test/integration/docker-compose.yml +++ b/test/integration/docker-compose.yml @@ -27,6 +27,7 @@ services: - ../../.nycrc.json:/code/.nycrc.json - ../../.coverage:/code/.coverage - ../../.test-reports:/code/.test-reports + - ../../test/integration/settings.yaml:/code/settings.yaml - ../../tsconfig.json:/code/tsconfig.json working_dir: /code depends_on: diff --git a/test/integration/settings.yaml b/test/integration/settings.yaml new file mode 100644 index 00000000..469f5d61 --- /dev/null +++ b/test/integration/settings.yaml @@ -0,0 +1,7 @@ +# Integration test settings override. +# Enables features that are disabled by default so integration tests can +# exercise them. +nip50: + enabled: true + language: simple + maxQueryLength: 256