Skip to content
Open
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-invite-codes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"nostream": minor
---

Add NIP-43 invite code foundation: InviteCodeRepository with atomic claimCode, invite_codes migration, and event kind/tag constants.
3 changes: 2 additions & 1 deletion .knip.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@
"lzma-native"
],
"ignore": [
".nostr/**"
".nostr/**",
"src/repositories/invite-code-repository.ts"
],
"commitlint": false,
"eslint": false,
Expand Down
28 changes: 28 additions & 0 deletions migrations/20260624_120000_create_nip43_invite_codes_table.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
exports.up = async function (knex) {
await knex.schema.createTable('invite_codes', (table) => {
table.string('code', 64).primary()
table.binary('created_by').nullable()
table.binary('claimed_by').nullable()
table.timestamp('expires_at', { useTz: true }).nullable()
table.integer('max_uses').notNullable().defaultTo(1)
table.integer('use_count').notNullable().defaultTo(0)
table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(knex.fn.now())
table.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(knex.fn.now())
})

await knex.raw(
'ALTER TABLE invite_codes ADD CONSTRAINT chk_use_count_non_negative CHECK (use_count >= 0)'
)
await knex.raw(
'ALTER TABLE invite_codes ADD CONSTRAINT chk_max_uses_non_negative CHECK (max_uses >= 0)'
)

// partial index: only rows with an expiry set
await knex.raw(
'CREATE INDEX idx_invite_codes_expires_at ON invite_codes(expires_at) WHERE expires_at IS NOT NULL'
)
}

exports.down = async function (knex) {
await knex.schema.dropTableIfExists('invite_codes')
}
21 changes: 21 additions & 0 deletions src/@types/invite-code.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
export interface InviteCode {
code: string
createdBy: string | null
claimedBy: string | null
expiresAt: Date | null
maxUses: number
useCount: number
createdAt: Date
updatedAt: Date
}

export interface DBInviteCode {
code: string
created_by: Buffer | null
claimed_by: Buffer | null
expires_at: Date | null
max_uses: number
use_count: number
created_at: Date
updated_at: Date
}
10 changes: 10 additions & 0 deletions src/@types/repositories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { DatabaseClient, EventId, Pubkey } from './base'
import { DBEvent, Event } from './event'
import { EventKinds } from '../constants/base'
import { EventKindsRange } from './settings'
import { InviteCode } from './invite-code'
import { Invoice } from './invoice'
import { Nip05Verification } from './nip05'
import { SubscriptionFilter } from './subscription'
Expand Down Expand Up @@ -63,3 +64,12 @@ export interface INip05VerificationRepository {
findPendingVerifications(updateFrequencyMs: number, maxFailures: number, limit: number): Promise<Nip05Verification[]>
deleteByPubkey(pubkey: Pubkey): Promise<number>
}

export interface IInviteCodeRepository {
create(code: string, expiresAt?: Date, maxUses?: number): Promise<InviteCode>
findByCode(code: string): Promise<InviteCode | undefined>
claimCode(code: string, pubkey: Pubkey): Promise<boolean>
findActiveCodes(limit?: number): Promise<InviteCode[]>
deleteExpiredCodes(): Promise<number>
}

9 changes: 9 additions & 0 deletions src/@types/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -285,6 +285,14 @@ export interface WoTSettings {
refreshIntervalHours: number
}

export interface Nip43Settings {
enabled: boolean
inviteCodeExpiry?: number
defaultMaxUses?: number
allowInviteRequests?: boolean
inviteRequestWhitelist?: Pubkey[]
}

export interface Settings {
info: Info
payments?: Payments
Expand All @@ -294,6 +302,7 @@ export interface Settings {
limits?: Limits
mirroring?: Mirroring
nip05?: Nip05Settings
nip43?: Nip43Settings
nip45?: Nip45Settings
wot?: WoTSettings
}
12 changes: 12 additions & 0 deletions src/constants/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@ export enum EventKinds {
// Relay-only
RELAY_INVITE = 50,
INVOICE_UPDATE = 402,
// NIP-43: Relay Access Metadata and Requests
NIP43_ADD_USER = 8000,
NIP43_REMOVE_USER = 8001,
// Lightning zaps
ZAP_REQUEST = 9734,
ZAP_RECEIPT = 9735,
Expand All @@ -42,11 +45,17 @@ export enum EventKinds {
RELAY_LIST = 10002,
// Marmot Protocol MIP-00: KeyPackage Relay List
MARMOT_KEY_PACKAGE_RELAY_LIST = 10051,
// NIP-43: Membership List
NIP43_MEMBERSHIP_LIST = 13534,
REPLACEABLE_LAST = 19999,
// Ephemeral events
EPHEMERAL_FIRST = 20000,
// NIP-42: Client Authentication
AUTH = 22242,
// NIP-43: Ephemeral access request kinds
NIP43_JOIN_REQUEST = 28934,
NIP43_INVITE_REQUEST = 28935,
NIP43_LEAVE_REQUEST = 28936,
EPHEMERAL_LAST = 29999,
// Parameterized replaceable events
PARAMETERIZED_REPLACEABLE_FIRST = 30000,
Expand Down Expand Up @@ -81,6 +90,9 @@ export enum EventTags {
Group = 'h',
// NIP-70: Protected Events
Protected = '-',
// NIP-43: Relay Access Metadata
Member = 'member',
Claim = 'claim',
}

export const ALL_RELAYS = 'ALL_RELAYS'
Expand Down
149 changes: 149 additions & 0 deletions src/repositories/invite-code-repository.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
import { randomBytes } from 'crypto'

import { DatabaseClient, Pubkey } from '../@types/base'
import { DBInviteCode, InviteCode } from '../@types/invite-code'
import { IInviteCodeRepository } from '../@types/repositories'
import { createLogger } from '../factories/logger-factory'
import { toBuffer } from '../utils/transform'

const logger = createLogger('invite-code-repository')

export function generateInviteCode(): string {
return randomBytes(16).toString('hex')
}

function fromDBInviteCode(row: DBInviteCode): InviteCode {
return {
code: row.code,
createdBy: row.created_by ? row.created_by.toString('hex') : null,
claimedBy: row.claimed_by ? row.claimed_by.toString('hex') : null,
expiresAt: row.expires_at,
maxUses: row.max_uses,
useCount: row.use_count,
createdAt: row.created_at,
updatedAt: row.updated_at,
}
}

function affectedRows(result: unknown): number {
if (typeof result === 'number') { return result }
if (result && typeof (result as any).rowCount === 'number') { return (result as any).rowCount }
return 0
}

export class InviteCodeRepository implements IInviteCodeRepository {
public constructor(private readonly dbClient: DatabaseClient) {}

public async create(
code: string,
expiresAt?: Date,
maxUses: number = 1,
client: DatabaseClient = this.dbClient,
): Promise<InviteCode> {
logger('create invite code: %s (expires: %s, maxUses: %d)', code, expiresAt ?? 'never', maxUses)

const now = new Date()
const row: DBInviteCode = {
code,
created_by: null,
claimed_by: null,
expires_at: expiresAt ?? null,
max_uses: maxUses,
use_count: 0,
created_at: now,
updated_at: now,
}

await client<DBInviteCode>('invite_codes').insert(row)

return fromDBInviteCode(row)
}

public async findByCode(
code: string,
client: DatabaseClient = this.dbClient,
): Promise<InviteCode | undefined> {
logger('find invite code: %s', code)

const [row] = await client<DBInviteCode>('invite_codes')
.where('code', code)
.select()

if (!row) {
return
}

return fromDBInviteCode(row)
}

// Atomic claim: single UPDATE ensures only one caller wins on a single-use code
public async claimCode(
code: string,
pubkey: Pubkey,
client: DatabaseClient = this.dbClient,
): Promise<boolean> {
logger('claim invite code %s for %s', code, pubkey)

const now = new Date()

const result = await client<DBInviteCode>('invite_codes')
.where('code', code)
.where(function () {
this.where('max_uses', 0) // 0 = unlimited uses
.orWhereRaw('use_count < max_uses')
})
.where(function () {
this.whereNull('expires_at')
.orWhere('expires_at', '>', now)
})
.update({
use_count: client.raw('use_count + 1'),
claimed_by: toBuffer(pubkey),
updated_at: now,
} as any)

return affectedRows(result) > 0
}

public async findActiveCodes(
limit: number = 100,
client: DatabaseClient = this.dbClient,
): Promise<InviteCode[]> {
logger('find active invite codes (limit %d)', limit)

const now = new Date()

const rows = await client<DBInviteCode>('invite_codes')
.where(function () {
this.whereNull('expires_at')
.orWhere('expires_at', '>', now)
})
.where(function () {
this.where('max_uses', 0)
.orWhereRaw('use_count < max_uses')
})
.orderBy('created_at', 'desc')
.limit(limit)
.select()

return rows.map(fromDBInviteCode)
}

public async deleteExpiredCodes(
client: DatabaseClient = this.dbClient,
): Promise<number> {
logger('delete expired invite codes')

const now = new Date()

const result = await client<DBInviteCode>('invite_codes')
.whereNotNull('expires_at')
.where('expires_at', '<=', now)
.delete()

const count = affectedRows(result)
logger('deleted %d expired invite codes', count)

return count
}
}
Loading
Loading