Skip to content
Draft
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/nip43-join-leave.md
Original file line number Diff line number Diff line change
@@ -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.
3 changes: 1 addition & 2 deletions .knip.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,7 @@
"lzma-native"
],
"ignore": [
".nostr/**",
"src/repositories/invite-code-repository.ts"
".nostr/**"
],
"commitlint": false,
"eslint": false,
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
28,
33,
40,
43,
44,
45,
50,
Expand Down
14 changes: 13 additions & 1 deletion src/factories/event-strategy-factory.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { IEventRepository, IUserRepository } from '../@types/repositories'
import { IEventRepository, IInviteCodeRepository, IUserRepository } from '../@types/repositories'
import {
isDeleteEvent,
isEphemeralEvent,
Expand All @@ -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'
Expand All @@ -19,15 +20,20 @@ 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'

export const eventStrategyFactory =
(
eventRepository: IEventRepository,
userRepository: IUserRepository,
inviteCodeRepository: IInviteCodeRepository,
settings: () => Settings,
): Factory<IEventStrategy<Event, Promise<void>>, [Event, IWebSocketAdapter]> =>
([event, adapter]: [Event, IWebSocketAdapter]) => {
if (isRequestToVanishEvent(event)) {
Expand All @@ -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)) {
Expand Down
5 changes: 3 additions & 2 deletions src/factories/message-handler-factory.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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,
Expand Down
6 changes: 4 additions & 2 deletions src/factories/websocket-adapter-factory.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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,
)

4 changes: 3 additions & 1 deletion src/factories/worker-factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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()

Expand Down Expand Up @@ -63,7 +65,7 @@ export const workerFactory = (): AppWorker => {
const adapter = new WebSocketServerAdapter(
server,
webSocketServer,
webSocketAdapterFactory(eventRepository, userRepository, nip05VerificationRepository),
webSocketAdapterFactory(eventRepository, userRepository, nip05VerificationRepository, inviteCodeRepository),
createSettings,
)

Expand Down
11 changes: 11 additions & 0 deletions src/handlers/event-message-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) &&
Expand Down Expand Up @@ -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
}
Expand Down
82 changes: 82 additions & 0 deletions src/handlers/event-strategies/join-request-event-strategy.ts
Original file line number Diff line number Diff line change
@@ -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<Event, Promise<void>> {
public constructor(
private readonly webSocket: IWebSocketAdapter,
private readonly inviteCodeRepository: IInviteCodeRepository,
private readonly userRepository: IUserRepository,
private readonly settings: () => Settings,
) {}

public async execute(event: Event): Promise<void> {
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}!`),
)
}
}
62 changes: 62 additions & 0 deletions src/handlers/event-strategies/leave-request-event-strategy.ts
Original file line number Diff line number Diff line change
@@ -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<Event, Promise<void>> {
public constructor(
private readonly webSocket: IWebSocketAdapter,
private readonly userRepository: IUserRepository,
private readonly settings: () => Settings,
) {}

public async execute(event: Event): Promise<void> {
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, ''),
)
}
}
13 changes: 13 additions & 0 deletions src/utils/nip43.ts
Original file line number Diff line number Diff line change
@@ -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]
}
21 changes: 19 additions & 2 deletions test/unit/factories/event-strategy-factory.spec.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -12,25 +12,32 @@ 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<IEventStrategy<Event, Promise<void>>, [Event, IWebSocketAdapter]>

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', () => {
Expand Down Expand Up @@ -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)
})
})
Loading
Loading