From 9c2ce97e64464af1fa870011f48f78935289bd79 Mon Sep 17 00:00:00 2001 From: anshumancanrock Date: Sat, 11 Jul 2026 23:52:31 +0530 Subject: [PATCH] feat(nip43): add join/leave request event strategies Wire InviteCodeRepository through the factory chain and add event strategies for NIP-43 access request kinds: - kind 28934 (Join Request): validates NIP-42 auth, extracts claim tag, atomically claims invite code, admits user - kind 28936 (Leave Request): validates NIP-42 auth, checks membership, revokes admission Pipeline changes: - Route NIP-43 kinds before the ephemeral catch-all (prevents broadcast) - Bypass isUserAdmitted for join/leave (they ARE the admission flow) - Bypass checkNip05Verification for join/leave (new users have no record) - Remove stale knip.json ignore for invite-code-repository Includes unit tests for both strategies and updated factory tests. --- .changeset/nip43-join-leave.md | 5 + .knip.json | 3 +- package.json | 1 + src/factories/event-strategy-factory.ts | 14 +- src/factories/message-handler-factory.ts | 5 +- src/factories/websocket-adapter-factory.ts | 6 +- src/factories/worker-factory.ts | 4 +- src/handlers/event-message-handler.ts | 11 ++ .../join-request-event-strategy.ts | 82 +++++++++++ .../leave-request-event-strategy.ts | 62 +++++++++ src/utils/nip43.ts | 13 ++ .../factories/event-strategy-factory.spec.ts | 21 ++- .../factories/message-handler-factory.spec.ts | 6 +- .../websocket-adapter-factory.spec.ts | 5 +- .../join-request-event-strategy.spec.ts | 130 ++++++++++++++++++ .../leave-request-event-strategy.spec.ts | 108 +++++++++++++++ 16 files changed, 462 insertions(+), 14 deletions(-) create mode 100644 .changeset/nip43-join-leave.md create mode 100644 src/handlers/event-strategies/join-request-event-strategy.ts create mode 100644 src/handlers/event-strategies/leave-request-event-strategy.ts create mode 100644 src/utils/nip43.ts create mode 100644 test/unit/handlers/event-strategies/join-request-event-strategy.spec.ts create mode 100644 test/unit/handlers/event-strategies/leave-request-event-strategy.spec.ts diff --git a/.changeset/nip43-join-leave.md b/.changeset/nip43-join-leave.md new file mode 100644 index 00000000..6b86bb0b --- /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, invite code claiming, and admission management. Advertise NIP-43 in NIP-11 document. 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/src/factories/event-strategy-factory.ts b/src/factories/event-strategy-factory.ts index b88aa1c6..7322531b 100644 --- a/src/factories/event-strategy-factory.ts +++ b/src/factories/event-strategy-factory.ts @@ -1,4 +1,4 @@ -import { IEventRepository, IUserRepository } from '../@types/repositories' +import { IEventRepository, IInviteCodeRepository, IUserRepository } from '../@types/repositories' import { isDeleteEvent, isEphemeralEvent, @@ -9,6 +9,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' @@ -19,8 +20,11 @@ import { GiftWrapEventStrategy } from '../handlers/event-strategies/gift-wrap-ev 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,8 @@ export const eventStrategyFactory = ( eventRepository: IEventRepository, userRepository: IUserRepository, + inviteCodeRepository: IInviteCodeRepository, + settings: () => Settings, ): Factory>, [Event, IWebSocketAdapter]> => ([event, adapter]: [Event, IWebSocketAdapter]) => { if (isRequestToVanishEvent(event)) { @@ -40,6 +46,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, settings) + } else if (isNip43LeaveRequest(event)) { + return new LeaveRequestEventStrategy(adapter, userRepository, 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..55bffdd1 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, createSettings), eventRepository, userRepository, createSettings, diff --git a/src/factories/websocket-adapter-factory.ts b/src/factories/websocket-adapter-factory.ts index d40b6aec..c797d1d5 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,15 @@ 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..671fb52e 100644 --- a/src/handlers/event-message-handler.ts +++ b/src/handlers/event-message-handler.ts @@ -380,6 +380,11 @@ 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) && @@ -453,6 +458,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..9ac3729b --- /dev/null +++ b/src/handlers/event-strategies/join-request-event-strategy.ts @@ -0,0 +1,82 @@ +import { IInviteCodeRepository, IUserRepository } from '../../@types/repositories' +import { createCommandResult } from '../../utils/messages' +import { createLogger } from '../../factories/logger-factory' +import { Event } from '../../@types/event' +import { getClaimTag } from '../../utils/nip43' +import { IEventStrategy } from '../../@types/message-handlers' +import { IWebSocketAdapter } from '../../@types/adapters' +import { Settings } from '../../@types/settings' +import { WebSocketAdapterEvent } from '../../constants/adapter' + +const logger = createLogger('join-request-event-strategy') + +export class JoinRequestEventStrategy implements IEventStrategy> { + public constructor( + private readonly webSocket: IWebSocketAdapter, + private readonly inviteCodeRepository: IInviteCodeRepository, + private readonly userRepository: IUserRepository, + 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.webSocket.emit( + WebSocketAdapterEvent.Message, + createCommandResult(event.id, false, 'restricted: NIP-43 is not enabled on this relay'), + ) + return + } + + if (!this.webSocket.getAuthenticatedPubkeys().has(event.pubkey)) { + logger('unauthenticated join request from %s', event.pubkey) + this.webSocket.emit( + WebSocketAdapterEvent.Message, + createCommandResult(event.id, false, 'restricted: authentication required (NIP-42)'), + ) + return + } + + const claimCode = getClaimTag(event) + if (!claimCode) { + logger('join request from %s missing claim tag', event.pubkey) + this.webSocket.emit( + WebSocketAdapterEvent.Message, + createCommandResult(event.id, false, 'restricted: missing claim tag'), + ) + return + } + + const existingUser = await this.userRepository.findByPubkey(event.pubkey) + if (existingUser?.isAdmitted) { + logger('join request from %s: already admitted', event.pubkey) + this.webSocket.emit( + WebSocketAdapterEvent.Message, + createCommandResult(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.webSocket.emit( + WebSocketAdapterEvent.Message, + createCommandResult(event.id, false, 'restricted: invalid or expired invite code'), + ) + return + } + + const now = new Date() + await this.userRepository.admitUser(event.pubkey, now) + + const relayUrl = currentSettings.info?.relay_url ?? 'this relay' + logger('admitted %s via invite code', event.pubkey) + this.webSocket.emit( + WebSocketAdapterEvent.Message, + createCommandResult(event.id, true, `info: welcome to ${relayUrl}!`), + ) + } +} 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..e005d139 --- /dev/null +++ b/src/handlers/event-strategies/leave-request-event-strategy.ts @@ -0,0 +1,62 @@ +import { IUserRepository } from '../../@types/repositories' +import { createCommandResult } from '../../utils/messages' +import { createLogger } from '../../factories/logger-factory' +import { Event } from '../../@types/event' +import { IEventStrategy } from '../../@types/message-handlers' +import { IWebSocketAdapter } from '../../@types/adapters' +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 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.webSocket.emit( + WebSocketAdapterEvent.Message, + createCommandResult(event.id, false, 'restricted: NIP-43 is not enabled on this relay'), + ) + return + } + + if (!this.webSocket.getAuthenticatedPubkeys().has(event.pubkey)) { + logger('unauthenticated leave request from %s', event.pubkey) + this.webSocket.emit( + WebSocketAdapterEvent.Message, + createCommandResult(event.id, false, 'restricted: 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.webSocket.emit( + WebSocketAdapterEvent.Message, + createCommandResult(event.id, true, ''), + ) + return + } + + await this.userRepository.upsert({ + pubkey: event.pubkey, + isAdmitted: false, + }) + + logger('revoked admission for %s', event.pubkey) + this.webSocket.emit( + WebSocketAdapterEvent.Message, + createCommandResult(event.id, true, ''), + ) + } +} diff --git a/src/utils/nip43.ts b/src/utils/nip43.ts new file mode 100644 index 00000000..38bcb530 --- /dev/null +++ b/src/utils/nip43.ts @@ -0,0 +1,13 @@ +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] +} diff --git a/test/unit/factories/event-strategy-factory.spec.ts b/test/unit/factories/event-strategy-factory.spec.ts index e94e75dc..bea51acf 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' @@ -12,14 +12,19 @@ import { GiftWrapEventStrategy } from '../../../src/handlers/event-strategies/gi import { GroupEventStrategy } from '../../../src/handlers/event-strategies/group-event-strategy' import { IEventStrategy } from '../../../src/@types/message-handlers' import { 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 settings: () => Settings let event: Event let adapter: IWebSocketAdapter let factory: Factory>, [Event, IWebSocketAdapter]> @@ -27,10 +32,12 @@ describe('eventStrategyFactory', () => { beforeEach(() => { eventRepository = {} as any userRepository = {} as any + inviteCodeRepository = {} 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, settings) }) it('returns ReplaceableEvent given a set_metadata event', () => { @@ -117,4 +124,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-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..449c6b27 --- /dev/null +++ b/test/unit/handlers/event-strategies/join-request-event-strategy.spec.ts @@ -0,0 +1,130 @@ +import { expect } from 'chai' +import sinon from 'sinon' + +import { Event } from '../../../../src/@types/event' +import { EventKinds, EventTags } from '../../../../src/constants/base' +import { IInviteCodeRepository, IUserRepository } from '../../../../src/@types/repositories' +import { IWebSocketAdapter } from '../../../../src/@types/adapters' +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 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 + + settings = () => ({ + info: { relay_url: 'wss://test.relay' }, + nip43: { enabled: true }, + } as any) + + strategy = new JoinRequestEventStrategy(adapter, inviteCodeRepository, userRepository, 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, 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 unauthenticated requests', 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('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('missing 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 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(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..1e964b05 --- /dev/null +++ b/test/unit/handlers/event-strategies/leave-request-event-strategy.spec.ts @@ -0,0 +1,108 @@ +import { expect } from 'chai' +import sinon from 'sinon' + +import { Event } from '../../../../src/@types/event' +import { EventKinds } from '../../../../src/constants/base' +import { IUserRepository } from '../../../../src/@types/repositories' +import { IWebSocketAdapter } from '../../../../src/@types/adapters' +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 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 }), + upsert: sinon.stub().resolves(1), + } as any + + settings = () => ({ + info: { relay_url: 'wss://test.relay' }, + nip43: { enabled: true }, + } as any) + + strategy = new LeaveRequestEventStrategy(adapter, userRepository, 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, 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 unauthenticated requests', 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('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.upsert 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.upsert 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 successfully', async () => { + await strategy.execute(event) + + expect((userRepository.upsert as sinon.SinonStub).calledOnce).to.be.true + const upsertArg = (userRepository.upsert as sinon.SinonStub).firstCall.args[0] + expect(upsertArg.pubkey).to.equal('aabbccdd') + expect(upsertArg.isAdmitted).to.be.false + + expect(emitStub.calledOnce).to.be.true + const [eventName, result] = emitStub.firstCall.args + expect(eventName).to.equal(WebSocketAdapterEvent.Message) + expect(result[2]).to.be.true + }) +})