diff --git a/.changeset/nip43-invite-cli.md b/.changeset/nip43-invite-cli.md new file mode 100644 index 00000000..ceea941a --- /dev/null +++ b/.changeset/nip43-invite-cli.md @@ -0,0 +1,5 @@ +--- +"nostream": minor +--- + +Add a CLI to mint NIP-43 invite codes (`nostream invite create`) so operators can issue a claim without SQL. New codes honor `nip43.defaultMaxUses` and `nip43.inviteCodeExpirySeconds`. diff --git a/.knip.json b/.knip.json index f47eb4b9..e056bf38 100644 --- a/.knip.json +++ b/.knip.json @@ -16,7 +16,6 @@ ], "ignore": [ ".nostr/**", - "src/repositories/invite-code-repository.ts", "src/repositories/dvm-job-repository.ts", "src/utils/relay-probe/**" ], diff --git a/CLI.md b/CLI.md index d62cfda0..de76dcd7 100644 --- a/CLI.md +++ b/CLI.md @@ -26,10 +26,28 @@ nostream update nostream clean nostream setup [--yes] [--start] nostream seed [--count 100] +nostream invite create [--uses N] [--expires-in ] [--json] nostream import [file.jsonl|file.json] [--file file.jsonl|file.json] [--batch-size 1000] nostream export [output] [--output output] [--format jsonl|json] ``` +## NIP-43 invite codes + +Join (kind 28934) is already implemented. Operators mint a claim string and share it out of band; the client then publishes a join request with a `claim` tag. + +```bash +# Local PostgreSQL (DB_URI or DB_HOST in the environment / .env) +nostream invite create +nostream invite create --uses 3 --expires-in 86400 --json + +# Docker: Postgres is not published to the host +docker compose exec nostream node src/cli/index.js invite create +``` + +`--uses` defaults to `nip43.defaultMaxUses` (1). `--expires-in` defaults to `nip43.inviteCodeExpirySeconds` (600 = 10 minutes). `--expires-in` must be a positive integer; never-expiring codes are a yaml policy (`nip43.inviteCodeExpirySeconds: 0`), not a CLI flag. The printed code is the first line of human output so scripts can capture it. If `info.self` is a hex pubkey or `npub1…`, it is stored as `created_by`. + +This does not yet generate kind 28935 on `REQ` or publish membership list events. + ## Removed Legacy Wrappers The old shell wrapper scripts are no longer shipped in `scripts/`. @@ -135,4 +153,8 @@ nostream config set payments.enabled true --restart nostream config env set RELAY_PORT 8008 nostream config env get SECRET --show-secrets nostream config env validate + +# Mint a NIP-43 invite code (kind 28934 join) +nostream invite create --json +docker compose exec nostream node src/cli/index.js invite create ``` diff --git a/CONFIGURATION.md b/CONFIGURATION.md index dcf65ed4..d19ba761 100644 --- a/CONFIGURATION.md +++ b/CONFIGURATION.md @@ -191,6 +191,9 @@ The settings below are listed in alphabetical order by name. Please keep this ta | nip05.verifyUpdateFrequency | Minimum interval in milliseconds between re-verification attempts for a given author. Defaults to 86400000 (24 hours). | | nip42.restrictedReads.enabled | Enable NIP-42 auth-based read filtering. When enabled, events of the restricted kinds are only delivered to clients that have authenticated as the event's author or as a pubkey listed in the event's `p` tags. Applies to stored events (REQ), live broadcasts and COUNT queries. Subscriptions that exclusively target restricted kinds from unauthenticated clients are closed with an `auth-required:` reason. Defaults to false. | | nip42.restrictedReads.kinds | List of event kinds (or `[min, max]` ranges) protected by auth-based read filtering. Defaults to `[4, 1059]` (NIP-04 encrypted direct messages and NIP-59 gift wraps). | +| nip43.enabled | Enable NIP-43 invite-based membership. When true, only admitted members may publish. Defaults to false. | +| nip43.inviteCodeExpirySeconds | Seconds until a newly minted invite code expires. `0` means the code never expires. Defaults to 600 (10 minutes). | +| nip43.defaultMaxUses | How many times a newly minted invite code can be claimed. Defaults to 1. | | nip45.enabled | Enable or disable NIP-45 COUNT handling. Defaults to true. | | nip50.enabled | Enable or disable NIP-50 full-text search. Defaults to false. When enabled, clients can include a `search` field in REQ filters to perform text queries against event content. Requires the GIN full-text index migration. | | nip50.language | PostgreSQL text-search configuration name. Defaults to `simple` (language-agnostic tokenization). Set to `english`, `spanish`, etc. for stemming support. See [PostgreSQL text search configurations](https://www.postgresql.org/docs/current/textsearch-configuration.html). **Note:** The GIN index migration is built with the `simple` configuration. If you change this value, you must manually rebuild the index: `DROP INDEX CONCURRENTLY events_content_fts_idx; CREATE INDEX CONCURRENTLY events_content_fts_idx ON events USING gin (to_tsvector('', event_content));` — otherwise the planner cannot use the index and queries fall back to sequential scans. | diff --git a/README.md b/README.md index e15823cd..8f3c6c9d 100644 --- a/README.md +++ b/README.md @@ -322,6 +322,17 @@ The importer: - Prints progress in the format: `[Processed: 50,000 | Inserted: 45,000 | Skipped: 5,000 | Errors: 0]` +### NIP-43 invite codes + +Kind 28934 join requests are implemented. Mint a code and share it out of band: + + ``` + nostream invite create --json + docker compose exec nostream node src/cli/index.js invite create + ``` + +See [CLI.md](CLI.md) for `--uses` / `--expires-in` and Docker vs local Postgres. + ### Running as a Service By default this server will run continuously until you stop it with Ctrl+C or until the system restarts. diff --git a/resources/default-settings.yaml b/resources/default-settings.yaml index 99ff9856..1bf3f714 100755 --- a/resources/default-settings.yaml +++ b/resources/default-settings.yaml @@ -73,6 +73,11 @@ nip43: # (users who claimed an invite code via a kind 28934 join request) may # publish events. Enabling this on an open relay blocks all non-members. enabled: false + # Seconds until a newly minted invite code expires. Default is 600 (10 minutes). + # 0 means the code never expires. + inviteCodeExpirySeconds: 600 + # How many times a newly minted invite code can be claimed. + defaultMaxUses: 1 nip45: enabled: true nip50: diff --git a/src/@types/invite-code.ts b/src/@types/invite-code.ts index a74e7d37..753e6241 100644 --- a/src/@types/invite-code.ts +++ b/src/@types/invite-code.ts @@ -8,6 +8,12 @@ export interface InviteCode { updatedAt: Date } +export interface CreateInviteCodeOptions { + expiresAt?: Date | null + remainingUses?: number | null + createdBy?: string | null +} + export interface DBInviteCode { code: string created_by: Buffer | null diff --git a/src/@types/repositories.ts b/src/@types/repositories.ts index 717ba034..c25a0e62 100644 --- a/src/@types/repositories.ts +++ b/src/@types/repositories.ts @@ -3,7 +3,7 @@ import { EventKinds } from '../constants/base' import { DatabaseClient, EventId, Pubkey } from './base' import { DvmJob } from './dvm' import { DBEvent, Event } from './event' -import { InviteCode } from './invite-code' +import { CreateInviteCodeOptions, InviteCode } from './invite-code' import { Invoice } from './invoice' import { Nip05Verification } from './nip05' import { EventKindsRange } from './settings' @@ -67,7 +67,7 @@ export interface INip05VerificationRepository { } export interface IInviteCodeRepository { - create(code: string, expiresAt?: Date, remainingUses?: number | null): Promise + create(code: string, options?: CreateInviteCodeOptions): Promise findByCode(code: string): Promise claimCode(code: string, pubkey: Pubkey): Promise findActiveCodes(limit?: number): Promise diff --git a/src/@types/settings.ts b/src/@types/settings.ts index f4551421..56c64388 100644 --- a/src/@types/settings.ts +++ b/src/@types/settings.ts @@ -364,7 +364,7 @@ export interface Nip42Settings { export interface Nip43Settings { enabled: boolean - inviteCodeExpiry?: number + inviteCodeExpirySeconds?: number defaultMaxUses?: number allowInviteRequests?: boolean inviteRequestWhitelist?: Pubkey[] diff --git a/src/cli/commands/invite.ts b/src/cli/commands/invite.ts new file mode 100644 index 00000000..1d31c676 --- /dev/null +++ b/src/cli/commands/invite.ts @@ -0,0 +1,181 @@ +import knex, { Knex } from 'knex' + +import { DatabaseClient } from '../../@types/base' +import { InviteCode } from '../../@types/invite-code' +import { IInviteCodeRepository } from '../../@types/repositories' +import { Settings } from '../../@types/settings' +import { InviteCodeRepository } from '../../repositories/invite-code-repository' +import { issueInviteCode, parseRelayPubkey } from '../../utils/nip43-invites' +import { loadMergedSettings } from '../utils/config' +import { readEnvValues } from '../utils/env-config' +import { logInfo } from '../utils/output' + +const DB_ENV_KEYS = ['DB_URI', 'DB_HOST', 'DB_PORT', 'DB_USER', 'DB_PASSWORD', 'DB_NAME'] as const +const CLI_DB_ACQUIRE_TIMEOUT_MS = 3000 + +export const INVITE_CLI_DB_HINT = `If Nostream is running in Docker, Postgres is not published to the host. Run: + docker compose exec nostream node src/cli/index.js invite create + +If PostgreSQL is local, set DB_URI or DB_HOST (see .env.example).` + +export type InviteCreateOptions = { + uses?: number + expiresIn?: number + json?: boolean +} + +export type InviteCreateDependencies = { + loadSettings?: () => Settings + issue?: typeof issueInviteCode + createRepository?: (db: DatabaseClient) => IInviteCodeRepository + createDbClient?: () => DatabaseClient + now?: () => number +} + +const unquoteEnvValue = (value: string): string => { + const trimmed = value.trim() + if ((trimmed.startsWith('"') && trimmed.endsWith('"')) || (trimmed.startsWith("'") && trimmed.endsWith("'"))) { + return trimmed.slice(1, -1) + } + return trimmed +} + +export const applyDbEnvFileDefaults = (): void => { + const fileValues = readEnvValues() + + for (const key of DB_ENV_KEYS) { + if (process.env[key]) { + continue + } + const fileValue = fileValues[key] + if (fileValue) { + process.env[key] = unquoteEnvValue(fileValue) + } + } +} + +const UNREACHABLE_DB_CODES = new Set([ + 'ECONNREFUSED', + 'ENOTFOUND', + 'ETIMEDOUT', + 'EAI_AGAIN', + 'ECONNRESET', + 'ENETUNREACH', + 'EHOSTUNREACH', + '28P01', + '3D000', +]) + +const UNREACHABLE_DB_MESSAGE = + /connect ECONNREFUSED|getaddrinfo (?:ENOTFOUND|EAI_AGAIN)|connect ETIMEDOUT|connect ECONNRESET|timeout acquiring a connection|the pool is probably full|password authentication failed|database ".*" does not exist/i + +const walkErrorChain = (error: unknown): Array<{ code?: string; message?: string }> => { + const items: Array<{ code?: string; message?: string }> = [] + const seen = new Set() + let current: unknown = error + + while (current && typeof current === 'object' && !seen.has(current)) { + seen.add(current) + const err = current as { code?: string; message?: string; cause?: unknown; original?: unknown } + items.push({ code: err.code, message: err.message }) + current = err.cause ?? err.original + } + + return items +} + +const isUnreachableDbError = (error: unknown): boolean => + walkErrorChain(error).some( + ({ code, message }) => + (code !== undefined && UNREACHABLE_DB_CODES.has(code)) || + (typeof message === 'string' && UNREACHABLE_DB_MESSAGE.test(message)), + ) + +export const openInviteDbClient = (): DatabaseClient => { + applyDbEnvFileDefaults() + + if (!process.env.DB_URI && !process.env.DB_HOST) { + throw new Error(`PostgreSQL is not configured (set DB_URI or DB_HOST).\n\n${INVITE_CLI_DB_HINT}`) + } + + return knex({ + client: 'pg', + connection: process.env.DB_URI + ? process.env.DB_URI + : { + host: process.env.DB_HOST, + port: Number(process.env.DB_PORT ?? 5432), + user: process.env.DB_USER, + password: process.env.DB_PASSWORD, + database: process.env.DB_NAME, + }, + pool: { + min: 0, + max: 1, + idleTimeoutMillis: 1000, + acquireTimeoutMillis: CLI_DB_ACQUIRE_TIMEOUT_MS, + propagateCreateError: true, + }, + acquireConnectionTimeout: CLI_DB_ACQUIRE_TIMEOUT_MS, + } as Knex.Config) +} + +const serializeInviteCode = (invite: InviteCode) => ({ + code: invite.code, + createdBy: invite.createdBy, + claimedBy: invite.claimedBy, + expiresAt: invite.expiresAt ? invite.expiresAt.toISOString() : null, + remainingUses: invite.remainingUses, + createdAt: invite.createdAt.toISOString(), + updatedAt: invite.updatedAt.toISOString(), +}) + +export const runInviteCreate = async ( + options: InviteCreateOptions, + deps: InviteCreateDependencies = {}, +): Promise => { + const loadSettings = deps.loadSettings ?? loadMergedSettings + const issue = deps.issue ?? issueInviteCode + const now = deps.now ?? Date.now + const settings = loadSettings() + + const overrides: Parameters[2] = {} + if (typeof options.uses === 'number') { + overrides.remainingUses = options.uses + } + if (typeof options.expiresIn === 'number') { + overrides.expiresAt = new Date(now() + options.expiresIn * 1000) + } + const createdBy = parseRelayPubkey(settings.info?.self) + if (createdBy) { + overrides.createdBy = createdBy + } + + let dbClient: DatabaseClient | undefined + try { + dbClient = deps.createDbClient ? deps.createDbClient() : openInviteDbClient() + const repository = deps.createRepository ? deps.createRepository(dbClient) : new InviteCodeRepository(dbClient) + + const invite = await issue(repository, settings.nip43, overrides) + + if (options.json) { + logInfo(JSON.stringify(serializeInviteCode(invite))) + } else { + logInfo(invite.code) + logInfo(`uses: ${invite.remainingUses ?? 'unlimited'}`) + logInfo(`expires: ${invite.expiresAt ? invite.expiresAt.toISOString() : 'never'}`) + } + + return 0 + } catch (error) { + if (isUnreachableDbError(error)) { + const message = error instanceof Error ? error.message : String(error) + throw new Error(`${message}\n\n${INVITE_CLI_DB_HINT}`) + } + throw error + } finally { + if (dbClient && typeof (dbClient as Knex).destroy === 'function') { + await (dbClient as Knex).destroy() + } + } +} diff --git a/src/cli/index.ts b/src/cli/index.ts index f3e4f53c..6567f173 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -29,8 +29,9 @@ import { runDevTestIntegration, runDevTestUnit, runDevTestPerfConnection, - runDevTestPerfMessage + runDevTestPerfMessage, } from './commands/dev' +import { runInviteCreate } from './commands/invite' import { runTui } from './tui/main' import { logError, logInfo } from './utils/output' @@ -91,6 +92,10 @@ const configEnvSubHelp: Record = { validate: 'Usage: nostream config env validate', } +const inviteSubHelp: Record = { + create: 'Usage: nostream invite create [--uses N] [--expires-in ] [--json]', +} + const devSubHelp: Record = { 'db:clean': 'Usage: nostream dev db:clean [--all|--older-than=|--kinds=1,7,4] [--dry-run] [--force]', 'db:reset': 'Usage: nostream dev db:reset [--yes]', @@ -170,21 +175,17 @@ cli }), ) -cli - .command('update [...args]', 'Pull latest git changes and restart relay') - .action( - withErrorBoundary(async (args: unknown) => { - return runUpdate(args as string[]) - }), - ) +cli.command('update [...args]', 'Pull latest git changes and restart relay').action( + withErrorBoundary(async (args: unknown) => { + return runUpdate(args as string[]) + }), +) -cli - .command('clean', 'Clean Docker resources (legacy script replacement)') - .action( - withErrorBoundary(async () => { - return runDevDockerClean({ yes: true }) - }), - ) +cli.command('clean', 'Clean Docker resources (legacy script replacement)').action( + withErrorBoundary(async () => { + return runDevDockerClean({ yes: true }) + }), +) cli .command('import [file] [...args]', 'Import events from .jsonl or .json') @@ -237,9 +238,7 @@ cli (format) => !isStructuredExportFormat(format) && !isCompressionExportFormat(format), ) if (unknownFormats.length > 0) { - throw new CliUsageError( - `Unsupported format: ${unknownFormats[0]}. Supported values: json, jsonl, gzip, gz, xz`, - ) + throw new CliUsageError(`Unsupported format: ${unknownFormats[0]}. Supported values: json, jsonl, gzip, gz, xz`) } const structuredFormats = [...formatCandidates].filter(isStructuredExportFormat) @@ -251,17 +250,16 @@ cli const compressionFamilies = new Set(compressionFormats.map((format) => (format === 'xz' ? 'xz' : 'gzip'))) if (compressionFamilies.size > 1) { - throw new CliUsageError( - 'Conflicting compression formats were provided. Use only one of: gzip/gz or xz', - ) + throw new CliUsageError('Conflicting compression formats were provided. Use only one of: gzip/gz or xz') } if (structuredFormats.length > 0 && compressionFormats.length > 0) { - throw new CliUsageError('Cannot combine structured export format (json/jsonl) with compression format (gzip/gz/xz).') + throw new CliUsageError( + 'Cannot combine structured export format (json/jsonl) with compression format (gzip/gz/xz).', + ) } - const compress = - Boolean(resolved.compress) || passthrough.includes('--compress') || passthrough.includes('-z') + const compress = Boolean(resolved.compress) || passthrough.includes('--compress') || passthrough.includes('-z') if (structuredFormats.length > 0 && compress) { throw new CliUsageError('Cannot combine --compress with --format json/jsonl.') } @@ -290,6 +288,45 @@ cli }), ) +cli + .command('invite [...args]', 'Mint NIP-43 invite codes') + .option('--uses ', 'Times the code can be claimed (default: nip43.defaultMaxUses)', { type: [Number] }) + .option('--expires-in ', 'Code lifetime in seconds (default: nip43.inviteCodeExpirySeconds)', { type: [Number] }) + .option('--json', 'Print machine-readable JSON') + .action( + withErrorBoundary(async (args: unknown, options: unknown) => { + const positional = (args as string[]) ?? [] + const command = positional[0] + const resolved = options as Record + const json = Boolean(resolved.json) + + if (resolved.help && command && inviteSubHelp[command]) { + logInfo(inviteSubHelp[command]) + return 0 + } + + if (command !== 'create' || positional.length > 1) { + throw new CliUsageError(inviteSubHelp.create) + } + + const uses = Array.isArray(resolved.uses) ? resolved.uses[0] : resolved.uses + const expiresIn = Array.isArray(resolved.expiresIn) ? resolved.expiresIn[0] : resolved.expiresIn + + if (uses !== undefined && (!Number.isSafeInteger(uses) || (uses as number) < 1)) { + throw new CliUsageError('--uses must be a positive integer') + } + if (expiresIn !== undefined && (!Number.isSafeInteger(expiresIn) || (expiresIn as number) < 1)) { + throw new CliUsageError('--expires-in must be a positive integer (seconds)') + } + + return runInviteCreate({ + uses: uses as number | undefined, + expiresIn: expiresIn as number | undefined, + json, + }) + }), + ) + cli .command('setup', 'Initial interactive setup') .option('-y, --yes', 'Non-interactive defaults') @@ -444,6 +481,7 @@ withErrorBoundary(async () => { 'dev', 'update', 'clean', + 'invite', ]) if (userArgs.length >= 2 && userArgs.includes('--help')) { @@ -466,6 +504,11 @@ withErrorBoundary(async () => { logInfo(devSubHelp[userArgs[1]]) return 0 } + + if (userArgs[0] === 'invite' && inviteSubHelp[userArgs[1]]) { + logInfo(inviteSubHelp[userArgs[1]]) + return 0 + } } if (userArgs.length > 0 && !userArgs[0].startsWith('-') && !knownTopLevel.has(userArgs[0])) { diff --git a/src/repositories/invite-code-repository.ts b/src/repositories/invite-code-repository.ts index e539996b..0abaa120 100644 --- a/src/repositories/invite-code-repository.ts +++ b/src/repositories/invite-code-repository.ts @@ -1,17 +1,11 @@ -import { randomBytes } from 'crypto' - import { DatabaseClient, Pubkey } from '../@types/base' -import { DBInviteCode, InviteCode } from '../@types/invite-code' +import { CreateInviteCodeOptions, 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, @@ -25,8 +19,12 @@ function fromDBInviteCode(row: DBInviteCode): InviteCode { } function affectedRows(result: unknown): number { - if (typeof result === 'number') { return result } - if (result && typeof (result as any).rowCount === 'number') { return (result as any).rowCount } + if (typeof result === 'number') { + return result + } + if (result && typeof (result as any).rowCount === 'number') { + return (result as any).rowCount + } return 0 } @@ -35,18 +33,21 @@ export class InviteCodeRepository implements IInviteCodeRepository { public async create( code: string, - expiresAt?: Date, - remainingUses: number | null = 1, + options: CreateInviteCodeOptions = {}, client: DatabaseClient = this.dbClient, ): Promise { + const expiresAt = options.expiresAt ?? null + const remainingUses = options.remainingUses === undefined ? 1 : options.remainingUses + const createdBy = options.createdBy ?? null + logger('create invite code (expires: %s, remainingUses: %s)', expiresAt ?? 'never', remainingUses ?? 'unlimited') const now = new Date() const row: DBInviteCode = { code, - created_by: null, + created_by: createdBy ? toBuffer(createdBy) : null, claimed_by: null, - expires_at: expiresAt ?? null, + expires_at: expiresAt, remaining_uses: remainingUses, created_at: now, updated_at: now, @@ -57,15 +58,10 @@ export class InviteCodeRepository implements IInviteCodeRepository { return fromDBInviteCode(row) } - public async findByCode( - code: string, - client: DatabaseClient = this.dbClient, - ): Promise { + public async findByCode(code: string, client: DatabaseClient = this.dbClient): Promise { logger('find invite code') - const [row] = await client('invite_codes') - .where('code', code) - .select() + const [row] = await client('invite_codes').where('code', code).select() if (!row) { return @@ -75,11 +71,7 @@ export class InviteCodeRepository implements IInviteCodeRepository { } // 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 { + public async claimCode(code: string, pubkey: Pubkey, client: DatabaseClient = this.dbClient): Promise { logger('claim invite code for %s', pubkey) const now = new Date() @@ -91,8 +83,7 @@ export class InviteCodeRepository implements IInviteCodeRepository { .orWhere('remaining_uses', '>', 0) }) .where(function () { - this.whereNull('expires_at') - .orWhere('expires_at', '>', now) + this.whereNull('expires_at').orWhere('expires_at', '>', now) }) .update({ remaining_uses: client.raw('remaining_uses - 1'), @@ -103,22 +94,17 @@ export class InviteCodeRepository implements IInviteCodeRepository { return affectedRows(result) > 0 } - public async findActiveCodes( - limit: number = 100, - client: DatabaseClient = this.dbClient, - ): Promise { + public async findActiveCodes(limit: number = 100, client: DatabaseClient = this.dbClient): Promise { logger('find active invite codes (limit %d)', limit) const now = new Date() const rows = await client('invite_codes') .where(function () { - this.whereNull('expires_at') - .orWhere('expires_at', '>', now) + this.whereNull('expires_at').orWhere('expires_at', '>', now) }) .where(function () { - this.whereNull('remaining_uses') - .orWhere('remaining_uses', '>', 0) + this.whereNull('remaining_uses').orWhere('remaining_uses', '>', 0) }) .orderBy('created_at', 'desc') .limit(limit) @@ -127,9 +113,7 @@ export class InviteCodeRepository implements IInviteCodeRepository { return rows.map(fromDBInviteCode) } - public async deleteExpiredCodes( - client: DatabaseClient = this.dbClient, - ): Promise { + public async deleteExpiredCodes(client: DatabaseClient = this.dbClient): Promise { logger('delete expired invite codes') const now = new Date() diff --git a/src/utils/nip43-invites.ts b/src/utils/nip43-invites.ts new file mode 100644 index 00000000..fc19e268 --- /dev/null +++ b/src/utils/nip43-invites.ts @@ -0,0 +1,87 @@ +import { randomBytes } from 'crypto' + +import { CreateInviteCodeOptions, InviteCode } from '../@types/invite-code' +import { IInviteCodeRepository } from '../@types/repositories' +import { Nip43Settings } from '../@types/settings' +import { fromBech32 } from './transform' + +export const DEFAULT_INVITE_MAX_USES = 1 +export const DEFAULT_INVITE_CODE_EXPIRY_SECONDS = 600 + +const HEX_PUBKEY = /^[0-9a-f]{64}$/i + +export const generateInviteCode = (): string => randomBytes(16).toString('hex') + +export interface IssueInviteCodeOverrides { + remainingUses?: number + expiresAt?: Date | null + createdBy?: string | null +} + +export const isHexPubkey = (value: string | undefined | null): boolean => + typeof value === 'string' && HEX_PUBKEY.test(value) + +export const parseRelayPubkey = (value: string | undefined | null): string | undefined => { + if (typeof value !== 'string' || value.length === 0) { + return undefined + } + + if (HEX_PUBKEY.test(value)) { + return value.toLowerCase() + } + + if (value.toLowerCase().startsWith('npub1')) { + const hex = fromBech32(value) + if (!HEX_PUBKEY.test(hex)) { + throw new Error('info.self npub did not decode to a 32-byte pubkey') + } + return hex + } + + return undefined +} + +export const resolveInviteCodeLimits = ( + settings: Nip43Settings | undefined, + overrides: IssueInviteCodeOverrides = {}, +): Required> => { + const remainingUses = overrides.remainingUses ?? settings?.defaultMaxUses ?? DEFAULT_INVITE_MAX_USES + + if (!Number.isSafeInteger(remainingUses) || remainingUses < 1) { + throw new Error('remainingUses must be a positive integer') + } + + if (overrides.expiresAt !== undefined) { + return { remainingUses, expiresAt: overrides.expiresAt } + } + + const expirySeconds = settings?.inviteCodeExpirySeconds ?? DEFAULT_INVITE_CODE_EXPIRY_SECONDS + const expiresAt = + typeof expirySeconds === 'number' && Number.isFinite(expirySeconds) && expirySeconds > 0 + ? new Date(Date.now() + expirySeconds * 1000) + : null + + return { remainingUses, expiresAt } +} + +export const issueInviteCode = async ( + repository: IInviteCodeRepository, + settings: Nip43Settings | undefined, + overrides: IssueInviteCodeOverrides = {}, +): Promise => { + const { remainingUses, expiresAt } = resolveInviteCodeLimits(settings, overrides) + + let createdBy: string | null = null + if (overrides.createdBy) { + if (!isHexPubkey(overrides.createdBy)) { + throw new Error('createdBy must be a 64-character hex pubkey') + } + createdBy = overrides.createdBy.toLowerCase() + } + + return repository.create(generateInviteCode(), { + remainingUses, + expiresAt, + createdBy, + }) +} diff --git a/test/unit/cli/cli.integration.spec.ts b/test/unit/cli/cli.integration.spec.ts index 69462de2..d6c75fb9 100644 --- a/test/unit/cli/cli.integration.spec.ts +++ b/test/unit/cli/cli.integration.spec.ts @@ -80,11 +80,7 @@ const runPnpmCli = (args: string[], env: NodeJS.ProcessEnv = {}): Promise { const target = path.join(dir, name) - fs.writeFileSync( - target, - ['#!/usr/bin/env bash', 'set -euo pipefail', scriptBody].join('\n'), - 'utf-8', - ) + fs.writeFileSync(target, ['#!/usr/bin/env bash', 'set -euo pipefail', scriptBody].join('\n'), 'utf-8') fs.chmodSync(target, 0o755) } @@ -135,6 +131,7 @@ describe('cli integration (spawn)', function () { expect(result.code).to.equal(0) expect(result.stdout).to.include('Usage:') expect(result.stdout).to.include('config [...args]') + expect(result.stdout).to.include('invite [...args]') expect(result.stdout).to.include('update [...args]') expect(result.stdout).to.include('clean') }) @@ -183,6 +180,34 @@ describe('cli integration (spawn)', function () { expect(devClean.stdout).to.include('Usage: nostream dev db:clean') }) + it('shows invite create help', async () => { + const result = await runCli(['invite', 'create', '--help']) + + expect(result.code).to.equal(0) + expect(result.stdout).to.include('Usage: nostream invite create') + }) + + it('returns usage exit code for invite without a subcommand', async () => { + const result = await runCli(['invite']) + + expect(result.code).to.equal(2) + expect(result.stderr).to.include('Usage: nostream invite create') + }) + + it('rejects --uses 0 before touching the database', async () => { + const result = await runCli(['invite', 'create', '--uses', '0']) + + expect(result.code).to.equal(2) + expect(result.stderr).to.include('--uses must be a positive integer') + }) + + it('rejects extra positional arguments after invite create', async () => { + const result = await runCli(['invite', 'create', 'leftover']) + + expect(result.code).to.equal(2) + expect(result.stderr).to.include('Usage: nostream invite create') + }) + it('returns usage exit code for unknown command', async () => { const result = await runCli(['nope']) @@ -200,29 +225,25 @@ describe('cli integration (spawn)', function () { it('supports config set/get with indexed path and validation controls', async () => { const configDir = fs.mkdtempSync(path.join(os.tmpdir(), 'nostream-cli-config-')) - const setIndexed = await runCli( - ['config', 'set', 'limits.event.content[0].maxLength', '2048'], - { NOSTR_CONFIG_DIR: configDir }, - ) + const setIndexed = await runCli(['config', 'set', 'limits.event.content[0].maxLength', '2048'], { + NOSTR_CONFIG_DIR: configDir, + }) expect(setIndexed.code).to.equal(0) - const getIndexed = await runCli( - ['config', 'get', 'limits.event.content[0].maxLength'], - { NOSTR_CONFIG_DIR: configDir }, - ) + const getIndexed = await runCli(['config', 'get', 'limits.event.content[0].maxLength'], { + NOSTR_CONFIG_DIR: configDir, + }) expect(getIndexed.code).to.equal(0) expect(getIndexed.stdout).to.include('2048') - const setInvalidValidated = await runCli( - ['config', 'set', 'limits.rateLimiter.strategy', 'broken-strategy'], - { NOSTR_CONFIG_DIR: configDir }, - ) + const setInvalidValidated = await runCli(['config', 'set', 'limits.rateLimiter.strategy', 'broken-strategy'], { + NOSTR_CONFIG_DIR: configDir, + }) expect(setInvalidValidated.code).to.equal(1) - const getStrategyAfterReject = await runCli( - ['config', 'get', 'limits.rateLimiter.strategy'], - { NOSTR_CONFIG_DIR: configDir }, - ) + const getStrategyAfterReject = await runCli(['config', 'get', 'limits.rateLimiter.strategy'], { + NOSTR_CONFIG_DIR: configDir, + }) expect(getStrategyAfterReject.code).to.equal(0) expect(getStrategyAfterReject.stdout).to.include('ewma') @@ -232,10 +253,9 @@ describe('cli integration (spawn)', function () { ) expect(setInvalidNoValidate.code).to.equal(0) - const getStrategyAfterNoValidate = await runCli( - ['config', 'get', 'limits.rateLimiter.strategy'], - { NOSTR_CONFIG_DIR: configDir }, - ) + const getStrategyAfterNoValidate = await runCli(['config', 'get', 'limits.rateLimiter.strategy'], { + NOSTR_CONFIG_DIR: configDir, + }) expect(getStrategyAfterNoValidate.code).to.equal(0) expect(getStrategyAfterNoValidate.stdout).to.include('broken-strategy') }) @@ -250,10 +270,7 @@ describe('cli integration (spawn)', function () { expect(setResult.code).to.equal(0) - const getResult = await runCli( - ['config', 'get', 'nip05.domainWhitelist'], - { NOSTR_CONFIG_DIR: configDir }, - ) + const getResult = await runCli(['config', 'get', 'nip05.domainWhitelist'], { NOSTR_CONFIG_DIR: configDir }) expect(getResult.code).to.equal(0) expect(getResult.stdout).to.include('example.com') @@ -334,7 +351,9 @@ describe('cli integration (spawn)', function () { expect(importResult.stderr).to.include('Unknown option `--format`') expect(exportResult.code).to.equal(2) - expect(exportResult.stderr).to.include('Error: Unsupported format: yaml. Supported values: json, jsonl, gzip, gz, xz') + expect(exportResult.stderr).to.include( + 'Error: Unsupported format: yaml. Supported values: json, jsonl, gzip, gz, xz', + ) expect(exportResult.stderr).to.include('Unsupported format: yaml') expect(conflictingExportResult.code).to.equal(2) @@ -353,14 +372,7 @@ describe('cli integration (spawn)', function () { const logPath = path.join(shimDir, 'docker.log') const configDir = fs.mkdtempSync(path.join(os.tmpdir(), 'nostream-cli-shim-config-')) - createShimCommand( - shimDir, - 'docker', - [ - `echo "$*" >> "${logPath}"`, - 'exit 0', - ].join('\n'), - ) + createShimCommand(shimDir, 'docker', [`echo "$*" >> "${logPath}"`, 'exit 0'].join('\n')) const result = await runCli(['start', '--tor', '--i2p', '--debug'], { PATH: `${shimDir}:${process.env.PATH}`, @@ -381,14 +393,7 @@ describe('cli integration (spawn)', function () { const logPath = path.join(shimDir, 'docker.log') const configDir = fs.mkdtempSync(path.join(os.tmpdir(), 'nostream-cli-shim-port-config-')) - createShimCommand( - shimDir, - 'docker', - [ - `echo "$*" >> "${logPath}"`, - 'exit 0', - ].join('\n'), - ) + createShimCommand(shimDir, 'docker', [`echo "$*" >> "${logPath}"`, 'exit 0'].join('\n')) const before = fs .readdirSync(os.tmpdir()) @@ -419,14 +424,7 @@ describe('cli integration (spawn)', function () { const logPath = path.join(shimDir, 'docker.log') const configDir = fs.mkdtempSync(path.join(os.tmpdir(), 'nostream-cli-shim-clean-config-')) - createShimCommand( - shimDir, - 'docker', - [ - `echo "$*" >> "${logPath}"`, - 'exit 0', - ].join('\n'), - ) + createShimCommand(shimDir, 'docker', [`echo "$*" >> "${logPath}"`, 'exit 0'].join('\n')) const result = await runCli(['clean'], { PATH: `${shimDir}:${process.env.PATH}`, @@ -448,14 +446,7 @@ describe('cli integration (spawn)', function () { const gitLogPath = path.join(shimDir, 'git.log') const configDir = fs.mkdtempSync(path.join(os.tmpdir(), 'nostream-cli-shim-update-config-')) - createShimCommand( - shimDir, - 'docker', - [ - `echo "$*" >> "${dockerLogPath}"`, - 'exit 0', - ].join('\n'), - ) + createShimCommand(shimDir, 'docker', [`echo "$*" >> "${dockerLogPath}"`, 'exit 0'].join('\n')) createShimCommand( shimDir, diff --git a/test/unit/cli/docs.spec.ts b/test/unit/cli/docs.spec.ts index c4bbf565..7d464d4c 100644 --- a/test/unit/cli/docs.spec.ts +++ b/test/unit/cli/docs.spec.ts @@ -21,6 +21,15 @@ describe('cli documentation alignment', () => { expect(cliDoc).to.include('Advanced dot-path get/set remains available for full settings access.') }) + it('documents NIP-43 invite minting', () => { + const readme = fs.readFileSync(path.join(projectRoot, 'README.md'), 'utf-8') + const cliDoc = fs.readFileSync(path.join(projectRoot, 'CLI.md'), 'utf-8') + + expect(cliDoc).to.include('nostream invite create') + expect(cliDoc).to.include('docker compose exec nostream node src/cli/index.js invite create') + expect(readme).to.include('nostream invite create') + }) + it('does not ship removed legacy wrapper scripts', () => { const removedWrappers = [ 'start', diff --git a/test/unit/cli/invite.spec.ts b/test/unit/cli/invite.spec.ts new file mode 100644 index 00000000..211efc03 --- /dev/null +++ b/test/unit/cli/invite.spec.ts @@ -0,0 +1,227 @@ +import { expect } from 'chai' +import sinon from 'sinon' + +import type { InviteCode } from '../../../src/@types/invite-code' +import type { IInviteCodeRepository } from '../../../src/@types/repositories' +import type { Settings } from '../../../src/@types/settings' +import { + applyDbEnvFileDefaults, + INVITE_CLI_DB_HINT, + openInviteDbClient, + runInviteCreate, +} from '../../../src/cli/commands/invite' +import * as envConfig from '../../../src/cli/utils/env-config' +import * as output from '../../../src/cli/utils/output' +import { toBech32 } from '../../../src/utils/transform' + +describe('runInviteCreate', () => { + const pubkey = '22e804d26ed16b68db5259e78449e96dab5d464c8f470bda3eb1a70467f2c793' + const now = new Date('2026-08-15T12:00:00.000Z') + + const invite: InviteCode = { + code: 'abc123deadbeef4567890000cafebabe', + createdBy: pubkey, + claimedBy: null, + expiresAt: null, + remainingUses: 1, + createdAt: now, + updatedAt: now, + } + + let stdout = '' + let issue: sinon.SinonStub + let destroy: sinon.SinonStub + let sandbox: sinon.SinonSandbox + + const settings = (overrides: Partial = {}): Settings => + ({ + info: { self: pubkey, relay_url: 'wss://test.relay', name: 'test', pubkey: '', contact: '', description: '' }, + nip43: { enabled: false, defaultMaxUses: 1, inviteCodeExpirySeconds: 600 }, + ...overrides, + }) as Settings + + beforeEach(() => { + sandbox = sinon.createSandbox() + stdout = '' + sandbox.stub(output, 'logInfo').callsFake((message: string) => { + stdout += `${message}\n` + }) + issue = sandbox.stub().resolves(invite) + destroy = sandbox.stub().resolves() + }) + + afterEach(() => { + sandbox.restore() + }) + + const deps = (load: () => Settings = () => settings()) => ({ + loadSettings: load, + issue, + createDbClient: () => ({ destroy }) as any, + createRepository: () => ({}) as IInviteCodeRepository, + now: () => now.getTime(), + }) + + it('prints the code first so scripts can capture it', async () => { + const code = await runInviteCreate({}, deps()) + + expect(code).to.equal(0) + expect(stdout.split('\n')[0]).to.equal(invite.code) + expect(stdout).to.include('uses: 1') + expect(stdout).to.include('expires: never') + expect(destroy.calledOnce).to.equal(true) + }) + + it('prints JSON when requested', async () => { + const code = await runInviteCreate({ json: true }, deps()) + + expect(code).to.equal(0) + expect(JSON.parse(stdout)).to.deep.equal({ + code: invite.code, + createdBy: pubkey, + claimedBy: null, + expiresAt: null, + remainingUses: 1, + createdAt: now.toISOString(), + updatedAt: now.toISOString(), + }) + }) + + it('passes CLI overrides and info.self as createdBy', async () => { + await runInviteCreate({ uses: 4, expiresIn: 120 }, deps()) + + expect(issue.calledOnce).to.equal(true) + const [, nip43, overrides] = issue.firstCall.args + expect(nip43).to.deep.equal({ enabled: false, defaultMaxUses: 1, inviteCodeExpirySeconds: 600 }) + expect(overrides.remainingUses).to.equal(4) + expect(overrides.expiresAt).to.deep.equal(new Date(now.getTime() + 120_000)) + expect(overrides.createdBy).to.equal(pubkey) + }) + + it('omits createdBy when info.self is the placeholder', async () => { + await runInviteCreate( + {}, + deps(() => settings({ info: { self: 'replace-with-your-relay-pubkey-in-hex' } } as any)), + ) + + expect(issue.firstCall.args[2].createdBy).to.equal(undefined) + }) + + it('decodes npub info.self into createdBy hex', async () => { + await runInviteCreate( + {}, + deps(() => settings({ info: { self: toBech32('npub')(pubkey) } } as any)), + ) + + expect(issue.firstCall.args[2].createdBy).to.equal(pubkey) + }) + + it('fails before minting when info.self is a malformed npub', async () => { + try { + await runInviteCreate( + {}, + deps(() => settings({ info: { self: 'npub1invalid' } } as any)), + ) + expect.fail('expected throw') + } catch (error) { + expect(issue.called).to.equal(false) + expect((error as Error).message).to.not.include(INVITE_CLI_DB_HINT) + } + }) + + it('still destroys the db client when issue throws', async () => { + issue.rejects(new Error('insert failed')) + + try { + await runInviteCreate({}, deps()) + expect.fail('expected throw') + } catch (error) { + expect((error as Error).message).to.equal('insert failed') + } + + expect(destroy.calledOnce).to.equal(true) + }) + + it('appends the docker hint when postgres is unreachable', async () => { + const connError = Object.assign(new Error('connect ECONNREFUSED 127.0.0.1:5432'), { code: 'ECONNREFUSED' }) + issue.rejects(connError) + + try { + await runInviteCreate({}, deps()) + expect.fail('expected throw') + } catch (error) { + expect((error as Error).message).to.include('ECONNREFUSED') + expect((error as Error).message).to.include(INVITE_CLI_DB_HINT) + } + }) + + it('appends the docker hint for knex acquire timeouts', async () => { + issue.rejects(new Error('Knex: Timeout acquiring a connection. The pool is probably full.')) + + try { + await runInviteCreate({}, deps()) + expect.fail('expected throw') + } catch (error) { + expect((error as Error).message).to.include(INVITE_CLI_DB_HINT) + } + }) + + it('does not treat unrelated timeouts as unreachable postgres', async () => { + issue.rejects(new Error('statement timeout')) + + try { + await runInviteCreate({}, deps()) + expect.fail('expected throw') + } catch (error) { + expect((error as Error).message).to.equal('statement timeout') + } + }) +}) + +describe('applyDbEnvFileDefaults', () => { + let sandbox: sinon.SinonSandbox + let previousHost: string | undefined + let previousUri: string | undefined + + beforeEach(() => { + sandbox = sinon.createSandbox() + previousHost = process.env.DB_HOST + previousUri = process.env.DB_URI + delete process.env.DB_HOST + delete process.env.DB_URI + }) + + afterEach(() => { + sandbox.restore() + if (previousHost === undefined) { + delete process.env.DB_HOST + } else { + process.env.DB_HOST = previousHost + } + if (previousUri === undefined) { + delete process.env.DB_URI + } else { + process.env.DB_URI = previousUri + } + }) + + it('fills missing DB_* from .env without overriding the process environment', () => { + process.env.DB_HOST = 'from-process' + sandbox.stub(envConfig, 'readEnvValues').returns({ + DB_HOST: 'from-file', + DB_URI: '"postgresql://relay:relay@db:5432/relay"', + }) + + applyDbEnvFileDefaults() + + expect(process.env.DB_HOST).to.equal('from-process') + expect(process.env.DB_URI).to.equal('postgresql://relay:relay@db:5432/relay') + }) + + it('fails loudly when PostgreSQL is not configured', () => { + sandbox.stub(envConfig, 'readEnvValues').returns({}) + + expect(() => openInviteDbClient()).to.throw('PostgreSQL is not configured') + expect(() => openInviteDbClient()).to.throw(INVITE_CLI_DB_HINT) + }) +}) diff --git a/test/unit/repositories/invite-code-repository.spec.ts b/test/unit/repositories/invite-code-repository.spec.ts index eb90e43f..abd7379d 100644 --- a/test/unit/repositories/invite-code-repository.spec.ts +++ b/test/unit/repositories/invite-code-repository.spec.ts @@ -5,7 +5,7 @@ import sinonChai from 'sinon-chai' import chaiAsPromised from 'chai-as-promised' import { DatabaseClient } from '../../../src/@types/base' -import { generateInviteCode, InviteCodeRepository } from '../../../src/repositories/invite-code-repository' +import { InviteCodeRepository } from '../../../src/repositories/invite-code-repository' chai.use(sinonChai) chai.use(chaiAsPromised) @@ -40,21 +40,11 @@ describe('InviteCodeRepository', () => { }) afterEach(async () => { - try { await dbClient.destroy() } finally { sandbox.restore() } - }) - - describe('generateInviteCode', () => { - it('returns a 32-character hex string', () => { - const code = generateInviteCode() - expect(code).to.be.a('string') - expect(code).to.have.lengthOf(32) - expect(code).to.match(/^[0-9a-f]{32}$/) - }) - - it('generates unique codes on successive calls', () => { - const codes = new Set(Array.from({ length: 50 }, () => generateInviteCode())) - expect(codes.size).to.equal(50) - }) + try { + await dbClient.destroy() + } finally { + sandbox.restore() + } }) describe('.create', () => { @@ -64,7 +54,7 @@ describe('InviteCodeRepository', () => { insert: insertStub, }) as unknown as DatabaseClient - await repository.create(testCode, undefined, 1, client) + await repository.create(testCode, { remainingUses: 1 }, client) expect(client).to.have.been.calledWith('invite_codes') }) @@ -75,7 +65,7 @@ describe('InviteCodeRepository', () => { insert: insertStub, }) as unknown as DatabaseClient - const result = await repository.create(testCode, undefined, 1, client) + const result = await repository.create(testCode, { remainingUses: 1 }, client) expect(result).to.deep.include({ code: testCode, @@ -95,7 +85,7 @@ describe('InviteCodeRepository', () => { }) as unknown as DatabaseClient const expiresAt = new Date('2026-07-01T00:00:00.000Z') - const result = await repository.create(testCode, expiresAt, 5, client) + const result = await repository.create(testCode, { expiresAt, remainingUses: 5 }, client) expect(result.expiresAt).to.deep.equal(expiresAt) expect(result.remainingUses).to.equal(5) @@ -111,7 +101,7 @@ describe('InviteCodeRepository', () => { insert: insertStub, }) as unknown as DatabaseClient - const result = await repository.create(testCode, undefined, 1, client) + const result = await repository.create(testCode, { remainingUses: 1 }, client) expect(result.expiresAt).to.be.null const insertedRow = insertStub.firstCall.args[0] @@ -124,10 +114,23 @@ describe('InviteCodeRepository', () => { insert: insertStub, }) as unknown as DatabaseClient - const result = await repository.create(testCode, undefined, undefined, client) + const result = await repository.create(testCode, {}, client) expect(result.remainingUses).to.equal(1) }) + + it('persists created_by when provided', async () => { + const insertStub = sandbox.stub().resolves() + const client = sandbox.stub().returns({ + insert: insertStub, + }) as unknown as DatabaseClient + + const result = await repository.create(testCode, { createdBy: pubkeyHex }, client) + + expect(result.createdBy).to.equal(pubkeyHex) + const insertedRow = insertStub.firstCall.args[0] + expect(insertedRow.created_by).to.deep.equal(Buffer.from(pubkeyHex, 'hex')) + }) }) describe('.findByCode', () => { diff --git a/test/unit/utils/nip43-invites.spec.ts b/test/unit/utils/nip43-invites.spec.ts new file mode 100644 index 00000000..598d8688 --- /dev/null +++ b/test/unit/utils/nip43-invites.spec.ts @@ -0,0 +1,184 @@ +import * as chai from 'chai' +import chaiAsPromised from 'chai-as-promised' +import * as sinon from 'sinon' +import sinonChai from 'sinon-chai' +import { InviteCode } from '../../../src/@types/invite-code' +import { IInviteCodeRepository } from '../../../src/@types/repositories' +import { Nip43Settings } from '../../../src/@types/settings' +import { + DEFAULT_INVITE_CODE_EXPIRY_SECONDS, + DEFAULT_INVITE_MAX_USES, + generateInviteCode, + isHexPubkey, + issueInviteCode, + parseRelayPubkey, + resolveInviteCodeLimits, +} from '../../../src/utils/nip43-invites' +import { toBech32 } from '../../../src/utils/transform' + +chai.use(sinonChai) +chai.use(chaiAsPromised) + +const { expect } = chai + +describe('nip43-invites', () => { + const pubkey = '22e804d26ed16b68db5259e78449e96dab5d464c8f470bda3eb1a70467f2c793' + const fixedNow = new Date('2026-08-15T12:00:00.000Z') + + let sandbox: sinon.SinonSandbox + + beforeEach(() => { + sandbox = sinon.createSandbox() + sandbox.useFakeTimers(fixedNow.getTime()) + }) + + afterEach(() => { + sandbox.restore() + }) + + describe('generateInviteCode', () => { + it('returns a 32-character hex string', () => { + const code = generateInviteCode() + expect(code).to.be.a('string') + expect(code).to.have.lengthOf(32) + expect(code).to.match(/^[0-9a-f]{32}$/) + }) + + it('generates unique codes on successive calls', () => { + const codes = new Set(Array.from({ length: 50 }, () => generateInviteCode())) + expect(codes.size).to.equal(50) + }) + }) + + describe('isHexPubkey', () => { + it('accepts 64-character hex', () => { + expect(isHexPubkey(pubkey)).to.equal(true) + expect(isHexPubkey(pubkey.toUpperCase())).to.equal(true) + }) + + it('rejects placeholders and short values', () => { + expect(isHexPubkey('replace-with-your-relay-pubkey-in-hex')).to.equal(false) + expect(isHexPubkey('aabbcc')).to.equal(false) + expect(isHexPubkey(undefined)).to.equal(false) + expect(isHexPubkey(null)).to.equal(false) + }) + }) + + describe('parseRelayPubkey', () => { + it('lowercases hex pubkeys', () => { + expect(parseRelayPubkey(pubkey.toUpperCase())).to.equal(pubkey) + }) + + it('decodes npub1 self the same way NIP-11 does', () => { + expect(parseRelayPubkey(toBech32('npub')(pubkey))).to.equal(pubkey) + }) + + it('omits the settings placeholder and other non-pubkeys', () => { + expect(parseRelayPubkey('replace-with-your-relay-pubkey-in-hex')).to.equal(undefined) + expect(parseRelayPubkey('')).to.equal(undefined) + expect(parseRelayPubkey(undefined)).to.equal(undefined) + }) + + it('throws on a malformed npub', () => { + expect(() => parseRelayPubkey('npub1invalid')).to.throw() + }) + }) + + describe('resolveInviteCodeLimits', () => { + it('defaults to one use and a 10-minute expiry', () => { + expect(resolveInviteCodeLimits(undefined)).to.deep.equal({ + remainingUses: DEFAULT_INVITE_MAX_USES, + expiresAt: new Date(fixedNow.getTime() + DEFAULT_INVITE_CODE_EXPIRY_SECONDS * 1000), + }) + }) + + it('reads defaultMaxUses and inviteCodeExpirySeconds from settings', () => { + const settings: Nip43Settings = { enabled: true, defaultMaxUses: 3, inviteCodeExpirySeconds: 60 } + + expect(resolveInviteCodeLimits(settings)).to.deep.equal({ + remainingUses: 3, + expiresAt: new Date(fixedNow.getTime() + 60_000), + }) + }) + + it('treats inviteCodeExpirySeconds 0 as never expires', () => { + expect(resolveInviteCodeLimits({ enabled: false, inviteCodeExpirySeconds: 0, defaultMaxUses: 1 })).to.deep.equal({ + remainingUses: 1, + expiresAt: null, + }) + }) + + it('lets overrides win over settings', () => { + const expiresAt = new Date('2026-08-16T00:00:00.000Z') + const settings: Nip43Settings = { enabled: true, defaultMaxUses: 9, inviteCodeExpirySeconds: 3600 } + + expect(resolveInviteCodeLimits(settings, { remainingUses: 2, expiresAt })).to.deep.equal({ + remainingUses: 2, + expiresAt, + }) + }) + + it('rejects non-positive remainingUses', () => { + expect(() => resolveInviteCodeLimits(undefined, { remainingUses: 0 })).to.throw('positive integer') + expect(() => resolveInviteCodeLimits({ enabled: false, defaultMaxUses: -1 })).to.throw('positive integer') + }) + }) + + describe('issueInviteCode', () => { + const stored: InviteCode = { + code: 'abc123deadbeef4567890000cafebabe', + createdBy: null, + claimedBy: null, + expiresAt: null, + remainingUses: 1, + createdAt: fixedNow, + updatedAt: fixedNow, + } + + it('uses a 10-minute expiry when settings omit inviteCodeExpirySeconds', async () => { + const create = sandbox.stub().resolves(stored) + const repository = { create } as unknown as IInviteCodeRepository + + await issueInviteCode(repository, { enabled: false }) + + expect(create.firstCall.args[1].expiresAt).to.deep.equal( + new Date(fixedNow.getTime() + DEFAULT_INVITE_CODE_EXPIRY_SECONDS * 1000), + ) + }) + + it('generates a code and persists yaml defaults', async () => { + const create = sandbox.stub().resolves(stored) + const repository = { create } as unknown as IInviteCodeRepository + + const result = await issueInviteCode(repository, { enabled: false, defaultMaxUses: 1, inviteCodeExpirySeconds: 0 }) + + expect(result).to.equal(stored) + expect(create).to.have.been.calledOnce + const [code, options] = create.firstCall.args + expect(code).to.match(/^[0-9a-f]{32}$/) + expect(options).to.deep.equal({ + remainingUses: 1, + expiresAt: null, + createdBy: null, + }) + }) + + it('lowercases a valid createdBy pubkey', async () => { + const create = sandbox.stub().resolves(stored) + const repository = { create } as unknown as IInviteCodeRepository + + await issueInviteCode(repository, undefined, { createdBy: pubkey.toUpperCase() }) + + expect(create.firstCall.args[1].createdBy).to.equal(pubkey) + }) + + it('rejects an invalid createdBy pubkey', async () => { + const repository = { create: sandbox.stub() } as unknown as IInviteCodeRepository + + await expect(issueInviteCode(repository, undefined, { createdBy: 'not-a-pubkey' })).to.be.rejectedWith( + 'createdBy must be a 64-character hex pubkey', + ) + expect((repository.create as sinon.SinonStub).called).to.equal(false) + }) + }) +}) diff --git a/test/unit/utils/settings.spec.ts b/test/unit/utils/settings.spec.ts index 57e5f9e2..f0856024 100644 --- a/test/unit/utils/settings.spec.ts +++ b/test/unit/utils/settings.spec.ts @@ -261,6 +261,32 @@ describe('SettingsStatic', () => { }) }) + describe('NIP-43 settings defaults', () => { + it('default-settings.yaml contains a nip43 block with mint defaults', () => { + const defaults = SettingsStatic.loadAndParseYamlFile(SettingsStatic.getDefaultSettingsFilePath()) + + expect(defaults).to.have.nested.property('nip43.enabled', false) + expect(defaults).to.have.nested.property('nip43.inviteCodeExpirySeconds', 600) + expect(defaults).to.have.nested.property('nip43.defaultMaxUses', 1) + }) + + it('user config nip43 block overrides defaults', () => { + const defaults = SettingsStatic.loadAndParseYamlFile(SettingsStatic.getDefaultSettingsFilePath()) + const userConfig = { + nip43: { + enabled: true, + inviteCodeExpirySeconds: 86400, + defaultMaxUses: 5, + }, + } + const merged = mergeDeepRight(defaults, userConfig) as Settings + + expect(merged.nip43?.enabled).to.equal(true) + expect(merged.nip43?.inviteCodeExpirySeconds).to.equal(86400) + expect(merged.nip43?.defaultMaxUses).to.equal(5) + }) + }) + describe('NIP-66 settings defaults', () => { it('default-settings.yaml contains a nip66 block with safe defaults', () => { const defaults = SettingsStatic.loadAndParseYamlFile(SettingsStatic.getDefaultSettingsFilePath())