Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/full-donuts-allow.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"nostream": minor
---

added NIP-45 COUNT support with end-to-end handling (validation, handler routing, DB counting, and tests).
1 change: 1 addition & 0 deletions CONFIGURATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,7 @@ The settings below are listed in alphabetical order by name. Please keep this ta
| nip05.mode | NIP-05 verification mode: `enabled` requires verification, `passive` verifies without blocking, `disabled` does nothing. Defaults to `disabled`. |
| 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. |
| 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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ NIPs with a relay-specific implementation are listed here.
- [x] NIP-33: Parameterized Replaceable Events
- [x] NIP-40: Expiration Timestamp
- [x] NIP-44: Encrypted Payloads (Versioned)
- [x] NIP-45: Event Counts
- [x] NIP-62: Request to Vanish

## Requirements
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@
28,
33,
40,
44
44,
45
],
"supportedNipExtensions": [
"11a"
Expand Down
2 changes: 2 additions & 0 deletions resources/default-settings.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,8 @@ nip05:
domainWhitelist: []
# Block authors with NIP-05 at these domains
domainBlacklist: []
nip45:
enabled: true
network:
maxPayloadSize: 524288
# Uncomment only when using a trusted reverse proxy and configuring trustedProxies.
Expand Down
31 changes: 29 additions & 2 deletions src/@types/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,15 @@ export enum MessageType {
NOTICE = 'NOTICE',
EOSE = 'EOSE',
OK = 'OK',
COUNT = 'COUNT',
CLOSED = 'CLOSED',
}

export type IncomingMessage = (SubscribeMessage | IncomingEventMessage | UnsubscribeMessage) & {
export type IncomingMessage = (SubscribeMessage | IncomingEventMessage | UnsubscribeMessage | CountMessage) & {
[ContextMetadataKey]?: ContextMetadata
}

export type OutgoingMessage = OutgoingEventMessage | EndOfStoredEventsNotice | NoticeMessage | CommandResult
export type OutgoingMessage = OutgoingEventMessage | EndOfStoredEventsNotice | NoticeMessage | CommandResult | CountResultMessage | ClosedMessage

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

export type CountMessage = {
[index in Range<2, 100>]: SubscriptionFilter
} & {
0: MessageType.COUNT
1: SubscriptionId
} & Array<SubscriptionFilter>

export type IncomingEventMessage = EventMessage & [MessageType.EVENT, Event]

export type IncomingRelayedEventMessage = [MessageType.EVENT, RelayedEvent, Secret]
Expand Down Expand Up @@ -62,3 +71,21 @@ export interface EndOfStoredEventsNotice {
0: MessageType.EOSE
1: SubscriptionId
}

export interface CountResultPayload {
count: number
approximate?: boolean
hll?: string
}

export interface CountResultMessage {
0: MessageType.COUNT
1: SubscriptionId
2: CountResultPayload
}

export interface ClosedMessage {
0: MessageType.CLOSED
1: SubscriptionId
2: string
}
1 change: 1 addition & 0 deletions src/@types/repositories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ export interface IEventRepository {
upsert(event: Event): Promise<number>
upsertMany(events: Event[]): Promise<number>
findByFilters(filters: SubscriptionFilter[]): IQueryResult<DBEvent[]>
countByFilters(filters: SubscriptionFilter[]): Promise<number>
deleteByPubkeyAndIds(pubkey: Pubkey, ids: EventId[]): Promise<number>
deleteByPubkeyExceptKinds(pubkey: Pubkey, excludedKinds: number[]): Promise<number>
hasActiveRequestToVanish(pubkey: Pubkey): Promise<boolean>
Expand Down
5 changes: 5 additions & 0 deletions src/@types/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,10 @@ export interface Mirroring {

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

export interface Nip45Settings {
enabled?: boolean
}

export interface Nip05Settings {
mode: Nip05Mode
/**
Expand Down Expand Up @@ -266,4 +270,5 @@ export interface Settings {
limits?: Limits
mirroring?: Mirroring
nip05?: Nip05Settings
nip45?: Nip45Settings
}
3 changes: 3 additions & 0 deletions src/factories/message-handler-factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 { CountMessageHandler } from '../handlers/count-message-handler'
import { EventMessageHandler } from '../handlers/event-message-handler'
import { eventStrategyFactory } from './event-strategy-factory'
import { getCacheClient } from '../cache/client'
Expand Down Expand Up @@ -42,6 +43,8 @@ export const messageHandlerFactory =
return new SubscribeMessageHandler(adapter, eventRepository, createSettings)
case MessageType.CLOSE:
return new UnsubscribeMessageHandler(adapter)
case MessageType.COUNT:
return new CountMessageHandler(adapter, eventRepository, createSettings)
default:
throw new Error(`Unknown message type: ${String(message[0]).substring(0, 64)}`)
}
Expand Down
67 changes: 67 additions & 0 deletions src/handlers/count-message-handler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { equals, uniqWith } from 'ramda'

import { IWebSocketAdapter } from '../@types/adapters'
import { IMessageHandler } from '../@types/message-handlers'
import { CountMessage } from '../@types/messages'
import { IEventRepository } from '../@types/repositories'
import { Settings } from '../@types/settings'
import { SubscriptionFilter, SubscriptionId } from '../@types/subscription'
import { WebSocketAdapterEvent } from '../constants/adapter'
import { createLogger } from '../factories/logger-factory'
import { createClosedMessage, createCountResultMessage } from '../utils/messages'

const debug = createLogger('count-message-handler')

export class CountMessageHandler implements IMessageHandler {
public constructor(
private readonly webSocket: IWebSocketAdapter,
private readonly eventRepository: IEventRepository,
private readonly settings: () => Settings,
) {}

public async handleMessage(message: CountMessage): Promise<void> {
const queryId = message[1]
const countEnabled = this.settings().nip45?.enabled ?? true
if (!countEnabled) {
this.webSocket.emit(WebSocketAdapterEvent.Message, createClosedMessage(queryId, 'COUNT is disabled by relay configuration'))
return
}

// Some clients send the same filter more than once.
// We remove duplicates so we do less DB work.
const filters = uniqWith(equals, message.slice(2)) as SubscriptionFilter[]

const reason = this.canCount(queryId, filters)
if (reason) {
debug('count request %s with %o rejected: %s', queryId, filters, reason)
// NIP-45 says we should close rejected COUNT requests with a reason.
this.webSocket.emit(WebSocketAdapterEvent.Message, createClosedMessage(queryId, reason))
return
}

try {
const count = await this.eventRepository.countByFilters(filters)
this.webSocket.emit(WebSocketAdapterEvent.Message, createCountResultMessage(queryId, { count }))
} catch (error) {
debug('count request %s failed: %o', queryId, error)
// Keep this message generic so internal errors are not leaked to clients.
this.webSocket.emit(WebSocketAdapterEvent.Message, createClosedMessage(queryId, 'error: unable to count events'))
}
}

private canCount(queryId: SubscriptionId, filters: SubscriptionFilter[]): string | undefined {
const subscriptionLimits = this.settings().limits?.client?.subscription
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's add a setting to enable or disable COUNT. We can have it enabled by default.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've pushed settings to toggle COUNT here; 666f8a6, 1de0a5b

const maxFilters = subscriptionLimits?.maxFilters ?? 0

if (maxFilters > 0 && filters.length > maxFilters) {
return `Too many filters: Number of filters per count query must be less than or equal to ${maxFilters}`
}

if (
typeof subscriptionLimits?.maxSubscriptionIdLength === 'number' &&
queryId.length > subscriptionLimits.maxSubscriptionIdLength
) {
return `Query ID too long: Query ID must be less than or equal to ${subscriptionLimits.maxSubscriptionIdLength}`
}
}
}
Loading
Loading