Skip to content

Commit afbfc55

Browse files
feat: add NIP-43 invite code foundation
- Add event kinds 8000, 8001, 13534, 28934, 28935, 28936 and tags (member, claim) - Create InviteCode/DBInviteCode types and Nip43Settings interface - Add IInviteCodeRepository interface with create, findByCode, claimCode, findActiveCodes, deleteExpiredCodes - Implement InviteCodeRepository with atomic claimCode via single UPDATE - Add generateInviteCode() using crypto.randomBytes (128-bit entropy) - Create invite_codes migration with CHECK constraints and partial index - Add revokeAdmission() and findAllAdmitted() to UserRepository - Add 27 unit tests with full coverage
1 parent 15c643e commit afbfc55

16 files changed

Lines changed: 668 additions & 12 deletions

.changeset/nip43-invite-codes.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"nostream": minor
3+
---
4+
5+
Add NIP-43 invite code foundation: InviteCodeRepository with atomic claimCode, invite_codes migration, and event kind/tag constants.
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
exports.up = async function (knex) {
2+
await knex.schema.createTable('invite_codes', (table) => {
3+
table.string('code', 64).primary()
4+
table.binary('created_by').nullable()
5+
table.binary('claimed_by').nullable()
6+
table.timestamp('expires_at', { useTz: true }).nullable()
7+
table.integer('max_uses').notNullable().defaultTo(1)
8+
table.integer('use_count').notNullable().defaultTo(0)
9+
table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(knex.fn.now())
10+
table.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(knex.fn.now())
11+
})
12+
13+
await knex.schema.raw(
14+
'ALTER TABLE invite_codes ADD CONSTRAINT chk_use_count_non_negative CHECK (use_count >= 0)'
15+
)
16+
await knex.schema.raw(
17+
'ALTER TABLE invite_codes ADD CONSTRAINT chk_max_uses_non_negative CHECK (max_uses >= 0)'
18+
)
19+
20+
// partial index: only rows with an expiry set
21+
await knex.schema.raw(
22+
'CREATE INDEX idx_invite_codes_expires_at ON invite_codes(expires_at) WHERE expires_at IS NOT NULL'
23+
)
24+
}
25+
26+
exports.down = async function (knex) {
27+
await knex.schema.dropTableIfExists('invite_codes')
28+
}

src/@types/invite-code.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
export interface InviteCode {
2+
code: string
3+
createdBy: string | null
4+
claimedBy: string | null
5+
expiresAt: Date | null
6+
maxUses: number
7+
useCount: number
8+
createdAt: Date
9+
updatedAt: Date
10+
}
11+
12+
export interface DBInviteCode {
13+
code: string
14+
created_by: Buffer | null
15+
claimed_by: Buffer | null
16+
expires_at: Date | null
17+
max_uses: number
18+
use_count: number
19+
created_at: Date
20+
updated_at: Date
21+
}

src/@types/repositories.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { DatabaseClient, EventId, Pubkey } from './base'
44
import { DBEvent, Event } from './event'
55
import { EventKinds } from '../constants/base'
66
import { EventKindsRange } from './settings'
7+
import { InviteCode } from './invite-code'
78
import { Invoice } from './invoice'
89
import { Nip05Verification } from './nip05'
910
import { SubscriptionFilter } from './subscription'
@@ -55,6 +56,8 @@ export interface IUserRepository {
5556
isVanished(pubkey: Pubkey, client?: DatabaseClient): Promise<boolean>
5657
setVanished(pubkey: Pubkey, vanished: boolean, client?: DatabaseClient): Promise<number>
5758
admitUser(pubkey: Pubkey, admittedAt: Date, client?: DatabaseClient): Promise<void>
59+
revokeAdmission(pubkey: Pubkey, client?: DatabaseClient): Promise<number>
60+
findAllAdmitted(client?: DatabaseClient): Promise<User[]>
5861
}
5962

6063
export interface INip05VerificationRepository {
@@ -63,3 +66,12 @@ export interface INip05VerificationRepository {
6366
findPendingVerifications(updateFrequencyMs: number, maxFailures: number, limit: number): Promise<Nip05Verification[]>
6467
deleteByPubkey(pubkey: Pubkey): Promise<number>
6568
}
69+
70+
export interface IInviteCodeRepository {
71+
create(code: string, expiresAt?: Date, maxUses?: number): Promise<InviteCode>
72+
findByCode(code: string): Promise<InviteCode | undefined>
73+
claimCode(code: string, pubkey: Pubkey): Promise<boolean>
74+
findActiveCodes(limit?: number): Promise<InviteCode[]>
75+
deleteExpiredCodes(): Promise<number>
76+
}
77+

src/@types/settings.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -285,6 +285,14 @@ export interface WoTSettings {
285285
refreshIntervalHours: number
286286
}
287287

288+
export interface Nip43Settings {
289+
enabled: boolean
290+
inviteCodeExpiry?: number
291+
defaultMaxUses?: number
292+
allowInviteRequests?: boolean
293+
inviteRequestWhitelist?: Pubkey[]
294+
}
295+
288296
export interface Settings {
289297
info: Info
290298
payments?: Payments
@@ -294,6 +302,7 @@ export interface Settings {
294302
limits?: Limits
295303
mirroring?: Mirroring
296304
nip05?: Nip05Settings
305+
nip43?: Nip43Settings
297306
nip45?: Nip45Settings
298307
wot?: WoTSettings
299308
}

src/constants/base.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,9 @@ export enum EventKinds {
3333
// Relay-only
3434
RELAY_INVITE = 50,
3535
INVOICE_UPDATE = 402,
36+
// NIP-43: Relay Access Metadata and Requests
37+
NIP43_ADD_USER = 8000,
38+
NIP43_REMOVE_USER = 8001,
3639
// Lightning zaps
3740
ZAP_REQUEST = 9734,
3841
ZAP_RECEIPT = 9735,
@@ -42,11 +45,17 @@ export enum EventKinds {
4245
RELAY_LIST = 10002,
4346
// Marmot Protocol MIP-00: KeyPackage Relay List
4447
MARMOT_KEY_PACKAGE_RELAY_LIST = 10051,
48+
// NIP-43: Membership List
49+
NIP43_MEMBERSHIP_LIST = 13534,
4550
REPLACEABLE_LAST = 19999,
4651
// Ephemeral events
4752
EPHEMERAL_FIRST = 20000,
4853
// NIP-42: Client Authentication
4954
AUTH = 22242,
55+
// NIP-43: Ephemeral access request kinds
56+
NIP43_JOIN_REQUEST = 28934,
57+
NIP43_INVITE_REQUEST = 28935,
58+
NIP43_LEAVE_REQUEST = 28936,
5059
EPHEMERAL_LAST = 29999,
5160
// Parameterized replaceable events
5261
PARAMETERIZED_REPLACEABLE_FIRST = 30000,
@@ -81,6 +90,9 @@ export enum EventTags {
8190
Group = 'h',
8291
// NIP-70: Protected Events
8392
Protected = '-',
93+
// NIP-43: Relay Access Metadata
94+
Member = 'member',
95+
Claim = 'claim',
8496
}
8597

8698
export const ALL_RELAYS = 'ALL_RELAYS'

src/factories/event-strategy-factory.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { IEventRepository, IUserRepository } from '../@types/repositories'
1+
import { IEventRepository, IInviteCodeRepository, IUserRepository } from '../@types/repositories'
22
import {
33
isDeleteEvent,
44
isEphemeralEvent,
@@ -28,6 +28,7 @@ export const eventStrategyFactory =
2828
(
2929
eventRepository: IEventRepository,
3030
userRepository: IUserRepository,
31+
_inviteCodeRepository: IInviteCodeRepository,
3132
): Factory<IEventStrategy<Event, Promise<void>>, [Event, IWebSocketAdapter]> =>
3233
([event, adapter]: [Event, IWebSocketAdapter]) => {
3334
if (isRequestToVanishEvent(event)) {

src/factories/message-handler-factory.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { ICacheAdapter, IWebSocketAdapter } from '../@types/adapters'
2-
import { IEventRepository, INip05VerificationRepository, IUserRepository } from '../@types/repositories'
2+
import { IEventRepository, IInviteCodeRepository, INip05VerificationRepository, IUserRepository } from '../@types/repositories'
33
import { IncomingMessage, MessageType } from '../@types/messages'
44
import { createSettings } from './settings-factory'
55
import { AuthMessageHandler } from '../handlers/auth-message-handler'
@@ -24,14 +24,15 @@ export const messageHandlerFactory =
2424
(
2525
eventRepository: IEventRepository,
2626
userRepository: IUserRepository,
27+
inviteCodeRepository: IInviteCodeRepository,
2728
nip05VerificationRepository: INip05VerificationRepository,
2829
) =>
2930
([message, adapter]: [IncomingMessage, IWebSocketAdapter]) => {
3031
switch (message[0]) {
3132
case MessageType.EVENT: {
3233
return new EventMessageHandler(
3334
adapter,
34-
eventStrategyFactory(eventRepository, userRepository),
35+
eventStrategyFactory(eventRepository, userRepository, inviteCodeRepository),
3536
eventRepository,
3637
userRepository,
3738
createSettings,

src/factories/websocket-adapter-factory.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { IncomingMessage } from 'http'
22
import { WebSocket } from 'ws'
33

4-
import { IEventRepository, INip05VerificationRepository, IUserRepository } from '../@types/repositories'
4+
import { IEventRepository, IInviteCodeRepository, INip05VerificationRepository, IUserRepository } from '../@types/repositories'
55
import { createSettings } from './settings-factory'
66
import { IWebSocketServerAdapter } from '../@types/adapters'
77
import { messageHandlerFactory } from './message-handler-factory'
@@ -12,14 +12,15 @@ export const webSocketAdapterFactory =
1212
(
1313
eventRepository: IEventRepository,
1414
userRepository: IUserRepository,
15+
inviteCodeRepository: IInviteCodeRepository,
1516
nip05VerificationRepository: INip05VerificationRepository,
1617
) =>
1718
([client, request, webSocketServerAdapter]: [WebSocket, IncomingMessage, IWebSocketServerAdapter]) =>
1819
new WebSocketAdapter(
1920
client,
2021
request,
2122
webSocketServerAdapter,
22-
messageHandlerFactory(eventRepository, userRepository, nip05VerificationRepository),
23+
messageHandlerFactory(eventRepository, userRepository, inviteCodeRepository, nip05VerificationRepository),
2324
rateLimiterFactory,
2425
createSettings,
2526
)

src/factories/worker-factory.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { createLogger } from './logger-factory'
99
import { createSettings } from '../factories/settings-factory'
1010
import { createWebApp } from './web-app-factory'
1111
import { EventRepository } from '../repositories/event-repository'
12+
import { InviteCodeRepository } from '../repositories/invite-code-repository'
1213
import { Nip05VerificationRepository } from '../repositories/nip05-verification-repository'
1314
import { UserRepository } from '../repositories/user-repository'
1415
import { webSocketAdapterFactory } from './websocket-adapter-factory'
@@ -21,6 +22,7 @@ export const workerFactory = (): AppWorker => {
2122
const readReplicaDbClient = getReadReplicaDbClient()
2223
const eventRepository = new EventRepository(dbClient, readReplicaDbClient)
2324
const userRepository = new UserRepository(dbClient, eventRepository)
25+
const inviteCodeRepository = new InviteCodeRepository(dbClient)
2426
const nip05VerificationRepository = new Nip05VerificationRepository(dbClient)
2527

2628
const settings = createSettings()
@@ -63,7 +65,7 @@ export const workerFactory = (): AppWorker => {
6365
const adapter = new WebSocketServerAdapter(
6466
server,
6567
webSocketServer,
66-
webSocketAdapterFactory(eventRepository, userRepository, nip05VerificationRepository),
68+
webSocketAdapterFactory(eventRepository, userRepository, inviteCodeRepository, nip05VerificationRepository),
6769
createSettings,
6870
)
6971

0 commit comments

Comments
 (0)