Skip to content

Commit 30fa252

Browse files
feat: Add NIP-50 full-text search support (#587)
* feat: implement NIP-50 full-text search support * fix: use timingSafeEqual and zod for nodeless HMAC check * refactor: only register nodeless route when enabled * chore: sync with upstream main * 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. * 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. * 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. * 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. * test: update assertions for parameterized regconfig the ?::regconfig bindings serialize as 'simple'::regconfig in knex query strings, not 'simple' inside a template literal. * 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. * 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. * 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. --------- Co-authored-by: Ricardo Cabral <me@ricardocabral.io>
1 parent ca23be1 commit 30fa252

22 files changed

Lines changed: 431 additions & 11 deletions

.changeset/nip-50-search.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
"nostream": minor
3+
---
4+
5+
Add NIP-50 full-text search support with PostgreSQL `tsvector`/`GIN` indexing.
6+
7+
Clients can now include a `search` field in REQ filter objects to perform full-text
8+
queries against event content. Results are ranked by relevance (`ts_rank`) instead
9+
of the usual `created_at` ordering, per the NIP-50 specification.
10+
11+
Features:
12+
- New `search` filter field accepted in REQ messages
13+
- PostgreSQL GIN index on `to_tsvector('simple', event_content)` for fast full-text lookups
14+
- Configurable text-search language (defaults to `simple`, supports `english`, `spanish`, etc.)
15+
- Configurable max search query length for abuse prevention
16+
- NIP-50 listed in NIP-11 relay information document
17+
- Search can be combined with all existing filter fields (kinds, authors, tags, etc.)

CONFIGURATION.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,9 @@ The settings below are listed in alphabetical order by name. Please keep this ta
179179
| nip05.verifyExpiration | Time in milliseconds before a successful NIP-05 verification expires and needs re-checking. Defaults to 604800000 (1 week). |
180180
| nip05.verifyUpdateFrequency | Minimum interval in milliseconds between re-verification attempts for a given author. Defaults to 86400000 (24 hours). |
181181
| nip45.enabled | Enable or disable NIP-45 COUNT handling. Defaults to true. |
182+
| 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. |
183+
| 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('<your_language>', event_content));` — otherwise the planner cannot use the index and queries fall back to sequential scans. |
184+
| nip50.maxQueryLength | Maximum length of the search query string. Queries exceeding this are truncated. Defaults to 256. |
182185
| paymentProcessors.lnbits.baseURL | Base URL of your Lnbits instance. |
183186
| paymentProcessors.lnbits.callbackBaseURL | Public-facing Nostream's Lnbits Callback URL. (e.g. https://relay.your-domain.com/callbacks/lnbits) |
184187
| 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) |
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
exports.config = { transaction: false }
2+
3+
exports.up = function (knex) {
4+
return knex.raw(
5+
"CREATE INDEX CONCURRENTLY IF NOT EXISTS events_content_fts_idx ON events USING gin (to_tsvector('simple', event_content))",
6+
)
7+
}
8+
9+
exports.down = function (knex) {
10+
return knex.raw('DROP INDEX CONCURRENTLY IF EXISTS events_content_fts_idx')
11+
}

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
40,
2525
44,
2626
45,
27+
50,
2728
65
2829
],
2930
"supportedNipExtensions": [],

resources/default-settings.yaml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,11 @@ nip05:
6262
domainBlacklist: []
6363
nip45:
6464
enabled: true
65+
nip50:
66+
enabled: false
67+
# 'simple' (no stemming) or a language name like 'english', 'spanish'
68+
language: simple
69+
maxQueryLength: 256
6570
wot:
6671
# Web of Trust filtering. When enabled, only events from pubkeys within
6772
# the relay owner's 2-hop follow graph are accepted.

src/@types/settings.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -252,6 +252,12 @@ export interface Nip45Settings {
252252
enabled?: boolean
253253
}
254254

255+
export interface Nip50Settings {
256+
enabled?: boolean
257+
language?: string
258+
maxQueryLength?: number
259+
}
260+
255261
export interface Nip05Settings {
256262
mode: Nip05Mode
257263
/**
@@ -317,5 +323,6 @@ export interface Settings {
317323
nip05?: Nip05Settings
318324
nip43?: Nip43Settings
319325
nip45?: Nip45Settings
326+
nip50?: Nip50Settings
320327
wot?: WoTSettings
321328
}

src/@types/subscription.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,5 +10,6 @@ export interface SubscriptionFilter {
1010
until?: number
1111
authors?: Pubkey[]
1212
limit?: number
13+
search?: string
1314
[key: `#${string}`]: string[]
1415
}

src/factories/worker-factory.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ const logger = createLogger('worker-factory')
1919
export const workerFactory = (): AppWorker => {
2020
const dbClient = getMasterDbClient()
2121
const readReplicaDbClient = getReadReplicaDbClient()
22-
const eventRepository = new EventRepository(dbClient, readReplicaDbClient)
22+
const eventRepository = new EventRepository(dbClient, readReplicaDbClient, createSettings)
2323
const userRepository = new UserRepository(dbClient, eventRepository)
2424
const nip05VerificationRepository = new Nip05VerificationRepository(dbClient)
2525

src/handlers/request-handlers/root-request-handler.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,7 @@ export const rootRequestHandler = (request: Request, response: Response, next: N
102102
created_at_upper_limit: createdAtLimits?.maxPositiveDelta,
103103
default_limit: DEFAULT_FILTER_LIMIT,
104104
restricted_writes: hasWriteRestriction,
105+
search_supported: settings.nip50?.enabled ?? false,
105106
},
106107
payments_url: paymentsUrl.toString(),
107108
fees: Object.getOwnPropertyNames(settings.payments.feeSchedules).reduce(

src/handlers/subscribe-message-handler.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { anyPass, equals, isNil, map, propSatisfies, uniqWith } from 'ramda'
1+
import { anyPass, equals, isNil, map, omit, propSatisfies, uniqWith } from 'ramda'
22
// import { addAbortSignal } from 'stream'
33
import { pipeline } from 'stream/promises'
44

@@ -38,7 +38,11 @@ export class SubscribeMessageHandler implements IMessageHandler, IAbortable {
3838

3939
public async handleMessage(message: SubscribeMessage): Promise<void> {
4040
const subscriptionId = message[1]
41-
const filters = uniqWith(equals, message.slice(2)) as SubscriptionFilter[]
41+
const rawFilters = uniqWith(equals, message.slice(2)) as SubscriptionFilter[]
42+
43+
// NIP-50: strip search from filters when disabled so isEventMatchingFilter ignores it
44+
const nip50Enabled = this.settings()?.nip50?.enabled ?? false
45+
const filters = nip50Enabled ? rawFilters : rawFilters.map(omit(['search'])) as SubscriptionFilter[]
4246

4347
const reason = this.canSubscribe(subscriptionId, filters)
4448
if (reason) {

0 commit comments

Comments
 (0)