Skip to content

Commit 3461dfe

Browse files
feat: add NIP-43 invite code foundation (#650)
* 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 * refactor(nip43): address PR review feedback on invite codes Signed-off-by: anshumancanrock <anshu.1239.as@gmail.com> --------- Signed-off-by: anshumancanrock <anshu.1239.as@gmail.com>
1 parent 2f6d773 commit 3461dfe

9 files changed

Lines changed: 618 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: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
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('remaining_uses').nullable().defaultTo(1)
8+
table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(knex.fn.now())
9+
table.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(knex.fn.now())
10+
})
11+
12+
await knex.raw(
13+
'ALTER TABLE invite_codes ADD CONSTRAINT chk_remaining_uses_non_negative CHECK (remaining_uses >= 0)'
14+
)
15+
16+
// partial index: only rows with an expiry set
17+
await knex.raw(
18+
'CREATE INDEX idx_invite_codes_expires_at ON invite_codes(expires_at) WHERE expires_at IS NOT NULL'
19+
)
20+
}
21+
22+
exports.down = async function (knex) {
23+
await knex.schema.dropTableIfExists('invite_codes')
24+
}

src/@types/invite-code.ts

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

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, remainingUses?: number | null): 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
@@ -35,6 +35,9 @@ export enum EventKinds {
3535
// Relay-only
3636
RELAY_INVITE = 50,
3737
INVOICE_UPDATE = 402,
38+
// NIP-43: Relay Access Metadata and Requests
39+
NIP43_ADD_USER = 8000,
40+
NIP43_REMOVE_USER = 8001,
3841
// Lightning zaps
3942
ZAP_REQUEST = 9734,
4043
ZAP_RECEIPT = 9735,
@@ -44,11 +47,17 @@ export enum EventKinds {
4447
RELAY_LIST = 10002,
4548
// Marmot Protocol MIP-00: KeyPackage Relay List
4649
MARMOT_KEY_PACKAGE_RELAY_LIST = 10051,
50+
// NIP-43: Membership List
51+
NIP43_MEMBERSHIP_LIST = 13534,
4752
REPLACEABLE_LAST = 19999,
4853
// Ephemeral events
4954
EPHEMERAL_FIRST = 20000,
5055
// NIP-42: Client Authentication
5156
AUTH = 22242,
57+
// NIP-43: Ephemeral access request kinds
58+
NIP43_JOIN_REQUEST = 28934,
59+
NIP43_INVITE_REQUEST = 28935,
60+
NIP43_LEAVE_REQUEST = 28936,
5261
EPHEMERAL_LAST = 29999,
5362
// Parameterized replaceable events
5463
PARAMETERIZED_REPLACEABLE_FIRST = 30000,
@@ -83,6 +92,9 @@ export enum EventTags {
8392
Group = 'h',
8493
// NIP-70: Protected Events
8594
Protected = '-',
95+
// NIP-43: Relay Access Metadata
96+
Member = 'member',
97+
Claim = 'claim',
8698
}
8799

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

0 commit comments

Comments
 (0)