Skip to content

Commit 0119664

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 0119664

9 files changed

Lines changed: 630 additions & 1 deletion

File tree

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

.knip.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,8 @@
1414
"lzma-native"
1515
],
1616
"ignore": [
17-
".nostr/**"
17+
".nostr/**",
18+
"src/repositories/invite-code-repository.ts"
1819
],
1920
"commitlint": false,
2021
"eslint": false,
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.raw(
14+
'ALTER TABLE invite_codes ADD CONSTRAINT chk_use_count_non_negative CHECK (use_count >= 0)'
15+
)
16+
await knex.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.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: 10 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'
@@ -63,3 +64,12 @@ export interface INip05VerificationRepository {
6364
findPendingVerifications(updateFrequencyMs: number, maxFailures: number, limit: number): Promise<Nip05Verification[]>
6465
deleteByPubkey(pubkey: Pubkey): Promise<number>
6566
}
67+
68+
export interface IInviteCodeRepository {
69+
create(code: string, expiresAt?: Date, maxUses?: number): Promise<InviteCode>
70+
findByCode(code: string): Promise<InviteCode | undefined>
71+
claimCode(code: string, pubkey: Pubkey): Promise<boolean>
72+
findActiveCodes(limit?: number): Promise<InviteCode[]>
73+
deleteExpiredCodes(): Promise<number>
74+
}
75+

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'
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
import { randomBytes } from 'crypto'
2+
3+
import { DatabaseClient, Pubkey } from '../@types/base'
4+
import { DBInviteCode, InviteCode } from '../@types/invite-code'
5+
import { IInviteCodeRepository } from '../@types/repositories'
6+
import { createLogger } from '../factories/logger-factory'
7+
import { toBuffer } from '../utils/transform'
8+
9+
const logger = createLogger('invite-code-repository')
10+
11+
export function generateInviteCode(): string {
12+
return randomBytes(16).toString('hex')
13+
}
14+
15+
function fromDBInviteCode(row: DBInviteCode): InviteCode {
16+
return {
17+
code: row.code,
18+
createdBy: row.created_by ? row.created_by.toString('hex') : null,
19+
claimedBy: row.claimed_by ? row.claimed_by.toString('hex') : null,
20+
expiresAt: row.expires_at,
21+
maxUses: row.max_uses,
22+
useCount: row.use_count,
23+
createdAt: row.created_at,
24+
updatedAt: row.updated_at,
25+
}
26+
}
27+
28+
function affectedRows(result: unknown): number {
29+
if (typeof result === 'number') { return result }
30+
if (result && typeof (result as any).rowCount === 'number') { return (result as any).rowCount }
31+
return 0
32+
}
33+
34+
export class InviteCodeRepository implements IInviteCodeRepository {
35+
public constructor(private readonly dbClient: DatabaseClient) {}
36+
37+
public async create(
38+
code: string,
39+
expiresAt?: Date,
40+
maxUses: number = 1,
41+
client: DatabaseClient = this.dbClient,
42+
): Promise<InviteCode> {
43+
logger('create invite code: %s (expires: %s, maxUses: %d)', code, expiresAt ?? 'never', maxUses)
44+
45+
const now = new Date()
46+
const row: DBInviteCode = {
47+
code,
48+
created_by: null,
49+
claimed_by: null,
50+
expires_at: expiresAt ?? null,
51+
max_uses: maxUses,
52+
use_count: 0,
53+
created_at: now,
54+
updated_at: now,
55+
}
56+
57+
await client<DBInviteCode>('invite_codes').insert(row)
58+
59+
return fromDBInviteCode(row)
60+
}
61+
62+
public async findByCode(
63+
code: string,
64+
client: DatabaseClient = this.dbClient,
65+
): Promise<InviteCode | undefined> {
66+
logger('find invite code: %s', code)
67+
68+
const [row] = await client<DBInviteCode>('invite_codes')
69+
.where('code', code)
70+
.select()
71+
72+
if (!row) {
73+
return
74+
}
75+
76+
return fromDBInviteCode(row)
77+
}
78+
79+
// Atomic claim: single UPDATE ensures only one caller wins on a single-use code
80+
public async claimCode(
81+
code: string,
82+
pubkey: Pubkey,
83+
client: DatabaseClient = this.dbClient,
84+
): Promise<boolean> {
85+
logger('claim invite code %s for %s', code, pubkey)
86+
87+
const now = new Date()
88+
89+
const result = await client<DBInviteCode>('invite_codes')
90+
.where('code', code)
91+
.where(function () {
92+
this.where('max_uses', 0) // 0 = unlimited uses
93+
.orWhereRaw('use_count < max_uses')
94+
})
95+
.where(function () {
96+
this.whereNull('expires_at')
97+
.orWhere('expires_at', '>', now)
98+
})
99+
.update({
100+
use_count: client.raw('use_count + 1'),
101+
claimed_by: toBuffer(pubkey),
102+
updated_at: now,
103+
} as any)
104+
105+
return affectedRows(result) > 0
106+
}
107+
108+
public async findActiveCodes(
109+
limit: number = 100,
110+
client: DatabaseClient = this.dbClient,
111+
): Promise<InviteCode[]> {
112+
logger('find active invite codes (limit %d)', limit)
113+
114+
const now = new Date()
115+
116+
const rows = await client<DBInviteCode>('invite_codes')
117+
.where(function () {
118+
this.whereNull('expires_at')
119+
.orWhere('expires_at', '>', now)
120+
})
121+
.where(function () {
122+
this.where('max_uses', 0)
123+
.orWhereRaw('use_count < max_uses')
124+
})
125+
.orderBy('created_at', 'desc')
126+
.limit(limit)
127+
.select()
128+
129+
return rows.map(fromDBInviteCode)
130+
}
131+
132+
public async deleteExpiredCodes(
133+
client: DatabaseClient = this.dbClient,
134+
): Promise<number> {
135+
logger('delete expired invite codes')
136+
137+
const now = new Date()
138+
139+
const result = await client<DBInviteCode>('invite_codes')
140+
.whereNotNull('expires_at')
141+
.where('expires_at', '<=', now)
142+
.delete()
143+
144+
const count = affectedRows(result)
145+
logger('deleted %d expired invite codes', count)
146+
147+
return count
148+
}
149+
}

0 commit comments

Comments
 (0)