diff --git a/.changeset/nip43-join-leave.md b/.changeset/nip43-join-leave.md new file mode 100644 index 00000000..c10a809a --- /dev/null +++ b/.changeset/nip43-join-leave.md @@ -0,0 +1,5 @@ +--- +"nostream": minor +--- + +Add NIP-43 join/leave request event strategies (kinds 28934/28936) with NIP-42 auth enforcement, created_at freshness validation, invite code claiming, and admission management. When `nip43.enabled` is set, publishing is restricted to admitted members even without payments enabled, and NIP-43 is advertised in the NIP-11 document (hidden when disabled). Join/leave update the admission cache so membership changes take effect immediately. diff --git a/.knip.json b/.knip.json index 001eba2e..eb3256db 100644 --- a/.knip.json +++ b/.knip.json @@ -14,8 +14,7 @@ "lzma-native" ], "ignore": [ - ".nostr/**", - "src/repositories/invite-code-repository.ts" + ".nostr/**" ], "commitlint": false, "eslint": false, diff --git a/package.json b/package.json index df8dac50..f9b75fa5 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ 28, 33, 40, + 43, 44, 45, 50, diff --git a/resources/default-settings.yaml b/resources/default-settings.yaml index 300e2f38..a3fba302 100755 --- a/resources/default-settings.yaml +++ b/resources/default-settings.yaml @@ -60,6 +60,11 @@ nip05: domainWhitelist: [] # Block authors with NIP-05 at these domains domainBlacklist: [] +nip43: + # NIP-43: invite-based relay membership. When enabled, only admitted members + # (users who claimed an invite code via a kind 28934 join request) may + # publish events. Enabling this on an open relay blocks all non-members. + enabled: false nip45: enabled: true nip50: diff --git a/src/@types/repositories.ts b/src/@types/repositories.ts index ff12b8dc..df98e545 100644 --- a/src/@types/repositories.ts +++ b/src/@types/repositories.ts @@ -56,6 +56,7 @@ export interface IUserRepository { isVanished(pubkey: Pubkey, client?: DatabaseClient): Promise setVanished(pubkey: Pubkey, vanished: boolean, client?: DatabaseClient): Promise admitUser(pubkey: Pubkey, admittedAt: Date, client?: DatabaseClient): Promise + revokeAdmission(pubkey: Pubkey, client?: DatabaseClient): Promise } export interface INip05VerificationRepository { @@ -72,4 +73,3 @@ export interface IInviteCodeRepository { findActiveCodes(limit?: number): Promise deleteExpiredCodes(): Promise } - diff --git a/src/constants/caching.ts b/src/constants/caching.ts index 7bd6e556..75161447 100644 --- a/src/constants/caching.ts +++ b/src/constants/caching.ts @@ -3,3 +3,5 @@ export enum CacheAdmissionState { BLOCKED_NOT_ADMITTED = 'blocked_not_admitted', BLOCKED_INSUFFICIENT_BALANCE = 'blocked_insufficient_balance', } + +export const admissionCacheKey = (pubkey: string): string => `${pubkey}:is-admitted` diff --git a/src/factories/event-strategy-factory.ts b/src/factories/event-strategy-factory.ts index b88aa1c6..a9843ea2 100644 --- a/src/factories/event-strategy-factory.ts +++ b/src/factories/event-strategy-factory.ts @@ -1,4 +1,5 @@ -import { IEventRepository, IUserRepository } from '../@types/repositories' +import { ICacheAdapter, IWebSocketAdapter } from '../@types/adapters' +import { IEventRepository, IInviteCodeRepository, IUserRepository } from '../@types/repositories' import { isDeleteEvent, isEphemeralEvent, @@ -9,6 +10,7 @@ import { isReplaceableEvent, isRequestToVanishEvent, } from '../utils/event' +import { isNip43JoinRequest, isNip43LeaveRequest } from '../utils/nip43' import { isRelayListEvent } from '../utils/nip65' import { DefaultEventStrategy } from '../handlers/event-strategies/default-event-strategy' import { DeleteEventStrategy } from '../handlers/event-strategies/delete-event-strategy' @@ -18,9 +20,11 @@ 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 { JoinRequestEventStrategy } from '../handlers/event-strategies/join-request-event-strategy' +import { LeaveRequestEventStrategy } from '../handlers/event-strategies/leave-request-event-strategy' import { ParameterizedReplaceableEventStrategy } from '../handlers/event-strategies/parameterized-replaceable-event-strategy' import { ReplaceableEventStrategy } from '../handlers/event-strategies/replaceable-event-strategy' +import { Settings } from '../@types/settings' import { TimestampEventStrategy } from '../handlers/event-strategies/timestamp-event-strategy' import { VanishEventStrategy } from '../handlers/event-strategies/vanish-event-strategy' @@ -28,6 +32,9 @@ export const eventStrategyFactory = ( eventRepository: IEventRepository, userRepository: IUserRepository, + inviteCodeRepository: IInviteCodeRepository, + cache: ICacheAdapter, + settings: () => Settings, ): Factory>, [Event, IWebSocketAdapter]> => ([event, adapter]: [Event, IWebSocketAdapter]) => { if (isRequestToVanishEvent(event)) { @@ -40,6 +47,12 @@ export const eventStrategyFactory = return new TimestampEventStrategy(adapter, eventRepository) } else if (isRelayListEvent(event) || isReplaceableEvent(event)) { return new ReplaceableEventStrategy(adapter, eventRepository) + // NIP-43: Join/Leave requests MUST be checked before the generic ephemeral + // handler, because kinds 28934/28936 fall in the ephemeral range (20000-29999). + } else if (isNip43JoinRequest(event)) { + return new JoinRequestEventStrategy(adapter, inviteCodeRepository, userRepository, cache, settings) + } else if (isNip43LeaveRequest(event)) { + return new LeaveRequestEventStrategy(adapter, userRepository, cache, settings) } else if (isEphemeralEvent(event)) { return new EphemeralEventStrategy(adapter) } else if (isDeleteEvent(event)) { diff --git a/src/factories/message-handler-factory.ts b/src/factories/message-handler-factory.ts index b6943725..e7ae66de 100644 --- a/src/factories/message-handler-factory.ts +++ b/src/factories/message-handler-factory.ts @@ -1,5 +1,5 @@ import { ICacheAdapter, IWebSocketAdapter } from '../@types/adapters' -import { IEventRepository, INip05VerificationRepository, IUserRepository } from '../@types/repositories' +import { IEventRepository, IInviteCodeRepository, INip05VerificationRepository, IUserRepository } from '../@types/repositories' import { IncomingMessage, MessageType } from '../@types/messages' import { createSettings } from './settings-factory' import { AuthMessageHandler } from '../handlers/auth-message-handler' @@ -25,13 +25,14 @@ export const messageHandlerFactory = eventRepository: IEventRepository, userRepository: IUserRepository, nip05VerificationRepository: INip05VerificationRepository, + inviteCodeRepository: IInviteCodeRepository, ) => ([message, adapter]: [IncomingMessage, IWebSocketAdapter]) => { switch (message[0]) { case MessageType.EVENT: { return new EventMessageHandler( adapter, - eventStrategyFactory(eventRepository, userRepository), + eventStrategyFactory(eventRepository, userRepository, inviteCodeRepository, getCache(), createSettings), eventRepository, userRepository, createSettings, diff --git a/src/factories/websocket-adapter-factory.ts b/src/factories/websocket-adapter-factory.ts index d40b6aec..433f1de5 100644 --- a/src/factories/websocket-adapter-factory.ts +++ b/src/factories/websocket-adapter-factory.ts @@ -1,7 +1,7 @@ import { IncomingMessage } from 'http' import { WebSocket } from 'ws' -import { IEventRepository, INip05VerificationRepository, IUserRepository } from '../@types/repositories' +import { IEventRepository, IInviteCodeRepository, INip05VerificationRepository, IUserRepository } from '../@types/repositories' import { createSettings } from './settings-factory' import { IWebSocketServerAdapter } from '../@types/adapters' import { messageHandlerFactory } from './message-handler-factory' @@ -13,13 +13,14 @@ export const webSocketAdapterFactory = eventRepository: IEventRepository, userRepository: IUserRepository, nip05VerificationRepository: INip05VerificationRepository, + inviteCodeRepository: IInviteCodeRepository, ) => ([client, request, webSocketServerAdapter]: [WebSocket, IncomingMessage, IWebSocketServerAdapter]) => new WebSocketAdapter( client, request, webSocketServerAdapter, - messageHandlerFactory(eventRepository, userRepository, nip05VerificationRepository), + messageHandlerFactory(eventRepository, userRepository, nip05VerificationRepository, inviteCodeRepository), rateLimiterFactory, createSettings, ) diff --git a/src/factories/worker-factory.ts b/src/factories/worker-factory.ts index b893cde8..8b97d782 100644 --- a/src/factories/worker-factory.ts +++ b/src/factories/worker-factory.ts @@ -9,6 +9,7 @@ import { createLogger } from './logger-factory' import { createSettings } from '../factories/settings-factory' import { createWebApp } from './web-app-factory' import { EventRepository } from '../repositories/event-repository' +import { InviteCodeRepository } from '../repositories/invite-code-repository' import { Nip05VerificationRepository } from '../repositories/nip05-verification-repository' import { UserRepository } from '../repositories/user-repository' import { webSocketAdapterFactory } from './websocket-adapter-factory' @@ -22,6 +23,7 @@ export const workerFactory = (): AppWorker => { const eventRepository = new EventRepository(dbClient, readReplicaDbClient, createSettings) const userRepository = new UserRepository(dbClient, eventRepository) const nip05VerificationRepository = new Nip05VerificationRepository(dbClient) + const inviteCodeRepository = new InviteCodeRepository(dbClient) const settings = createSettings() @@ -63,7 +65,7 @@ export const workerFactory = (): AppWorker => { const adapter = new WebSocketServerAdapter( server, webSocketServer, - webSocketAdapterFactory(eventRepository, userRepository, nip05VerificationRepository), + webSocketAdapterFactory(eventRepository, userRepository, nip05VerificationRepository, inviteCodeRepository), createSettings, ) diff --git a/src/handlers/event-message-handler.ts b/src/handlers/event-message-handler.ts index 5ac88b71..13961483 100644 --- a/src/handlers/event-message-handler.ts +++ b/src/handlers/event-message-handler.ts @@ -30,7 +30,7 @@ import { } from '../utils/event' import { IEventRepository, INip05VerificationRepository, IUserRepository } from '../@types/repositories' import { IEventStrategy, IMessageHandler } from '../@types/message-handlers' -import { CacheAdmissionState } from '../constants/caching' +import { admissionCacheKey, CacheAdmissionState } from '../constants/caching' import { createEventCommandResult } from '../telemetry/event-metrics' import { createLogger } from '../factories/logger-factory' import { Factory } from '../@types/base' @@ -372,7 +372,10 @@ export class EventMessageHandler implements IMessageHandler { protected async isUserAdmitted(event: Event): Promise { const currentSettings = this.settings() - if (!currentSettings.payments?.enabled) { + const paymentsEnabled = currentSettings.payments?.enabled === true + // NIP-43: membership mode gates writes on admission even without payments + const nip43Enabled = currentSettings.nip43?.enabled === true + if (!paymentsEnabled && !nip43Enabled) { return } @@ -380,17 +383,29 @@ export class EventMessageHandler implements IMessageHandler { return } + // NIP-43: join/leave requests must bypass admission — they ARE the admission flow + if (event.kind === EventKinds.NIP43_JOIN_REQUEST || event.kind === EventKinds.NIP43_LEAVE_REQUEST) { + return + } + const isApplicableFee = (feeSchedule: FeeSchedule) => feeSchedule.enabled && !feeSchedule.whitelists?.pubkeys?.includes(event.pubkey) && !feeSchedule.whitelists?.event_kinds?.some(isEventKindOrRangeMatch(event)) - const feeSchedules = currentSettings.payments?.feeSchedules?.admission?.filter(isApplicableFee) - if (!Array.isArray(feeSchedules) || !feeSchedules.length) { + const feeSchedules = paymentsEnabled + ? currentSettings.payments?.feeSchedules?.admission?.filter(isApplicableFee) + : undefined + const admissionFeeRequired = Array.isArray(feeSchedules) && feeSchedules.length > 0 + if (!admissionFeeRequired && !nip43Enabled) { return } - const cacheKey = `${event.pubkey}:is-admitted` + const notAdmittedReason = nip43Enabled + ? 'restricted: pubkey not admitted; send a join request (kind 28934) with a valid invite code' + : 'blocked: pubkey not admitted' + + const cacheKey = admissionCacheKey(event.pubkey) try { const cachedValue = await this.cache.getKey(cacheKey) @@ -400,7 +415,7 @@ export class EventMessageHandler implements IMessageHandler { } if (cachedValue === CacheAdmissionState.BLOCKED_NOT_ADMITTED) { logger('cache hit for %s admission: blocked', event.pubkey) - return 'blocked: pubkey not admitted' + return notAdmittedReason } if (cachedValue === CacheAdmissionState.BLOCKED_INSUFFICIENT_BALANCE) { logger('cache hit for %s admission: insufficient balance', event.pubkey) @@ -413,10 +428,10 @@ export class EventMessageHandler implements IMessageHandler { const user = await this.userRepository.findByPubkey(event.pubkey) if (!user || !user.isAdmitted) { this.cacheSet(cacheKey, CacheAdmissionState.BLOCKED_NOT_ADMITTED, 60) - return 'blocked: pubkey not admitted' + return notAdmittedReason } - const minBalance = currentSettings.limits?.event?.pubkey?.minBalance ?? 0n + const minBalance = paymentsEnabled ? currentSettings.limits?.event?.pubkey?.minBalance ?? 0n : 0n if (minBalance > 0n && user.balance < minBalance) { this.cacheSet(cacheKey, CacheAdmissionState.BLOCKED_INSUFFICIENT_BALANCE, 60) return 'blocked: insufficient balance' @@ -453,6 +468,12 @@ export class EventMessageHandler implements IMessageHandler { return } + // NIP-43: join/leave requests bypass NIP-05 — a new user won't have a + // verification record yet, and requiring one would block the join flow. + if (event.kind === EventKinds.NIP43_JOIN_REQUEST || event.kind === EventKinds.NIP43_LEAVE_REQUEST) { + return + } + if (event.kind === EventKinds.SET_METADATA) { return } diff --git a/src/handlers/event-strategies/join-request-event-strategy.ts b/src/handlers/event-strategies/join-request-event-strategy.ts new file mode 100644 index 00000000..9a16d971 --- /dev/null +++ b/src/handlers/event-strategies/join-request-event-strategy.ts @@ -0,0 +1,89 @@ +import { getClaimTag, isNip43RequestTimestampValid } from '../../utils/nip43' +import { ICacheAdapter, IWebSocketAdapter } from '../../@types/adapters' +import { IInviteCodeRepository, IUserRepository } from '../../@types/repositories' +import { admissionCacheKey } from '../../constants/caching' +import { createEventCommandResult } from '../../telemetry/event-metrics' +import { createLogger } from '../../factories/logger-factory' +import { Event } from '../../@types/event' +import { IEventStrategy } from '../../@types/message-handlers' +import { Settings } from '../../@types/settings' +import { WebSocketAdapterEvent } from '../../constants/adapter' + +const logger = createLogger('join-request-event-strategy') + +// Join requests are answered with an OK result but never broadcast to +// subscribers: the claim tag would leak the invite code to anyone +// subscribed to kind 28934. +export class JoinRequestEventStrategy implements IEventStrategy> { + public constructor( + private readonly webSocket: IWebSocketAdapter, + private readonly inviteCodeRepository: IInviteCodeRepository, + private readonly userRepository: IUserRepository, + private readonly cache: ICacheAdapter, + private readonly settings: () => Settings, + ) {} + + public async execute(event: Event): Promise { + const currentSettings = this.settings() + + if (!currentSettings.nip43?.enabled) { + logger('NIP-43 disabled, rejecting join request from %s', event.pubkey) + this.sendResult(event.id, false, 'restricted: NIP-43 is not enabled on this relay') + return + } + + if (!isNip43RequestTimestampValid(event)) { + logger('join request from %s has stale created_at', event.pubkey) + this.sendResult(event.id, false, 'invalid: created_at is too far from the current time') + return + } + + if (!this.webSocket.getAuthenticatedPubkeys().has(event.pubkey)) { + logger('unauthenticated join request from %s', event.pubkey) + this.sendResult(event.id, false, 'auth-required: authentication required (NIP-42)') + return + } + + const claimCode = getClaimTag(event) + if (!claimCode) { + logger('join request from %s missing claim tag', event.pubkey) + this.sendResult(event.id, false, 'invalid: join request requires a claim tag') + return + } + + const existingUser = await this.userRepository.findByPubkey(event.pubkey) + if (existingUser?.isAdmitted) { + logger('join request from %s: already admitted', event.pubkey) + this.sendResult(event.id, true, 'duplicate: you are already a member of this relay') + return + } + + const claimed = await this.inviteCodeRepository.claimCode(claimCode, event.pubkey) + if (!claimed) { + logger('join request from %s: claim failed', event.pubkey) + this.sendResult(event.id, false, 'restricted: invalid or expired invite code') + return + } + + // Not transactional with claimCode: if admitUser throws, one code use is + // wasted. Acceptable — the alternative order (admit before claim) could + // admit users without a valid code. + await this.userRepository.admitUser(event.pubkey, new Date()) + + // Drop any cached "not admitted" verdict so the new member's next event + // isn't rejected by a stale admission cache entry. + try { + await this.cache.deleteKey(admissionCacheKey(event.pubkey)) + } catch (error) { + logger('unable to invalidate admission cache for %s: %o', event.pubkey, error) + } + + const relayUrl = currentSettings.info?.relay_url ?? 'this relay' + logger('admitted %s via invite code', event.pubkey) + this.sendResult(event.id, true, `info: welcome to ${relayUrl}!`) + } + + private sendResult(eventId: string, successful: boolean, message: string): void { + this.webSocket.emit(WebSocketAdapterEvent.Message, createEventCommandResult(eventId, successful, message)) + } +} diff --git a/src/handlers/event-strategies/leave-request-event-strategy.ts b/src/handlers/event-strategies/leave-request-event-strategy.ts new file mode 100644 index 00000000..f5499899 --- /dev/null +++ b/src/handlers/event-strategies/leave-request-event-strategy.ts @@ -0,0 +1,76 @@ +import { ICacheAdapter, IWebSocketAdapter } from '../../@types/adapters' +import { admissionCacheKey } from '../../constants/caching' +import { createEventCommandResult } from '../../telemetry/event-metrics' +import { createLogger } from '../../factories/logger-factory' +import { Event } from '../../@types/event' +import { IEventStrategy } from '../../@types/message-handlers' +import { isNip43RequestTimestampValid } from '../../utils/nip43' +import { isProtectedEvent } from '../../utils/event' +import { IUserRepository } from '../../@types/repositories' +import { Settings } from '../../@types/settings' +import { WebSocketAdapterEvent } from '../../constants/adapter' + +const logger = createLogger('leave-request-event-strategy') + +export class LeaveRequestEventStrategy implements IEventStrategy> { + public constructor( + private readonly webSocket: IWebSocketAdapter, + private readonly userRepository: IUserRepository, + private readonly cache: ICacheAdapter, + private readonly settings: () => Settings, + ) {} + + public async execute(event: Event): Promise { + const currentSettings = this.settings() + + if (!currentSettings.nip43?.enabled) { + logger('NIP-43 disabled, rejecting leave request from %s', event.pubkey) + this.sendResult(event.id, false, 'restricted: NIP-43 is not enabled on this relay') + return + } + + // NIP-43 requires leave requests to carry a NIP-70 "-" tag + if (!isProtectedEvent(event)) { + logger('leave request from %s missing "-" tag', event.pubkey) + this.sendResult(event.id, false, 'invalid: leave request requires a NIP-70 "-" tag') + return + } + + if (!isNip43RequestTimestampValid(event)) { + logger('leave request from %s has stale created_at', event.pubkey) + this.sendResult(event.id, false, 'invalid: created_at is too far from the current time') + return + } + + if (!this.webSocket.getAuthenticatedPubkeys().has(event.pubkey)) { + logger('unauthenticated leave request from %s', event.pubkey) + this.sendResult(event.id, false, 'auth-required: authentication required (NIP-42)') + return + } + + // Avoids creating phantom user rows for non-members. + const existingUser = await this.userRepository.findByPubkey(event.pubkey) + if (!existingUser?.isAdmitted) { + logger('leave request from %s: not currently admitted', event.pubkey) + this.sendResult(event.id, true, '') + return + } + + await this.userRepository.revokeAdmission(event.pubkey) + + // Drop any cached "admitted" verdict so the departed member can't keep + // publishing on a stale admission cache entry. + try { + await this.cache.deleteKey(admissionCacheKey(event.pubkey)) + } catch (error) { + logger('unable to invalidate admission cache for %s: %o', event.pubkey, error) + } + + logger('revoked admission for %s', event.pubkey) + this.sendResult(event.id, true, '') + } + + private sendResult(eventId: string, successful: boolean, message: string): void { + this.webSocket.emit(WebSocketAdapterEvent.Message, createEventCommandResult(eventId, successful, message)) + } +} diff --git a/src/handlers/request-handlers/root-request-handler.ts b/src/handlers/request-handlers/root-request-handler.ts index f322057f..80c76259 100644 --- a/src/handlers/request-handlers/root-request-handler.ts +++ b/src/handlers/request-handlers/root-request-handler.ts @@ -60,6 +60,7 @@ export const rootRequestHandler = (request: Request, response: Response, next: N Boolean(settings.payments?.feeSchedules?.admission?.some((feeSchedule) => feeSchedule.enabled)) const hasWriteRestriction = hasAdmissionRestriction || + settings.nip43?.enabled === true || (eventLimits?.eventId?.minLeadingZeroBits ?? 0) > 0 || (eventLimits?.pubkey?.minLeadingZeroBits ?? 0) > 0 || (eventLimits?.pubkey?.whitelist?.length ?? 0) > 0 || @@ -78,7 +79,11 @@ export const rootRequestHandler = (request: Request, response: Response, next: N pubkey, ...(self !== undefined ? { self } : {}), contact, - supported_nips: packageJson.supportedNips, + // NIP-43: clients MUST only send join requests to relays advertising 43, + // so only advertise it when the feature is actually enabled + supported_nips: settings.nip43?.enabled === true + ? packageJson.supportedNips + : packageJson.supportedNips.filter((nip: number) => nip !== 43), supported_nip_extensions: packageJson.supportedNipExtensions, supported_mips: packageJson.supportedMips, software: packageJson.repository.url, diff --git a/src/repositories/user-repository.ts b/src/repositories/user-repository.ts index 8d5d9986..eb120b77 100644 --- a/src/repositories/user-repository.ts +++ b/src/repositories/user-repository.ts @@ -113,6 +113,21 @@ export class UserRepository implements IUserRepository { return BigInt(user.balance) } + // Targeted update instead of upsert so admission revocation cannot clobber + // unrelated columns (is_vanished, tos_accepted_at) on the existing row. + public async revokeAdmission(pubkey: Pubkey, client: DatabaseClient = this.dbClient): Promise { + logger('revoke admission: %s', pubkey) + + const result = await client('users') + .where('pubkey', toBuffer(pubkey)) + .update({ + is_admitted: false, + updated_at: new Date(), + }) + + return typeof result === 'number' ? result : 0 + } + public async admitUser(pubkey: Pubkey, admittedAt: Date, client: DatabaseClient = this.dbClient): Promise { logger('admit user: %s at %s', pubkey, admittedAt) diff --git a/src/utils/nip43.ts b/src/utils/nip43.ts new file mode 100644 index 00000000..2ac3dc36 --- /dev/null +++ b/src/utils/nip43.ts @@ -0,0 +1,20 @@ +import { Event } from '../@types/event' +import { EventKinds, EventTags } from '../constants/base' + +export const isNip43JoinRequest = (event: Event): boolean => + event.kind === EventKinds.NIP43_JOIN_REQUEST + +export const isNip43LeaveRequest = (event: Event): boolean => + event.kind === EventKinds.NIP43_LEAVE_REQUEST + +export const getClaimTag = (event: Event): string | undefined => { + const tag = event.tags.find((t) => t.length >= 2 && t[0] === EventTags.Claim) + return tag?.[1] +} + +// NIP-43 requires join/leave request created_at to be "now, plus or minus a few +// minutes". Same tolerance as the NIP-42 auth handler. +const MAX_TIMESTAMP_DELTA_SECONDS = 600 + +export const isNip43RequestTimestampValid = (event: Event): boolean => + Math.abs(Math.floor(Date.now() / 1000) - event.created_at) <= MAX_TIMESTAMP_DELTA_SECONDS diff --git a/test/integration/features/nip-11/nip-11.feature.ts b/test/integration/features/nip-11/nip-11.feature.ts index 4d7413ba..dd706f6a 100644 --- a/test/integration/features/nip-11/nip-11.feature.ts +++ b/test/integration/features/nip-11/nip-11.feature.ts @@ -67,7 +67,9 @@ Then('the relay information document contains the required fields', function(thi Then('the supported_nips field matches the NIPs declared in package.json', function(this: World>) { const doc = this.parameters.httpResponse.data - expect(doc.supported_nips).to.deep.equal(packageJson.supportedNips) + // NIP-43 is only advertised when enabled in settings; the integration + // relay runs with NIP-43 disabled, so it is filtered from the document. + expect(doc.supported_nips).to.deep.equal(packageJson.supportedNips.filter((nip: number) => nip !== 43)) }) Then('the response body is not a relay information document', function(this: World>) { diff --git a/test/unit/factories/event-strategy-factory.spec.ts b/test/unit/factories/event-strategy-factory.spec.ts index e94e75dc..37dc159e 100644 --- a/test/unit/factories/event-strategy-factory.spec.ts +++ b/test/unit/factories/event-strategy-factory.spec.ts @@ -1,6 +1,6 @@ import { expect } from 'chai' -import { IEventRepository, IUserRepository } from '../../../src/@types/repositories' +import { IEventRepository, IInviteCodeRepository, IUserRepository } from '../../../src/@types/repositories' import { DefaultEventStrategy } from '../../../src/handlers/event-strategies/default-event-strategy' import { DeleteEventStrategy } from '../../../src/handlers/event-strategies/delete-event-strategy' import { EphemeralEventStrategy } from '../../../src/handlers/event-strategies/ephemeral-event-strategy' @@ -11,15 +11,21 @@ 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 { ICacheAdapter, IWebSocketAdapter } from '../../../src/@types/adapters' +import { JoinRequestEventStrategy } from '../../../src/handlers/event-strategies/join-request-event-strategy' +import { LeaveRequestEventStrategy } from '../../../src/handlers/event-strategies/leave-request-event-strategy' import { ParameterizedReplaceableEventStrategy } from '../../../src/handlers/event-strategies/parameterized-replaceable-event-strategy' import { ReplaceableEventStrategy } from '../../../src/handlers/event-strategies/replaceable-event-strategy' +import { Settings } from '../../../src/@types/settings' import { TimestampEventStrategy } from '../../../src/handlers/event-strategies/timestamp-event-strategy' import { VanishEventStrategy } from '../../../src/handlers/event-strategies/vanish-event-strategy' describe('eventStrategyFactory', () => { let eventRepository: IEventRepository let userRepository: IUserRepository + let inviteCodeRepository: IInviteCodeRepository + let cache: ICacheAdapter + let settings: () => Settings let event: Event let adapter: IWebSocketAdapter let factory: Factory>, [Event, IWebSocketAdapter]> @@ -27,10 +33,13 @@ describe('eventStrategyFactory', () => { beforeEach(() => { eventRepository = {} as any userRepository = {} as any + inviteCodeRepository = {} as any + cache = {} as any + settings = () => ({ info: { relay_url: 'wss://test.relay' } } as any) event = {} as any adapter = {} as any - factory = eventStrategyFactory(eventRepository, userRepository) + factory = eventStrategyFactory(eventRepository, userRepository, inviteCodeRepository, cache, settings) }) it('returns ReplaceableEvent given a set_metadata event', () => { @@ -117,4 +126,14 @@ describe('eventStrategyFactory', () => { event.kind = EventKinds.EXTERNAL_CONTENT_REACTION expect(factory([event, adapter])).to.be.an.instanceOf(DefaultEventStrategy) }) + + it('returns JoinRequestEventStrategy given a NIP-43 join request (kind 28934)', () => { + event.kind = EventKinds.NIP43_JOIN_REQUEST + expect(factory([event, adapter])).to.be.an.instanceOf(JoinRequestEventStrategy) + }) + + it('returns LeaveRequestEventStrategy given a NIP-43 leave request (kind 28936)', () => { + event.kind = EventKinds.NIP43_LEAVE_REQUEST + expect(factory([event, adapter])).to.be.an.instanceOf(LeaveRequestEventStrategy) + }) }) diff --git a/test/unit/factories/message-handler-factory.spec.ts b/test/unit/factories/message-handler-factory.spec.ts index 1878b703..68844389 100644 --- a/test/unit/factories/message-handler-factory.spec.ts +++ b/test/unit/factories/message-handler-factory.spec.ts @@ -1,6 +1,6 @@ import { expect } from 'chai' -import { IEventRepository, INip05VerificationRepository, IUserRepository } from '../../../src/@types/repositories' +import { IEventRepository, IInviteCodeRepository, 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' @@ -18,6 +18,7 @@ describe('messageHandlerFactory', () => { let eventRepository: IEventRepository let userRepository: IUserRepository let nip05VerificationRepository: INip05VerificationRepository + let inviteCodeRepository: IInviteCodeRepository let message: IncomingMessage let adapter: IWebSocketAdapter let factory @@ -40,11 +41,12 @@ describe('messageHandlerFactory', () => { eventRepository = {} as any userRepository = {} as any nip05VerificationRepository = {} as any + inviteCodeRepository = {} as any adapter = {} as any event = { tags: [], } as any - factory = messageHandlerFactory(eventRepository, userRepository, nip05VerificationRepository) + factory = messageHandlerFactory(eventRepository, userRepository, nip05VerificationRepository, inviteCodeRepository) }) afterEach(() => { diff --git a/test/unit/factories/websocket-adapter-factory.spec.ts b/test/unit/factories/websocket-adapter-factory.spec.ts index c282cb83..d1ec9405 100644 --- a/test/unit/factories/websocket-adapter-factory.spec.ts +++ b/test/unit/factories/websocket-adapter-factory.spec.ts @@ -3,7 +3,7 @@ import { IncomingMessage } from 'http' import Sinon from 'sinon' import WebSocket from 'ws' -import { IEventRepository, INip05VerificationRepository, IUserRepository } from '../../../src/@types/repositories' +import { IEventRepository, IInviteCodeRepository, INip05VerificationRepository, IUserRepository } from '../../../src/@types/repositories' import { IWebSocketServerAdapter } from '../../../src/@types/adapters' import { SettingsStatic } from '../../../src/utils/settings' import { WebSocketAdapter } from '../../../src/adapters/web-socket-adapter' @@ -32,6 +32,7 @@ describe('webSocketAdapterFactory', () => { const eventRepository: IEventRepository = {} as any const userRepository: IUserRepository = {} as any const nip05VerificationRepository: INip05VerificationRepository = {} as any + const inviteCodeRepository: IInviteCodeRepository = {} as any const client: WebSocket = { on: onStub, @@ -47,7 +48,7 @@ describe('webSocketAdapterFactory', () => { } as any const webSocketServerAdapter: IWebSocketServerAdapter = {} as any - const factory = webSocketAdapterFactory(eventRepository, userRepository, nip05VerificationRepository) + const factory = webSocketAdapterFactory(eventRepository, userRepository, nip05VerificationRepository, inviteCodeRepository) expect(factory([client, request, webSocketServerAdapter])).to.be.an.instanceOf(WebSocketAdapter) }) }) diff --git a/test/unit/handlers/event-message-handler.spec.ts b/test/unit/handlers/event-message-handler.spec.ts index 14c894da..04d8e102 100644 --- a/test/unit/handlers/event-message-handler.spec.ts +++ b/test/unit/handlers/event-message-handler.spec.ts @@ -1196,6 +1196,52 @@ describe('EventMessageHandler', () => { return expect((handler as any).isUserAdmitted(event)).to.eventually.be.undefined }) + describe('NIP-43 membership', () => { + beforeEach(() => { + settings.payments.enabled = false + ;(settings as any).nip43 = { enabled: true } + }) + + it('fulfills with restricted reason if user is not admitted and payments are disabled', async () => { + userRepositoryFindByPubkeyStub.resolves(undefined) + + return expect((handler as any).isUserAdmitted(event)).to.eventually.contain('restricted:') + }) + + it('fulfills with restricted reason if user exists but is not admitted', async () => { + userRepositoryFindByPubkeyStub.resolves({ isAdmitted: false, isVanished: false }) + + return expect((handler as any).isUserAdmitted(event)).to.eventually.contain('restricted:') + }) + + it('fulfills with undefined if user is admitted', async () => { + userRepositoryFindByPubkeyStub.resolves({ isAdmitted: true, isVanished: false }) + + return expect((handler as any).isUserAdmitted(event)).to.eventually.be.undefined + }) + + it('fulfills with undefined for a join request from a non-member', async () => { + event.kind = EventKinds.NIP43_JOIN_REQUEST + userRepositoryFindByPubkeyStub.resolves(undefined) + + return expect((handler as any).isUserAdmitted(event)).to.eventually.be.undefined + }) + + it('fulfills with undefined for a leave request from a non-member', async () => { + event.kind = EventKinds.NIP43_LEAVE_REQUEST + userRepositoryFindByPubkeyStub.resolves(undefined) + + return expect((handler as any).isUserAdmitted(event)).to.eventually.be.undefined + }) + + it('skips the minimum balance check when payments are disabled', async () => { + settings.limits.event.pubkey.minBalance = 1000n + userRepositoryFindByPubkeyStub.resolves({ isAdmitted: true, isVanished: false, balance: 0n }) + + return expect((handler as any).isUserAdmitted(event)).to.eventually.be.undefined + }) + }) + describe('caching', () => { it('falls back to repository lookup when cache read fails', async () => { cacheStub.getKey.rejects(new Error('cache unavailable')) diff --git a/test/unit/handlers/event-strategies/join-request-event-strategy.spec.ts b/test/unit/handlers/event-strategies/join-request-event-strategy.spec.ts new file mode 100644 index 00000000..d2260595 --- /dev/null +++ b/test/unit/handlers/event-strategies/join-request-event-strategy.spec.ts @@ -0,0 +1,163 @@ +import { expect } from 'chai' +import sinon from 'sinon' + +import { EventKinds, EventTags } from '../../../../src/constants/base' +import { ICacheAdapter, IWebSocketAdapter } from '../../../../src/@types/adapters' +import { IInviteCodeRepository, IUserRepository } from '../../../../src/@types/repositories' +import { admissionCacheKey } from '../../../../src/constants/caching' +import { Event } from '../../../../src/@types/event' +import { JoinRequestEventStrategy } from '../../../../src/handlers/event-strategies/join-request-event-strategy' +import { Settings } from '../../../../src/@types/settings' +import { WebSocketAdapterEvent } from '../../../../src/constants/adapter' + +describe('JoinRequestEventStrategy', () => { + let adapter: IWebSocketAdapter + let inviteCodeRepository: IInviteCodeRepository + let userRepository: IUserRepository + let cache: ICacheAdapter + let settings: () => Settings + let strategy: JoinRequestEventStrategy + let emitStub: sinon.SinonStub + let event: Event + + beforeEach(() => { + emitStub = sinon.stub() + adapter = { + emit: emitStub, + getAuthenticatedPubkeys: sinon.stub().returns(new Set(['aabbccdd'])), + } as any + + inviteCodeRepository = { + claimCode: sinon.stub(), + } as any + + userRepository = { + findByPubkey: sinon.stub(), + admitUser: sinon.stub(), + } as any + + cache = { + deleteKey: sinon.stub().resolves(1), + } as any + + settings = () => ({ + info: { relay_url: 'wss://test.relay' }, + nip43: { enabled: true }, + } as any) + + strategy = new JoinRequestEventStrategy(adapter, inviteCodeRepository, userRepository, cache, settings) + + event = { + id: 'eventid123', + kind: EventKinds.NIP43_JOIN_REQUEST, + pubkey: 'aabbccdd', + tags: [[EventTags.Claim, 'valid-code']], + content: '', + created_at: Math.floor(Date.now() / 1000), + sig: 'sig', + } as any + }) + + it('rejects when NIP-43 is disabled', async () => { + settings = () => ({ info: { relay_url: 'wss://test.relay' }, nip43: { enabled: false } } as any) + strategy = new JoinRequestEventStrategy(adapter, inviteCodeRepository, userRepository, cache, settings) + + await strategy.execute(event) + + expect(emitStub.calledOnce).to.be.true + const [eventName, result] = emitStub.firstCall.args + expect(eventName).to.equal(WebSocketAdapterEvent.Message) + expect(result[1]).to.equal(event.id) + expect(result[2]).to.be.false + expect(result[3]).to.contain('NIP-43 is not enabled') + }) + + it('rejects requests with stale created_at', async () => { + event.created_at = Math.floor(Date.now() / 1000) - 3600 + + await strategy.execute(event) + + expect(emitStub.calledOnce).to.be.true + const result = emitStub.firstCall.args[1] + expect(result[2]).to.be.false + expect(result[3]).to.contain('created_at is too far from the current time') + }) + + it('rejects unauthenticated requests with auth-required prefix', async () => { + ;(adapter.getAuthenticatedPubkeys as sinon.SinonStub).returns(new Set()) + + await strategy.execute(event) + + expect(emitStub.calledOnce).to.be.true + const result = emitStub.firstCall.args[1] + expect(result[2]).to.be.false + expect(result[3]).to.contain('auth-required:') + expect(result[3]).to.contain('authentication required') + }) + + it('rejects events missing the claim tag', async () => { + event.tags = [] + + await strategy.execute(event) + + expect(emitStub.calledOnce).to.be.true + const result = emitStub.firstCall.args[1] + expect(result[2]).to.be.false + expect(result[3]).to.contain('claim tag') + }) + + it('returns duplicate when user is already admitted', async () => { + ;(userRepository.findByPubkey as sinon.SinonStub).resolves({ isAdmitted: true }) + + await strategy.execute(event) + + expect(emitStub.calledOnce).to.be.true + const result = emitStub.firstCall.args[1] + expect(result[2]).to.be.true + expect(result[3]).to.contain('duplicate:') + }) + + it('rejects when invite code is invalid or expired', async () => { + ;(userRepository.findByPubkey as sinon.SinonStub).resolves(undefined) + ;(inviteCodeRepository.claimCode as sinon.SinonStub).resolves(false) + + await strategy.execute(event) + + expect(emitStub.calledOnce).to.be.true + const result = emitStub.firstCall.args[1] + expect(result[2]).to.be.false + expect(result[3]).to.contain('restricted:') + expect(result[3]).to.contain('invalid or expired') + }) + + it('admits user and invalidates admission cache on valid claim', async () => { + ;(userRepository.findByPubkey as sinon.SinonStub).resolves(undefined) + ;(inviteCodeRepository.claimCode as sinon.SinonStub).resolves(true) + ;(userRepository.admitUser as sinon.SinonStub).resolves() + + await strategy.execute(event) + + expect((inviteCodeRepository.claimCode as sinon.SinonStub).calledOnceWith('valid-code', 'aabbccdd')).to.be.true + expect((userRepository.admitUser as sinon.SinonStub).calledOnce).to.be.true + expect((cache.deleteKey as sinon.SinonStub).calledOnceWith(admissionCacheKey('aabbccdd'))).to.be.true + + expect(emitStub.calledOnce).to.be.true + const result = emitStub.firstCall.args[1] + expect(result[2]).to.be.true + expect(result[3]).to.contain('welcome to') + }) + + it('still admits user when cache invalidation fails', async () => { + ;(userRepository.findByPubkey as sinon.SinonStub).resolves(undefined) + ;(inviteCodeRepository.claimCode as sinon.SinonStub).resolves(true) + ;(userRepository.admitUser as sinon.SinonStub).resolves() + ;(cache.deleteKey as sinon.SinonStub).rejects(new Error('cache down')) + + await strategy.execute(event) + + expect(emitStub.calledOnce).to.be.true + const result = emitStub.firstCall.args[1] + expect(result[2]).to.be.true + expect(result[3]).to.contain('welcome to') + }) +}) diff --git a/test/unit/handlers/event-strategies/leave-request-event-strategy.spec.ts b/test/unit/handlers/event-strategies/leave-request-event-strategy.spec.ts new file mode 100644 index 00000000..37354b89 --- /dev/null +++ b/test/unit/handlers/event-strategies/leave-request-event-strategy.spec.ts @@ -0,0 +1,147 @@ +import { expect } from 'chai' +import sinon from 'sinon' + +import { ICacheAdapter, IWebSocketAdapter } from '../../../../src/@types/adapters' +import { admissionCacheKey } from '../../../../src/constants/caching' +import { Event } from '../../../../src/@types/event' +import { EventKinds } from '../../../../src/constants/base' +import { IUserRepository } from '../../../../src/@types/repositories' +import { LeaveRequestEventStrategy } from '../../../../src/handlers/event-strategies/leave-request-event-strategy' +import { Settings } from '../../../../src/@types/settings' +import { WebSocketAdapterEvent } from '../../../../src/constants/adapter' + +describe('LeaveRequestEventStrategy', () => { + let adapter: IWebSocketAdapter + let userRepository: IUserRepository + let cache: ICacheAdapter + let settings: () => Settings + let strategy: LeaveRequestEventStrategy + let emitStub: sinon.SinonStub + let event: Event + + beforeEach(() => { + emitStub = sinon.stub() + adapter = { + emit: emitStub, + getAuthenticatedPubkeys: sinon.stub().returns(new Set(['aabbccdd'])), + } as any + + userRepository = { + findByPubkey: sinon.stub().resolves({ isAdmitted: true }), + revokeAdmission: sinon.stub().resolves(1), + } as any + + cache = { + deleteKey: sinon.stub().resolves(1), + } as any + + settings = () => ({ + info: { relay_url: 'wss://test.relay' }, + nip43: { enabled: true }, + } as any) + + strategy = new LeaveRequestEventStrategy(adapter, userRepository, cache, settings) + + event = { + id: 'eventid456', + kind: EventKinds.NIP43_LEAVE_REQUEST, + pubkey: 'aabbccdd', + tags: [['-']], + content: '', + created_at: Math.floor(Date.now() / 1000), + sig: 'sig', + } as any + }) + + it('rejects when NIP-43 is disabled', async () => { + settings = () => ({ info: { relay_url: 'wss://test.relay' }, nip43: { enabled: false } } as any) + strategy = new LeaveRequestEventStrategy(adapter, userRepository, cache, settings) + + await strategy.execute(event) + + expect(emitStub.calledOnce).to.be.true + const result = emitStub.firstCall.args[1] + expect(result[2]).to.be.false + expect(result[3]).to.contain('NIP-43 is not enabled') + }) + + it('rejects leave requests missing the NIP-70 "-" tag', async () => { + event.tags = [] + + await strategy.execute(event) + + expect((userRepository.revokeAdmission as sinon.SinonStub).called).to.be.false + expect(emitStub.calledOnce).to.be.true + const result = emitStub.firstCall.args[1] + expect(result[2]).to.be.false + expect(result[3]).to.contain('"-" tag') + }) + + it('rejects requests with stale created_at', async () => { + event.created_at = Math.floor(Date.now() / 1000) - 3600 + + await strategy.execute(event) + + expect(emitStub.calledOnce).to.be.true + const result = emitStub.firstCall.args[1] + expect(result[2]).to.be.false + expect(result[3]).to.contain('created_at is too far from the current time') + }) + + it('rejects unauthenticated requests with auth-required prefix', async () => { + ;(adapter.getAuthenticatedPubkeys as sinon.SinonStub).returns(new Set()) + + await strategy.execute(event) + + expect(emitStub.calledOnce).to.be.true + const result = emitStub.firstCall.args[1] + expect(result[2]).to.be.false + expect(result[3]).to.contain('auth-required:') + expect(result[3]).to.contain('authentication required') + }) + + it('succeeds with no-op when user is not currently admitted', async () => { + ;(userRepository.findByPubkey as sinon.SinonStub).resolves(undefined) + + await strategy.execute(event) + + expect((userRepository.revokeAdmission as sinon.SinonStub).called).to.be.false + expect(emitStub.calledOnce).to.be.true + const result = emitStub.firstCall.args[1] + expect(result[2]).to.be.true + }) + + it('succeeds with no-op when user exists but is not admitted', async () => { + ;(userRepository.findByPubkey as sinon.SinonStub).resolves({ isAdmitted: false }) + + await strategy.execute(event) + + expect((userRepository.revokeAdmission as sinon.SinonStub).called).to.be.false + expect(emitStub.calledOnce).to.be.true + const result = emitStub.firstCall.args[1] + expect(result[2]).to.be.true + }) + + it('revokes admission and invalidates admission cache', async () => { + await strategy.execute(event) + + expect((userRepository.revokeAdmission as sinon.SinonStub).calledOnceWith('aabbccdd')).to.be.true + expect((cache.deleteKey as sinon.SinonStub).calledOnceWith(admissionCacheKey('aabbccdd'))).to.be.true + + expect(emitStub.calledOnce).to.be.true + const [eventName, result] = emitStub.firstCall.args + expect(eventName).to.equal(WebSocketAdapterEvent.Message) + expect(result[2]).to.be.true + }) + + it('still revokes admission when cache invalidation fails', async () => { + ;(cache.deleteKey as sinon.SinonStub).rejects(new Error('cache down')) + + await strategy.execute(event) + + expect((userRepository.revokeAdmission as sinon.SinonStub).calledOnce).to.be.true + expect(emitStub.calledOnce).to.be.true + const result = emitStub.firstCall.args[1] + expect(result[2]).to.be.true + }) +}) diff --git a/test/unit/repositories/user-repository.spec.ts b/test/unit/repositories/user-repository.spec.ts index 07e1a1bd..99e15c8a 100644 --- a/test/unit/repositories/user-repository.spec.ts +++ b/test/unit/repositories/user-repository.spec.ts @@ -327,4 +327,30 @@ describe('UserRepository', () => { ).to.be.rejectedWith(dbError) }) }) + + describe('.revokeAdmission', () => { + it('updates only is_admitted and updated_at for the given pubkey', async () => { + const updateStub = sandbox.stub().resolves(1) + const whereStub = sandbox.stub().returns({ update: updateStub }) + const client = sandbox.stub().returns({ where: whereStub }) as unknown as DatabaseClient + + const result = await repository.revokeAdmission(pubkeyHex, client) + + expect(client).to.have.been.calledWith('users') + expect(whereStub).to.have.been.calledWith('pubkey', Buffer.from(pubkeyHex, 'hex')) + expect(updateStub.firstCall.args[0]).to.have.keys(['is_admitted', 'updated_at']) + expect(updateStub.firstCall.args[0].is_admitted).to.equal(false) + expect(result).to.equal(1) + }) + + it('returns 0 when the driver does not report affected rows', async () => { + const updateStub = sandbox.stub().resolves(undefined) + const whereStub = sandbox.stub().returns({ update: updateStub }) + const client = sandbox.stub().returns({ where: whereStub }) as unknown as DatabaseClient + + const result = await repository.revokeAdmission(pubkeyHex, client) + + expect(result).to.equal(0) + }) + }) })