Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .changeset/nip-50-search.md
Original file line number Diff line number Diff line change
@@ -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.)
3 changes: 3 additions & 0 deletions CONFIGURATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
Expand Down
11 changes: 11 additions & 0 deletions migrations/20260427_000000_add_nip50_fts_index.js
Original file line number Diff line number Diff line change
@@ -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')
}
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@
33,
40,
44,
45
45,
50
],
"supportedNipExtensions": [],
"main": "src/index.ts",
Expand Down
5 changes: 5 additions & 0 deletions resources/default-settings.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
7 changes: 7 additions & 0 deletions src/@types/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,12 @@ export interface Nip45Settings {
enabled?: boolean
}

export interface Nip50Settings {
enabled?: boolean
language?: string
maxQueryLength?: number
}

export interface Nip05Settings {
mode: Nip05Mode
/**
Expand Down Expand Up @@ -276,4 +282,5 @@ export interface Settings {
mirroring?: Mirroring
nip05?: Nip05Settings
nip45?: Nip45Settings
nip50?: Nip50Settings
}
1 change: 1 addition & 0 deletions src/@types/subscription.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,5 +10,6 @@ export interface SubscriptionFilter {
until?: number
authors?: Pubkey[]
limit?: number
search?: string
[key: `#${string}`]: string[]
}
2 changes: 1 addition & 1 deletion src/factories/worker-factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
1 change: 1 addition & 0 deletions src/handlers/request-handlers/root-request-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
8 changes: 6 additions & 2 deletions src/handlers/subscribe-message-handler.ts
Original file line number Diff line number Diff line change
@@ -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'

Expand Down Expand Up @@ -38,7 +38,11 @@ export class SubscribeMessageHandler implements IMessageHandler, IAbortable {

public async handleMessage(message: SubscribeMessage): Promise<void> {
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) {
Expand Down
60 changes: 53 additions & 7 deletions src/repositories/event-repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,10 +58,21 @@ const groupByLengthSpec = groupBy<string, 'exact' | 'even' | 'odd'>(

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<DBEvent[]> {
Expand All @@ -72,15 +83,29 @@ export class EventRepository implements IEventRepository {
const queries = filters.map((currentFilter) => {
const builder = this.readReplicaDbClient<DBEvent>('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.*')
}

Expand All @@ -107,7 +132,7 @@ export class EventRepository implements IEventRepository {
const queries = filters.map((currentFilter) => {
const builder = this.readReplicaDbClient<DBEvent>('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')
Expand All @@ -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([
Expand Down Expand Up @@ -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')

Expand All @@ -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<number> {
Expand Down
4 changes: 3 additions & 1 deletion src/schemas/filter-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -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) => {
Expand Down
9 changes: 9 additions & 0 deletions src/utils/event.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
22 changes: 22 additions & 0 deletions test/integration/features/nip-50/nip-50.feature
Original file line number Diff line number Diff line change
@@ -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
63 changes: 63 additions & 0 deletions test/integration/features/nip-50/nip-50.feature.ts
Original file line number Diff line number Diff line change
@@ -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<Record<string, any>>, 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<Record<string, any>>, 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<Record<string, any>>, 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<Record<string, any>>, 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)
},
)
1 change: 1 addition & 0 deletions test/integration/features/shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
19 changes: 19 additions & 0 deletions test/unit/handlers/request-handlers/root-request-handler.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
Loading
Loading