Skip to content

Commit e9c9ab1

Browse files
fix(nip43): enforce membership, validate requests, and invalidate admission cache
Address review findings against the NIP-43 spec: - Enforce admission for all writes when nip43.enabled, even without payments enabled — membership was previously toothless on non-payment relays since isUserAdmitted short-circuited when payments were off. - Invalidate the per-pubkey admission cache on join and leave so membership changes take effect immediately instead of after the cache TTL (up to 60s of rejections after joining, up to 300s of continued access after leaving). - Validate created_at freshness on join/leave requests (spec MUST, same 10-minute tolerance as the NIP-42 auth handler). - Require the NIP-70 "-" tag on leave requests (spec MUST). - Use the auth-required: prefix for unauthenticated join/leave so NIP-42 clients know to AUTH and retry. - Revoke admission with a targeted UPDATE instead of a generic upsert that clobbered is_vanished on the existing row. - Emit OK results through createEventCommandResult so join/leave are counted in the accepted/rejected event metrics like other strategies. - Only advertise NIP-43 in the NIP-11 document when the feature is enabled (clients MUST key off supported_nips), and report restricted_writes accordingly. - Document the nip43 settings block in default-settings.yaml. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 9c2ce97 commit e9c9ab1

18 files changed

Lines changed: 302 additions & 88 deletions

.changeset/nip43-join-leave.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,4 @@
22
"nostream": minor
33
---
44

5-
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.
5+
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.

resources/default-settings.yaml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,11 @@ nip05:
6060
domainWhitelist: []
6161
# Block authors with NIP-05 at these domains
6262
domainBlacklist: []
63+
nip43:
64+
# NIP-43: invite-based relay membership. When enabled, only admitted members
65+
# (users who claimed an invite code via a kind 28934 join request) may
66+
# publish events. Enabling this on an open relay blocks all non-members.
67+
enabled: false
6368
nip45:
6469
enabled: true
6570
nip50:

src/@types/repositories.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ export interface IUserRepository {
5656
isVanished(pubkey: Pubkey, client?: DatabaseClient): Promise<boolean>
5757
setVanished(pubkey: Pubkey, vanished: boolean, client?: DatabaseClient): Promise<number>
5858
admitUser(pubkey: Pubkey, admittedAt: Date, client?: DatabaseClient): Promise<void>
59+
revokeAdmission(pubkey: Pubkey, client?: DatabaseClient): Promise<number>
5960
}
6061

6162
export interface INip05VerificationRepository {

src/constants/caching.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,5 @@ export enum CacheAdmissionState {
33
BLOCKED_NOT_ADMITTED = 'blocked_not_admitted',
44
BLOCKED_INSUFFICIENT_BALANCE = 'blocked_insufficient_balance',
55
}
6+
7+
export const admissionCacheKey = (pubkey: string): string => `${pubkey}:is-admitted`

src/factories/event-strategy-factory.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { ICacheAdapter, IWebSocketAdapter } from '../@types/adapters'
12
import { IEventRepository, IInviteCodeRepository, IUserRepository } from '../@types/repositories'
23
import {
34
isDeleteEvent,
@@ -19,7 +20,6 @@ import { Factory } from '../@types/base'
1920
import { GiftWrapEventStrategy } from '../handlers/event-strategies/gift-wrap-event-strategy'
2021
import { GroupEventStrategy } from '../handlers/event-strategies/group-event-strategy'
2122
import { IEventStrategy } from '../@types/message-handlers'
22-
import { IWebSocketAdapter } from '../@types/adapters'
2323
import { JoinRequestEventStrategy } from '../handlers/event-strategies/join-request-event-strategy'
2424
import { LeaveRequestEventStrategy } from '../handlers/event-strategies/leave-request-event-strategy'
2525
import { ParameterizedReplaceableEventStrategy } from '../handlers/event-strategies/parameterized-replaceable-event-strategy'
@@ -33,6 +33,7 @@ export const eventStrategyFactory =
3333
eventRepository: IEventRepository,
3434
userRepository: IUserRepository,
3535
inviteCodeRepository: IInviteCodeRepository,
36+
cache: ICacheAdapter,
3637
settings: () => Settings,
3738
): Factory<IEventStrategy<Event, Promise<void>>, [Event, IWebSocketAdapter]> =>
3839
([event, adapter]: [Event, IWebSocketAdapter]) => {
@@ -49,9 +50,9 @@ export const eventStrategyFactory =
4950
// NIP-43: Join/Leave requests MUST be checked before the generic ephemeral
5051
// handler, because kinds 28934/28936 fall in the ephemeral range (20000-29999).
5152
} else if (isNip43JoinRequest(event)) {
52-
return new JoinRequestEventStrategy(adapter, inviteCodeRepository, userRepository, settings)
53+
return new JoinRequestEventStrategy(adapter, inviteCodeRepository, userRepository, cache, settings)
5354
} else if (isNip43LeaveRequest(event)) {
54-
return new LeaveRequestEventStrategy(adapter, userRepository, settings)
55+
return new LeaveRequestEventStrategy(adapter, userRepository, cache, settings)
5556
} else if (isEphemeralEvent(event)) {
5657
return new EphemeralEventStrategy(adapter)
5758
} else if (isDeleteEvent(event)) {

src/factories/message-handler-factory.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ export const messageHandlerFactory =
3232
case MessageType.EVENT: {
3333
return new EventMessageHandler(
3434
adapter,
35-
eventStrategyFactory(eventRepository, userRepository, inviteCodeRepository, createSettings),
35+
eventStrategyFactory(eventRepository, userRepository, inviteCodeRepository, getCache(), createSettings),
3636
eventRepository,
3737
userRepository,
3838
createSettings,

src/handlers/event-message-handler.ts

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ import {
3030
} from '../utils/event'
3131
import { IEventRepository, INip05VerificationRepository, IUserRepository } from '../@types/repositories'
3232
import { IEventStrategy, IMessageHandler } from '../@types/message-handlers'
33-
import { CacheAdmissionState } from '../constants/caching'
33+
import { admissionCacheKey, CacheAdmissionState } from '../constants/caching'
3434
import { createEventCommandResult } from '../telemetry/event-metrics'
3535
import { createLogger } from '../factories/logger-factory'
3636
import { Factory } from '../@types/base'
@@ -372,7 +372,10 @@ export class EventMessageHandler implements IMessageHandler {
372372

373373
protected async isUserAdmitted(event: Event): Promise<string | undefined> {
374374
const currentSettings = this.settings()
375-
if (!currentSettings.payments?.enabled) {
375+
const paymentsEnabled = currentSettings.payments?.enabled === true
376+
// NIP-43: membership mode gates writes on admission even without payments
377+
const nip43Enabled = currentSettings.nip43?.enabled === true
378+
if (!paymentsEnabled && !nip43Enabled) {
376379
return
377380
}
378381

@@ -390,12 +393,19 @@ export class EventMessageHandler implements IMessageHandler {
390393
!feeSchedule.whitelists?.pubkeys?.includes(event.pubkey) &&
391394
!feeSchedule.whitelists?.event_kinds?.some(isEventKindOrRangeMatch(event))
392395

393-
const feeSchedules = currentSettings.payments?.feeSchedules?.admission?.filter(isApplicableFee)
394-
if (!Array.isArray(feeSchedules) || !feeSchedules.length) {
396+
const feeSchedules = paymentsEnabled
397+
? currentSettings.payments?.feeSchedules?.admission?.filter(isApplicableFee)
398+
: undefined
399+
const admissionFeeRequired = Array.isArray(feeSchedules) && feeSchedules.length > 0
400+
if (!admissionFeeRequired && !nip43Enabled) {
395401
return
396402
}
397403

398-
const cacheKey = `${event.pubkey}:is-admitted`
404+
const notAdmittedReason = nip43Enabled
405+
? 'restricted: pubkey not admitted; send a join request (kind 28934) with a valid invite code'
406+
: 'blocked: pubkey not admitted'
407+
408+
const cacheKey = admissionCacheKey(event.pubkey)
399409

400410
try {
401411
const cachedValue = await this.cache.getKey(cacheKey)
@@ -405,7 +415,7 @@ export class EventMessageHandler implements IMessageHandler {
405415
}
406416
if (cachedValue === CacheAdmissionState.BLOCKED_NOT_ADMITTED) {
407417
logger('cache hit for %s admission: blocked', event.pubkey)
408-
return 'blocked: pubkey not admitted'
418+
return notAdmittedReason
409419
}
410420
if (cachedValue === CacheAdmissionState.BLOCKED_INSUFFICIENT_BALANCE) {
411421
logger('cache hit for %s admission: insufficient balance', event.pubkey)
@@ -418,10 +428,10 @@ export class EventMessageHandler implements IMessageHandler {
418428
const user = await this.userRepository.findByPubkey(event.pubkey)
419429
if (!user || !user.isAdmitted) {
420430
this.cacheSet(cacheKey, CacheAdmissionState.BLOCKED_NOT_ADMITTED, 60)
421-
return 'blocked: pubkey not admitted'
431+
return notAdmittedReason
422432
}
423433

424-
const minBalance = currentSettings.limits?.event?.pubkey?.minBalance ?? 0n
434+
const minBalance = paymentsEnabled ? currentSettings.limits?.event?.pubkey?.minBalance ?? 0n : 0n
425435
if (minBalance > 0n && user.balance < minBalance) {
426436
this.cacheSet(cacheKey, CacheAdmissionState.BLOCKED_INSUFFICIENT_BALANCE, 60)
427437
return 'blocked: insufficient balance'
Lines changed: 35 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
1+
import { getClaimTag, isNip43RequestTimestampValid } from '../../utils/nip43'
2+
import { ICacheAdapter, IWebSocketAdapter } from '../../@types/adapters'
13
import { IInviteCodeRepository, IUserRepository } from '../../@types/repositories'
2-
import { createCommandResult } from '../../utils/messages'
4+
import { admissionCacheKey } from '../../constants/caching'
5+
import { createEventCommandResult } from '../../telemetry/event-metrics'
36
import { createLogger } from '../../factories/logger-factory'
47
import { Event } from '../../@types/event'
5-
import { getClaimTag } from '../../utils/nip43'
68
import { IEventStrategy } from '../../@types/message-handlers'
7-
import { IWebSocketAdapter } from '../../@types/adapters'
89
import { Settings } from '../../@types/settings'
910
import { WebSocketAdapterEvent } from '../../constants/adapter'
1011

@@ -15,6 +16,7 @@ export class JoinRequestEventStrategy implements IEventStrategy<Event, Promise<v
1516
private readonly webSocket: IWebSocketAdapter,
1617
private readonly inviteCodeRepository: IInviteCodeRepository,
1718
private readonly userRepository: IUserRepository,
19+
private readonly cache: ICacheAdapter,
1820
private readonly settings: () => Settings,
1921
) {}
2022

@@ -23,60 +25,64 @@ export class JoinRequestEventStrategy implements IEventStrategy<Event, Promise<v
2325

2426
if (!currentSettings.nip43?.enabled) {
2527
logger('NIP-43 disabled, rejecting join request from %s', event.pubkey)
26-
this.webSocket.emit(
27-
WebSocketAdapterEvent.Message,
28-
createCommandResult(event.id, false, 'restricted: NIP-43 is not enabled on this relay'),
29-
)
28+
this.sendResult(event.id, false, 'restricted: NIP-43 is not enabled on this relay')
29+
return
30+
}
31+
32+
if (!isNip43RequestTimestampValid(event)) {
33+
logger('join request from %s has stale created_at', event.pubkey)
34+
this.sendResult(event.id, false, 'invalid: created_at is too far from the current time')
3035
return
3136
}
3237

3338
if (!this.webSocket.getAuthenticatedPubkeys().has(event.pubkey)) {
3439
logger('unauthenticated join request from %s', event.pubkey)
35-
this.webSocket.emit(
36-
WebSocketAdapterEvent.Message,
37-
createCommandResult(event.id, false, 'restricted: authentication required (NIP-42)'),
38-
)
40+
this.sendResult(event.id, false, 'auth-required: authentication required (NIP-42)')
3941
return
4042
}
4143

4244
const claimCode = getClaimTag(event)
4345
if (!claimCode) {
4446
logger('join request from %s missing claim tag', event.pubkey)
45-
this.webSocket.emit(
46-
WebSocketAdapterEvent.Message,
47-
createCommandResult(event.id, false, 'restricted: missing claim tag'),
48-
)
47+
this.sendResult(event.id, false, 'invalid: join request requires a claim tag')
4948
return
5049
}
5150

5251
const existingUser = await this.userRepository.findByPubkey(event.pubkey)
5352
if (existingUser?.isAdmitted) {
5453
logger('join request from %s: already admitted', event.pubkey)
55-
this.webSocket.emit(
56-
WebSocketAdapterEvent.Message,
57-
createCommandResult(event.id, true, 'duplicate: you are already a member of this relay'),
58-
)
54+
this.sendResult(event.id, true, 'duplicate: you are already a member of this relay')
5955
return
6056
}
6157

6258
const claimed = await this.inviteCodeRepository.claimCode(claimCode, event.pubkey)
6359
if (!claimed) {
6460
logger('join request from %s: claim failed', event.pubkey)
65-
this.webSocket.emit(
66-
WebSocketAdapterEvent.Message,
67-
createCommandResult(event.id, false, 'restricted: invalid or expired invite code'),
68-
)
61+
this.sendResult(event.id, false, 'restricted: invalid or expired invite code')
6962
return
7063
}
7164

72-
const now = new Date()
73-
await this.userRepository.admitUser(event.pubkey, now)
65+
// Not transactional with claimCode: if admitUser throws, one code use is
66+
// wasted. Acceptable — the alternative order (admit before claim) could
67+
// admit users without a valid code.
68+
await this.userRepository.admitUser(event.pubkey, new Date())
69+
70+
// Drop any cached "not admitted" verdict so the new member's next event
71+
// isn't rejected by a stale admission cache entry.
72+
try {
73+
await this.cache.deleteKey(admissionCacheKey(event.pubkey))
74+
} catch (error) {
75+
logger('unable to invalidate admission cache for %s: %o', event.pubkey, error)
76+
}
7477

7578
const relayUrl = currentSettings.info?.relay_url ?? 'this relay'
7679
logger('admitted %s via invite code', event.pubkey)
77-
this.webSocket.emit(
78-
WebSocketAdapterEvent.Message,
79-
createCommandResult(event.id, true, `info: welcome to ${relayUrl}!`),
80-
)
80+
this.sendResult(event.id, true, `info: welcome to ${relayUrl}!`)
81+
}
82+
83+
// Join requests are deliberately not broadcast to subscribers: the claim
84+
// tag would leak the invite code to anyone subscribed to kind 28934.
85+
private sendResult(eventId: string, successful: boolean, message: string): void {
86+
this.webSocket.emit(WebSocketAdapterEvent.Message, createEventCommandResult(eventId, successful, message))
8187
}
8288
}
Lines changed: 37 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
1-
import { IUserRepository } from '../../@types/repositories'
2-
import { createCommandResult } from '../../utils/messages'
1+
import { ICacheAdapter, IWebSocketAdapter } from '../../@types/adapters'
2+
import { admissionCacheKey } from '../../constants/caching'
3+
import { createEventCommandResult } from '../../telemetry/event-metrics'
34
import { createLogger } from '../../factories/logger-factory'
45
import { Event } from '../../@types/event'
56
import { IEventStrategy } from '../../@types/message-handlers'
6-
import { IWebSocketAdapter } from '../../@types/adapters'
7+
import { isNip43RequestTimestampValid } from '../../utils/nip43'
8+
import { isProtectedEvent } from '../../utils/event'
9+
import { IUserRepository } from '../../@types/repositories'
710
import { Settings } from '../../@types/settings'
811
import { WebSocketAdapterEvent } from '../../constants/adapter'
912

@@ -13,6 +16,7 @@ export class LeaveRequestEventStrategy implements IEventStrategy<Event, Promise<
1316
public constructor(
1417
private readonly webSocket: IWebSocketAdapter,
1518
private readonly userRepository: IUserRepository,
19+
private readonly cache: ICacheAdapter,
1620
private readonly settings: () => Settings,
1721
) {}
1822

@@ -21,42 +25,52 @@ export class LeaveRequestEventStrategy implements IEventStrategy<Event, Promise<
2125

2226
if (!currentSettings.nip43?.enabled) {
2327
logger('NIP-43 disabled, rejecting leave request from %s', event.pubkey)
24-
this.webSocket.emit(
25-
WebSocketAdapterEvent.Message,
26-
createCommandResult(event.id, false, 'restricted: NIP-43 is not enabled on this relay'),
27-
)
28+
this.sendResult(event.id, false, 'restricted: NIP-43 is not enabled on this relay')
29+
return
30+
}
31+
32+
// NIP-43 requires leave requests to carry a NIP-70 "-" tag
33+
if (!isProtectedEvent(event)) {
34+
logger('leave request from %s missing "-" tag', event.pubkey)
35+
this.sendResult(event.id, false, 'invalid: leave request requires a NIP-70 "-" tag')
36+
return
37+
}
38+
39+
if (!isNip43RequestTimestampValid(event)) {
40+
logger('leave request from %s has stale created_at', event.pubkey)
41+
this.sendResult(event.id, false, 'invalid: created_at is too far from the current time')
2842
return
2943
}
3044

3145
if (!this.webSocket.getAuthenticatedPubkeys().has(event.pubkey)) {
3246
logger('unauthenticated leave request from %s', event.pubkey)
33-
this.webSocket.emit(
34-
WebSocketAdapterEvent.Message,
35-
createCommandResult(event.id, false, 'restricted: authentication required (NIP-42)'),
36-
)
47+
this.sendResult(event.id, false, 'auth-required: authentication required (NIP-42)')
3748
return
3849
}
3950

4051
// Avoids creating phantom user rows for non-members.
4152
const existingUser = await this.userRepository.findByPubkey(event.pubkey)
4253
if (!existingUser?.isAdmitted) {
4354
logger('leave request from %s: not currently admitted', event.pubkey)
44-
this.webSocket.emit(
45-
WebSocketAdapterEvent.Message,
46-
createCommandResult(event.id, true, ''),
47-
)
55+
this.sendResult(event.id, true, '')
4856
return
4957
}
5058

51-
await this.userRepository.upsert({
52-
pubkey: event.pubkey,
53-
isAdmitted: false,
54-
})
59+
await this.userRepository.revokeAdmission(event.pubkey)
60+
61+
// Drop any cached "admitted" verdict so the departed member can't keep
62+
// publishing on a stale admission cache entry.
63+
try {
64+
await this.cache.deleteKey(admissionCacheKey(event.pubkey))
65+
} catch (error) {
66+
logger('unable to invalidate admission cache for %s: %o', event.pubkey, error)
67+
}
5568

5669
logger('revoked admission for %s', event.pubkey)
57-
this.webSocket.emit(
58-
WebSocketAdapterEvent.Message,
59-
createCommandResult(event.id, true, ''),
60-
)
70+
this.sendResult(event.id, true, '')
71+
}
72+
73+
private sendResult(eventId: string, successful: boolean, message: string): void {
74+
this.webSocket.emit(WebSocketAdapterEvent.Message, createEventCommandResult(eventId, successful, message))
6175
}
6276
}

src/handlers/request-handlers/root-request-handler.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ export const rootRequestHandler = (request: Request, response: Response, next: N
6060
Boolean(settings.payments?.feeSchedules?.admission?.some((feeSchedule) => feeSchedule.enabled))
6161
const hasWriteRestriction =
6262
hasAdmissionRestriction ||
63+
settings.nip43?.enabled === true ||
6364
(eventLimits?.eventId?.minLeadingZeroBits ?? 0) > 0 ||
6465
(eventLimits?.pubkey?.minLeadingZeroBits ?? 0) > 0 ||
6566
(eventLimits?.pubkey?.whitelist?.length ?? 0) > 0 ||
@@ -78,7 +79,11 @@ export const rootRequestHandler = (request: Request, response: Response, next: N
7879
pubkey,
7980
...(self !== undefined ? { self } : {}),
8081
contact,
81-
supported_nips: packageJson.supportedNips,
82+
// NIP-43: clients MUST only send join requests to relays advertising 43,
83+
// so only advertise it when the feature is actually enabled
84+
supported_nips: settings.nip43?.enabled === true
85+
? packageJson.supportedNips
86+
: packageJson.supportedNips.filter((nip: number) => nip !== 43),
8287
supported_nip_extensions: packageJson.supportedNipExtensions,
8388
supported_mips: packageJson.supportedMips,
8489
software: packageJson.repository.url,

0 commit comments

Comments
 (0)