Skip to content

Commit 7edd6c3

Browse files
authored
feat: add NIP-45 Event Count support (#522)
* feat: add COUNT/CLOSED message types * feat: add COUNT message validation, response helpers, repository counting, and handler routing * feat: add schema, factory, handler, and repository tests * docs: mark NIP-45 as supported * refactor: share filter-building logic between findByFilters and countByFilters * feat: add nip45.enabled toggle for COUNT * feat: add nip45.enabled test and docs
1 parent 0036d89 commit 7edd6c3

17 files changed

Lines changed: 566 additions & 78 deletions

.changeset/full-donuts-allow.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"nostream": minor
3+
---
4+
5+
added NIP-45 COUNT support with end-to-end handling (validation, handler routing, DB counting, and tests).

CONFIGURATION.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,7 @@ The settings below are listed in alphabetical order by name. Please keep this ta
163163
| nip05.mode | NIP-05 verification mode: `enabled` requires verification, `passive` verifies without blocking, `disabled` does nothing. Defaults to `disabled`. |
164164
| nip05.verifyExpiration | Time in milliseconds before a successful NIP-05 verification expires and needs re-checking. Defaults to 604800000 (1 week). |
165165
| nip05.verifyUpdateFrequency | Minimum interval in milliseconds between re-verification attempts for a given author. Defaults to 86400000 (24 hours). |
166+
| nip45.enabled | Enable or disable NIP-45 COUNT handling. Defaults to true. |
166167
| paymentProcessors.lnbits.baseURL | Base URL of your Lnbits instance. |
167168
| paymentProcessors.lnbits.callbackBaseURL | Public-facing Nostream's Lnbits Callback URL. (e.g. https://relay.your-domain.com/callbacks/lnbits) |
168169
| 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) |

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ NIPs with a relay-specific implementation are listed here.
6161
- [x] NIP-33: Parameterized Replaceable Events
6262
- [x] NIP-40: Expiration Timestamp
6363
- [x] NIP-44: Encrypted Payloads (Versioned)
64+
- [x] NIP-45: Event Counts
6465
- [x] NIP-62: Request to Vanish
6566

6667
## Requirements

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,8 @@
1818
28,
1919
33,
2020
40,
21-
44
21+
44,
22+
45
2223
],
2324
"supportedNipExtensions": [
2425
"11a"

resources/default-settings.yaml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,8 @@ nip05:
5353
domainWhitelist: []
5454
# Block authors with NIP-05 at these domains
5555
domainBlacklist: []
56+
nip45:
57+
enabled: true
5658
network:
5759
maxPayloadSize: 524288
5860
# Uncomment only when using a trusted reverse proxy and configuring trustedProxies.

src/@types/messages.ts

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,15 @@ export enum MessageType {
1010
NOTICE = 'NOTICE',
1111
EOSE = 'EOSE',
1212
OK = 'OK',
13+
COUNT = 'COUNT',
14+
CLOSED = 'CLOSED',
1315
}
1416

15-
export type IncomingMessage = (SubscribeMessage | IncomingEventMessage | UnsubscribeMessage) & {
17+
export type IncomingMessage = (SubscribeMessage | IncomingEventMessage | UnsubscribeMessage | CountMessage) & {
1618
[ContextMetadataKey]?: ContextMetadata
1719
}
1820

19-
export type OutgoingMessage = OutgoingEventMessage | EndOfStoredEventsNotice | NoticeMessage | CommandResult
21+
export type OutgoingMessage = OutgoingEventMessage | EndOfStoredEventsNotice | NoticeMessage | CommandResult | CountResultMessage | ClosedMessage
2022

2123
export type SubscribeMessage = {
2224
[index in Range<2, 100>]: SubscriptionFilter
@@ -25,6 +27,13 @@ export type SubscribeMessage = {
2527
1: SubscriptionId
2628
} & Array<SubscriptionFilter>
2729

30+
export type CountMessage = {
31+
[index in Range<2, 100>]: SubscriptionFilter
32+
} & {
33+
0: MessageType.COUNT
34+
1: SubscriptionId
35+
} & Array<SubscriptionFilter>
36+
2837
export type IncomingEventMessage = EventMessage & [MessageType.EVENT, Event]
2938

3039
export type IncomingRelayedEventMessage = [MessageType.EVENT, RelayedEvent, Secret]
@@ -62,3 +71,21 @@ export interface EndOfStoredEventsNotice {
6271
0: MessageType.EOSE
6372
1: SubscriptionId
6473
}
74+
75+
export interface CountResultPayload {
76+
count: number
77+
approximate?: boolean
78+
hll?: string
79+
}
80+
81+
export interface CountResultMessage {
82+
0: MessageType.COUNT
83+
1: SubscriptionId
84+
2: CountResultPayload
85+
}
86+
87+
export interface ClosedMessage {
88+
0: MessageType.CLOSED
89+
1: SubscriptionId
90+
2: string
91+
}

src/@types/repositories.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ export interface IEventRepository {
3333
upsert(event: Event): Promise<number>
3434
upsertMany(events: Event[]): Promise<number>
3535
findByFilters(filters: SubscriptionFilter[]): IQueryResult<DBEvent[]>
36+
countByFilters(filters: SubscriptionFilter[]): Promise<number>
3637
deleteByPubkeyAndIds(pubkey: Pubkey, ids: EventId[]): Promise<number>
3738
deleteByPubkeyExceptKinds(pubkey: Pubkey, excludedKinds: number[]): Promise<number>
3839
hasActiveRequestToVanish(pubkey: Pubkey): Promise<boolean>

src/@types/settings.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -236,6 +236,10 @@ export interface Mirroring {
236236

237237
export type Nip05Mode = 'enabled' | 'passive' | 'disabled'
238238

239+
export interface Nip45Settings {
240+
enabled?: boolean
241+
}
242+
239243
export interface Nip05Settings {
240244
mode: Nip05Mode
241245
/**
@@ -266,4 +270,5 @@ export interface Settings {
266270
limits?: Limits
267271
mirroring?: Mirroring
268272
nip05?: Nip05Settings
273+
nip45?: Nip45Settings
269274
}

src/factories/message-handler-factory.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { ICacheAdapter, IWebSocketAdapter } from '../@types/adapters'
22
import { IEventRepository, INip05VerificationRepository, IUserRepository } from '../@types/repositories'
33
import { IncomingMessage, MessageType } from '../@types/messages'
44
import { createSettings } from './settings-factory'
5+
import { CountMessageHandler } from '../handlers/count-message-handler'
56
import { EventMessageHandler } from '../handlers/event-message-handler'
67
import { eventStrategyFactory } from './event-strategy-factory'
78
import { getCacheClient } from '../cache/client'
@@ -42,6 +43,8 @@ export const messageHandlerFactory =
4243
return new SubscribeMessageHandler(adapter, eventRepository, createSettings)
4344
case MessageType.CLOSE:
4445
return new UnsubscribeMessageHandler(adapter)
46+
case MessageType.COUNT:
47+
return new CountMessageHandler(adapter, eventRepository, createSettings)
4548
default:
4649
throw new Error(`Unknown message type: ${String(message[0]).substring(0, 64)}`)
4750
}
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import { equals, uniqWith } from 'ramda'
2+
3+
import { IWebSocketAdapter } from '../@types/adapters'
4+
import { IMessageHandler } from '../@types/message-handlers'
5+
import { CountMessage } from '../@types/messages'
6+
import { IEventRepository } from '../@types/repositories'
7+
import { Settings } from '../@types/settings'
8+
import { SubscriptionFilter, SubscriptionId } from '../@types/subscription'
9+
import { WebSocketAdapterEvent } from '../constants/adapter'
10+
import { createLogger } from '../factories/logger-factory'
11+
import { createClosedMessage, createCountResultMessage } from '../utils/messages'
12+
13+
const debug = createLogger('count-message-handler')
14+
15+
export class CountMessageHandler implements IMessageHandler {
16+
public constructor(
17+
private readonly webSocket: IWebSocketAdapter,
18+
private readonly eventRepository: IEventRepository,
19+
private readonly settings: () => Settings,
20+
) {}
21+
22+
public async handleMessage(message: CountMessage): Promise<void> {
23+
const queryId = message[1]
24+
const countEnabled = this.settings().nip45?.enabled ?? true
25+
if (!countEnabled) {
26+
this.webSocket.emit(WebSocketAdapterEvent.Message, createClosedMessage(queryId, 'COUNT is disabled by relay configuration'))
27+
return
28+
}
29+
30+
// Some clients send the same filter more than once.
31+
// We remove duplicates so we do less DB work.
32+
const filters = uniqWith(equals, message.slice(2)) as SubscriptionFilter[]
33+
34+
const reason = this.canCount(queryId, filters)
35+
if (reason) {
36+
debug('count request %s with %o rejected: %s', queryId, filters, reason)
37+
// NIP-45 says we should close rejected COUNT requests with a reason.
38+
this.webSocket.emit(WebSocketAdapterEvent.Message, createClosedMessage(queryId, reason))
39+
return
40+
}
41+
42+
try {
43+
const count = await this.eventRepository.countByFilters(filters)
44+
this.webSocket.emit(WebSocketAdapterEvent.Message, createCountResultMessage(queryId, { count }))
45+
} catch (error) {
46+
debug('count request %s failed: %o', queryId, error)
47+
// Keep this message generic so internal errors are not leaked to clients.
48+
this.webSocket.emit(WebSocketAdapterEvent.Message, createClosedMessage(queryId, 'error: unable to count events'))
49+
}
50+
}
51+
52+
private canCount(queryId: SubscriptionId, filters: SubscriptionFilter[]): string | undefined {
53+
const subscriptionLimits = this.settings().limits?.client?.subscription
54+
const maxFilters = subscriptionLimits?.maxFilters ?? 0
55+
56+
if (maxFilters > 0 && filters.length > maxFilters) {
57+
return `Too many filters: Number of filters per count query must be less than or equal to ${maxFilters}`
58+
}
59+
60+
if (
61+
typeof subscriptionLimits?.maxSubscriptionIdLength === 'number' &&
62+
queryId.length > subscriptionLimits.maxSubscriptionIdLength
63+
) {
64+
return `Query ID too long: Query ID must be less than or equal to ${subscriptionLimits.maxSubscriptionIdLength}`
65+
}
66+
}
67+
}

0 commit comments

Comments
 (0)