Skip to content
Merged
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-cli.md
Original file line number Diff line number Diff line change
@@ -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`.
1 change: 0 additions & 1 deletion .knip.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@
],
"ignore": [
".nostr/**",
"src/repositories/invite-code-repository.ts",
"src/repositories/dvm-job-repository.ts",
"src/utils/relay-probe/**"
],
Expand Down
22 changes: 22 additions & 0 deletions CLI.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,28 @@ nostream update
nostream clean
nostream setup [--yes] [--start]
nostream seed [--count 100]
nostream invite create [--uses N] [--expires-in <seconds>] [--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/`.
Expand Down Expand Up @@ -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
```
3 changes: 3 additions & 0 deletions CONFIGURATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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('<your_language>', event_content));` — otherwise the planner cannot use the index and queries fall back to sequential scans. |
Expand Down
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
5 changes: 5 additions & 0 deletions resources/default-settings.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
6 changes: 6 additions & 0 deletions src/@types/invite-code.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions src/@types/repositories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -67,7 +67,7 @@ export interface INip05VerificationRepository {
}

export interface IInviteCodeRepository {
create(code: string, expiresAt?: Date, remainingUses?: number | null): Promise<InviteCode>
create(code: string, options?: CreateInviteCodeOptions): Promise<InviteCode>
findByCode(code: string): Promise<InviteCode | undefined>
claimCode(code: string, pubkey: Pubkey): Promise<boolean>
findActiveCodes(limit?: number): Promise<InviteCode[]>
Expand Down
2 changes: 1 addition & 1 deletion src/@types/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -364,7 +364,7 @@ export interface Nip42Settings {

export interface Nip43Settings {
enabled: boolean
inviteCodeExpiry?: number
inviteCodeExpirySeconds?: number
defaultMaxUses?: number
allowInviteRequests?: boolean
inviteRequestWhitelist?: Pubkey[]
Expand Down
181 changes: 181 additions & 0 deletions src/cli/commands/invite.ts
Original file line number Diff line number Diff line change
@@ -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<unknown>()
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<number> => {
const loadSettings = deps.loadSettings ?? loadMergedSettings
const issue = deps.issue ?? issueInviteCode
const now = deps.now ?? Date.now
const settings = loadSettings()

const overrides: Parameters<typeof issueInviteCode>[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()
}
}
}
Loading
Loading