@toon-protocol/relay 2.0.0 → 2.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/version.ts","../src/types.ts","../src/filters/matchFilter.ts","../src/storage/InMemoryEventStore.ts","../src/storage/SqliteEventStore.ts","../src/crypto/verify-pool.ts","../src/websocket/ConnectionHandler.ts","../src/websocket/NostrRelayServer.ts","../src/subscriber/RelaySubscriber.ts","../src/launcher/metrics.ts","../src/launcher/handlers/write-handler.ts","../src/launcher/health.ts","../src/launcher/relay.ts"],"sourcesContent":["/** Package version, surfaced on `GET /health`. */\nexport const VERSION = '0.1.0';\n","/**\n * Configuration options for the Nostr relay.\n */\nexport interface RelayServerConfig {\n /** Port to listen on (default: 7000) */\n port: number;\n /** Host/IP to bind to (default: '0.0.0.0'). Set to '127.0.0.1' for hidden service mode. */\n host?: string;\n /**\n * Maximum concurrent WebSocket connections (default: 4096; relay#90).\n *\n * Each connection costs one file descriptor plus a few KB of handler\n * state, so the practical ceiling is fd-limit-shaped, not memory-shaped.\n * 4096 supports several hundred-listener huddles at once (the stock 100\n * made >100 listeners impossible) while leaving comfortable headroom\n * under docker's default nofile limit (1048576) AND still fitting under a\n * conservative 8192 ulimit; on a classic 1024 soft limit the startup\n * fd-limit check logs a warning (see NostrRelayServer.start).\n */\n maxConnections?: number;\n /** Maximum subscriptions per connection (default: 20) */\n maxSubscriptionsPerConnection?: number;\n /** Maximum filters per subscription (default: 10) */\n maxFiltersPerSubscription?: number;\n /** Path to SQLite database file (default: ':memory:' for in-memory) */\n databasePath?: string;\n}\n\n/**\n * Default relay configuration values.\n */\nexport const DEFAULT_RELAY_CONFIG: Required<RelayServerConfig> = {\n port: 7000,\n host: '0.0.0.0',\n maxConnections: 4096,\n maxSubscriptionsPerConnection: 20,\n maxFiltersPerSubscription: 10,\n databasePath: ':memory:',\n};\n","import type { NostrEvent } from 'nostr-tools/pure';\nimport type { Filter } from 'nostr-tools/filter';\n\n/**\n * Check if an event matches a single filter according to NIP-01 rules.\n *\n * Matching rules:\n * - All specified fields must match (AND logic)\n * - `ids` and `authors` support prefix matching\n * - Tag filters (#e, #p, etc.) match events with corresponding tags\n * - Empty filter matches all events\n *\n * @param event - The Nostr event to check\n * @param filter - The filter to match against\n * @returns true if the event matches the filter\n */\nexport function matchFilter(event: NostrEvent, filter: Filter): boolean {\n // Empty filter matches everything\n if (Object.keys(filter).length === 0) {\n return true;\n }\n\n // Check ids (prefix matching)\n if (filter.ids !== undefined && filter.ids.length > 0) {\n const matches = filter.ids.some((id) => event.id.startsWith(id));\n if (!matches) return false;\n }\n\n // Check authors (prefix matching)\n if (filter.authors !== undefined && filter.authors.length > 0) {\n const matches = filter.authors.some((author) =>\n event.pubkey.startsWith(author)\n );\n if (!matches) return false;\n }\n\n // Check kinds (exact matching)\n if (filter.kinds !== undefined && filter.kinds.length > 0) {\n if (!filter.kinds.includes(event.kind)) return false;\n }\n\n // Check since (created_at >= since)\n if (filter.since !== undefined) {\n if (event.created_at < filter.since) return false;\n }\n\n // Check until (created_at <= until)\n if (filter.until !== undefined) {\n if (event.created_at > filter.until) return false;\n }\n\n // Check tag filters (#e, #p, and generic #<single-letter>)\n for (const key of Object.keys(filter)) {\n if (key.startsWith('#') && key.length === 2) {\n const tagName = key.slice(1);\n const filterValues = filter[key as `#${string}`];\n\n if (filterValues !== undefined && filterValues.length > 0) {\n // Find matching tags in the event\n const eventTagValues = event.tags\n .filter((tag) => tag[0] === tagName)\n .map((tag) => tag[1]);\n\n // At least one filter value must match an event tag value\n const hasMatch = filterValues.some((v) => eventTagValues.includes(v));\n if (!hasMatch) return false;\n }\n }\n }\n\n return true;\n}\n","import type { NostrEvent } from 'nostr-tools/pure';\nimport type { Filter } from 'nostr-tools/filter';\nimport { matchFilter } from '../filters/index.js';\n\n/**\n * Interface for event storage backends.\n */\nexport interface EventStore {\n /** Store an event by its ID */\n store(event: NostrEvent): void;\n /** Retrieve a single event by ID */\n get(id: string): NostrEvent | undefined;\n /** Query events matching any of the provided filters */\n query(filters: Filter[]): NostrEvent[];\n /** Close the storage backend (optional) */\n close?(): void;\n}\n\n/**\n * In-memory implementation of EventStore.\n * Events are stored in a Map keyed by event ID.\n */\nexport class InMemoryEventStore implements EventStore {\n private events = new Map<string, NostrEvent>();\n\n store(event: NostrEvent): void {\n this.events.set(event.id, event);\n }\n\n get(id: string): NostrEvent | undefined {\n return this.events.get(id);\n }\n\n query(filters: Filter[]): NostrEvent[] {\n // Get all events\n const allEvents = Array.from(this.events.values());\n\n // If no filters provided, return all events sorted by created_at desc\n if (filters.length === 0) {\n return allEvents.sort((a, b) => b.created_at - a.created_at);\n }\n\n // Find events matching ANY filter (OR logic between filters)\n const matchingEvents: NostrEvent[] = [];\n\n for (const event of allEvents) {\n for (const filter of filters) {\n if (matchFilter(event, filter)) {\n matchingEvents.push(event);\n break; // Only add once even if matches multiple filters\n }\n }\n }\n\n // Sort by created_at descending\n matchingEvents.sort((a, b) => b.created_at - a.created_at);\n\n // Apply limit from first filter that has one (NIP-01 semantics)\n const limitFilter = filters.find((f) => f.limit !== undefined);\n if (limitFilter?.limit !== undefined) {\n return matchingEvents.slice(0, limitFilter.limit);\n }\n\n return matchingEvents;\n }\n\n /**\n * Close the storage backend (no-op for in-memory store).\n */\n close(): void {\n // No-op for in-memory store\n }\n}\n","import Database from 'better-sqlite3';\nimport type { NostrEvent } from 'nostr-tools/pure';\nimport type { Filter } from 'nostr-tools/filter';\nimport type { EventStore } from './InMemoryEventStore.js';\n\n/**\n * SQL schema for the events table.\n */\nconst SCHEMA_SQL = `\nCREATE TABLE IF NOT EXISTS events (\n id TEXT PRIMARY KEY,\n pubkey TEXT NOT NULL,\n kind INTEGER NOT NULL,\n content TEXT NOT NULL,\n tags TEXT NOT NULL,\n created_at INTEGER NOT NULL,\n sig TEXT NOT NULL,\n received_at INTEGER NOT NULL\n)\n`;\n\n/**\n * SQL for creating indexes on the events table.\n */\nconst INDEX_SQL = [\n 'CREATE INDEX IF NOT EXISTS idx_events_pubkey ON events(pubkey)',\n 'CREATE INDEX IF NOT EXISTS idx_events_kind ON events(kind)',\n 'CREATE INDEX IF NOT EXISTS idx_events_created_at ON events(created_at)',\n 'CREATE INDEX IF NOT EXISTS idx_events_pubkey_kind ON events(pubkey, kind)',\n];\n\n/**\n * Initialize the database schema.\n */\nfunction initializeSchema(db: Database.Database): void {\n db.exec(SCHEMA_SQL);\n for (const indexSql of INDEX_SQL) {\n db.exec(indexSql);\n }\n}\n\n/**\n * Custom error class for relay storage errors.\n */\nexport class RelayError extends Error {\n constructor(\n message: string,\n public code: string\n ) {\n super(message);\n this.name = 'RelayError';\n }\n}\n\n/**\n * Check if an event kind is in the replaceable range (10000-19999).\n * Excludes TOON-specific parameterized kinds 10032-10099.\n */\nfunction isReplaceableKind(kind: number): boolean {\n return kind >= 10000 && kind <= 19999 && !(kind >= 10032 && kind <= 10099);\n}\n\n/**\n * Check if an event kind is in the parameterized replaceable range.\n * Covers NIP-33 (30000-39999) and TOON-specific kinds (10032-10099).\n */\nfunction isParameterizedReplaceableKind(kind: number): boolean {\n return (kind >= 30000 && kind <= 39999) || (kind >= 10032 && kind <= 10099);\n}\n\n/**\n * Get the 'd' tag value from an event's tags array.\n */\nfunction getDTagValue(tags: string[][]): string {\n const dTag = tags.find((tag) => tag[0] === 'd');\n return dTag?.[1] ?? '';\n}\n\n/**\n * SQLite implementation of EventStore.\n * Persists events to a SQLite database file.\n */\nexport class SqliteEventStore implements EventStore {\n private db: Database.Database;\n private insertStmt: Database.Statement;\n private insertOrIgnoreStmt: Database.Statement;\n private getStmt: Database.Statement;\n private deleteByPubkeyKindStmt: Database.Statement;\n private deleteByPubkeyKindDTagStmt: Database.Statement;\n private getByPubkeyKindStmt: Database.Statement;\n private getByPubkeyKindDTagStmt: Database.Statement;\n\n /**\n * Create a new SqliteEventStore.\n * @param dbPath - Path to the database file. Use ':memory:' for in-memory database.\n */\n constructor(dbPath = ':memory:') {\n try {\n this.db = new Database(dbPath);\n\n // WAL + synchronous=NORMAL (connector#685): the default rollback\n // journal with synchronous=FULL costs two fsyncs per autocommit\n // INSERT -- ~4ms of event-loop blockage per stored event, which was\n // the dominant share of the paid-write pipeline's ~150 events/s\n // global admission ceiling. WAL with synchronous=NORMAL keeps\n // durability at the checkpoint level (an OS crash can lose the last\n // moments of writes, an app crash loses nothing) and turns each\n // insert into a memory-speed WAL append. On a ':memory:' database\n // the pragma is a harmless no-op.\n this.db.pragma('journal_mode = WAL');\n this.db.pragma('synchronous = NORMAL');\n\n initializeSchema(this.db);\n\n // Prepare statements for better performance\n this.insertStmt = this.db.prepare(`\n INSERT OR REPLACE INTO events (id, pubkey, kind, content, tags, created_at, sig, received_at)\n VALUES (?, ?, ?, ?, ?, ?, ?, ?)\n `);\n\n this.insertOrIgnoreStmt = this.db.prepare(`\n INSERT OR IGNORE INTO events (id, pubkey, kind, content, tags, created_at, sig, received_at)\n VALUES (?, ?, ?, ?, ?, ?, ?, ?)\n `);\n\n this.getStmt = this.db.prepare('SELECT * FROM events WHERE id = ?');\n\n this.deleteByPubkeyKindStmt = this.db.prepare(\n 'DELETE FROM events WHERE pubkey = ? AND kind = ?'\n );\n\n this.deleteByPubkeyKindDTagStmt = this.db.prepare(\n \"DELETE FROM events WHERE pubkey = ? AND kind = ? AND json_extract(tags, '$') LIKE ?\"\n );\n\n this.getByPubkeyKindStmt = this.db.prepare(\n 'SELECT id, created_at FROM events WHERE pubkey = ? AND kind = ?'\n );\n\n this.getByPubkeyKindDTagStmt = this.db.prepare(\n 'SELECT id, created_at FROM events WHERE pubkey = ? AND kind = ? AND tags LIKE ?'\n );\n } catch (error) {\n throw new RelayError(\n `Failed to initialize database: ${error instanceof Error ? error.message : String(error)}`,\n 'STORAGE_ERROR'\n );\n }\n }\n\n /**\n * Store an event in the database.\n * Handles replaceable and parameterized replaceable events according to NIP-01.\n */\n store(event: NostrEvent): void {\n try {\n const tagsJson = JSON.stringify(event.tags);\n const receivedAt = Math.floor(Date.now() / 1000);\n\n if (isReplaceableKind(event.kind)) {\n // Replaceable event (10000-19999)\n this.storeReplaceableEvent(event, tagsJson, receivedAt);\n } else if (isParameterizedReplaceableKind(event.kind)) {\n // Parameterized replaceable event (30000-39999)\n this.storeParameterizedReplaceableEvent(event, tagsJson, receivedAt);\n } else {\n // Regular event - INSERT OR IGNORE to handle duplicates. Uses the\n // statement prepared once in the constructor: re-preparing here on\n // every call was measurable overhead on the hot write path.\n this.insertOrIgnoreStmt.run(\n event.id,\n event.pubkey,\n event.kind,\n event.content,\n tagsJson,\n event.created_at,\n event.sig,\n receivedAt\n );\n }\n } catch (error) {\n if (error instanceof RelayError) {\n throw error;\n }\n throw new RelayError(\n `Failed to store event: ${error instanceof Error ? error.message : String(error)}`,\n 'STORAGE_ERROR'\n );\n }\n }\n\n /**\n * Store a replaceable event (kinds 10000-19999).\n * Only keeps the latest event per pubkey+kind.\n */\n private storeReplaceableEvent(\n event: NostrEvent,\n tagsJson: string,\n receivedAt: number\n ): void {\n const existing = this.getByPubkeyKindStmt.get(event.pubkey, event.kind) as\n | { id: string; created_at: number }\n | undefined;\n\n if (existing) {\n // Only replace if new event is newer, or same time with lower id\n if (\n event.created_at > existing.created_at ||\n (event.created_at === existing.created_at && event.id < existing.id)\n ) {\n // Use transaction for atomicity\n const transaction = this.db.transaction(() => {\n this.deleteByPubkeyKindStmt.run(event.pubkey, event.kind);\n this.insertStmt.run(\n event.id,\n event.pubkey,\n event.kind,\n event.content,\n tagsJson,\n event.created_at,\n event.sig,\n receivedAt\n );\n });\n transaction();\n }\n // If existing event is newer or same, don't replace\n } else {\n // No existing event, just insert\n this.insertStmt.run(\n event.id,\n event.pubkey,\n event.kind,\n event.content,\n tagsJson,\n event.created_at,\n event.sig,\n receivedAt\n );\n }\n }\n\n /**\n * Store a parameterized replaceable event (kinds 30000-39999).\n * Only keeps the latest event per pubkey+kind+d-tag.\n */\n private storeParameterizedReplaceableEvent(\n event: NostrEvent,\n tagsJson: string,\n receivedAt: number\n ): void {\n const dTagValue = getDTagValue(event.tags);\n\n // For empty d-tag value, we need to match events that either:\n // 1. Have [\"d\", \"\"] in tags\n // 2. Have no d-tag at all (tags doesn't contain \"d\" as first element)\n let existing: { id: string; created_at: number } | undefined;\n\n if (dTagValue === '') {\n // Query for events with same pubkey and kind, then filter in code\n const candidates = this.db\n .prepare(\n 'SELECT id, created_at, tags FROM events WHERE pubkey = ? AND kind = ?'\n )\n .all(event.pubkey, event.kind) as {\n id: string;\n created_at: number;\n tags: string;\n }[];\n\n // Find one with empty or missing d-tag\n for (const candidate of candidates) {\n const candidateTags = JSON.parse(candidate.tags) as string[][];\n const candidateDTagValue = getDTagValue(candidateTags);\n if (candidateDTagValue === '') {\n existing = { id: candidate.id, created_at: candidate.created_at };\n break;\n }\n }\n } else {\n const dTagPattern = `%[\"d\",\"${dTagValue}\"%`;\n existing = this.getByPubkeyKindDTagStmt.get(\n event.pubkey,\n event.kind,\n dTagPattern\n ) as { id: string; created_at: number } | undefined;\n }\n\n if (existing) {\n // Only replace if new event is newer, or same time with lower id\n if (\n event.created_at > existing.created_at ||\n (event.created_at === existing.created_at && event.id < existing.id)\n ) {\n // Use transaction for atomicity - delete by ID for safety\n const transaction = this.db.transaction(() => {\n this.db.prepare('DELETE FROM events WHERE id = ?').run(existing.id);\n this.insertStmt.run(\n event.id,\n event.pubkey,\n event.kind,\n event.content,\n tagsJson,\n event.created_at,\n event.sig,\n receivedAt\n );\n });\n transaction();\n }\n // If existing event is newer or same, don't replace\n } else {\n // No existing event, just insert\n this.insertStmt.run(\n event.id,\n event.pubkey,\n event.kind,\n event.content,\n tagsJson,\n event.created_at,\n event.sig,\n receivedAt\n );\n }\n }\n\n /**\n * Retrieve an event by its ID.\n */\n get(id: string): NostrEvent | undefined {\n try {\n const row = this.getStmt.get(id) as\n | {\n id: string;\n pubkey: string;\n kind: number;\n content: string;\n tags: string;\n created_at: number;\n sig: string;\n }\n | undefined;\n\n if (!row) {\n return undefined;\n }\n\n return {\n id: row.id,\n pubkey: row.pubkey,\n kind: row.kind,\n content: row.content,\n tags: JSON.parse(row.tags) as string[][],\n created_at: row.created_at,\n sig: row.sig,\n };\n } catch (error) {\n throw new RelayError(\n `Failed to get event: ${error instanceof Error ? error.message : String(error)}`,\n 'STORAGE_ERROR'\n );\n }\n }\n\n /**\n * Query events matching any of the provided filters.\n */\n query(filters: Filter[]): NostrEvent[] {\n try {\n const { sql, params } = this.buildQuerySql(filters);\n const stmt = this.db.prepare(sql);\n const rows = stmt.all(...params) as {\n id: string;\n pubkey: string;\n kind: number;\n content: string;\n tags: string;\n created_at: number;\n sig: string;\n }[];\n\n return rows.map((row) => ({\n id: row.id,\n pubkey: row.pubkey,\n kind: row.kind,\n content: row.content,\n tags: JSON.parse(row.tags) as string[][],\n created_at: row.created_at,\n sig: row.sig,\n }));\n } catch (error) {\n throw new RelayError(\n `Failed to query events: ${error instanceof Error ? error.message : String(error)}`,\n 'STORAGE_ERROR'\n );\n }\n }\n\n /**\n * Build SQL query from filters.\n */\n private buildQuerySql(filters: Filter[]): { sql: string; params: unknown[] } {\n if (filters.length === 0) {\n return {\n sql: 'SELECT * FROM events ORDER BY created_at DESC',\n params: [],\n };\n }\n\n const conditions: string[] = [];\n const params: unknown[] = [];\n\n for (const filter of filters) {\n const filterConditions: string[] = [];\n\n if (filter.ids?.length) {\n // Prefix matching with LIKE\n const idConditions = filter.ids.map(() => 'id LIKE ?');\n filterConditions.push(`(${idConditions.join(' OR ')})`);\n params.push(...filter.ids.map((id) => `${id}%`));\n }\n\n if (filter.authors?.length) {\n const authorConditions = filter.authors.map(() => 'pubkey LIKE ?');\n filterConditions.push(`(${authorConditions.join(' OR ')})`);\n params.push(...filter.authors.map((a) => `${a}%`));\n }\n\n if (filter.kinds?.length) {\n filterConditions.push(\n `kind IN (${filter.kinds.map(() => '?').join(', ')})`\n );\n params.push(...filter.kinds);\n }\n\n if (filter.since !== undefined) {\n filterConditions.push('created_at >= ?');\n params.push(filter.since);\n }\n\n if (filter.until !== undefined) {\n filterConditions.push('created_at <= ?');\n params.push(filter.until);\n }\n\n // Handle tag filters (#e, #p, etc.)\n for (const [key, values] of Object.entries(filter)) {\n if (key.startsWith('#') && Array.isArray(values) && values.length > 0) {\n const tagName = key.slice(1);\n const tagConditions = values.map(() => `tags LIKE ?`);\n filterConditions.push(`(${tagConditions.join(' OR ')})`);\n params.push(...values.map((v) => `%[\"${tagName}\",\"${v}\"%`));\n }\n }\n\n if (filterConditions.length > 0) {\n conditions.push(`(${filterConditions.join(' AND ')})`);\n }\n }\n\n let sql = 'SELECT * FROM events';\n if (conditions.length > 0) {\n sql += ` WHERE ${conditions.join(' OR ')}`;\n }\n sql += ' ORDER BY created_at DESC';\n\n // Apply limit from first filter that specifies it\n const limitFilter = filters.find((f) => f.limit !== undefined);\n if (limitFilter?.limit !== undefined) {\n sql += ' LIMIT ?';\n params.push(limitFilter.limit);\n }\n\n return { sql, params };\n }\n\n /**\n * Close the database connection.\n */\n close(): void {\n this.db.close();\n }\n}\n","/**\n * Worker-thread pool for event-signature verification (relay#85).\n *\n * Post-#87 the WASM verify takes ~0.2ms per event -- but it still runs ON the\n * single Node event loop, which also carries every WebSocket broadcast.\n * Agent writers make persistent-kind write rates potentially bursty and high;\n * a verify burst on the loop is exactly the kind of stall that shows up as\n * tail jitter on ephemeral (huddle-frame) latency. The pool moves persistent\n * -kind schnorr verification onto worker threads so verify bursts cannot\n * stall the loop, at the cost of one thread-hop per verified event.\n *\n * Shape:\n * - `size` workers (default `max(0, os.cpus().length - 1)`); each worker\n * imports `verify-event.ts` and therefore instantiates its OWN WASM\n * libsecp256k1 (same self-test + noble fallback semantics as inline).\n * - `size: 0` -- automatic on 1-core boxes, and the explicit config escape\n * hatch (TOON_VERIFY_WORKERS=0) -- keeps the current inline path: `verify`\n * resolves synchronously-computed results, no threads are created.\n * - Dispatch is least-busy; per-call results resolve independently.\n * ORDERING: results for CONCURRENT calls may settle out of submission\n * order. The write path stays correct because the upstream connector\n * serializes each BTP session's POSTs (next request only after the\n * previous response) -- pinned by tests in write-handler.test.ts.\n * - Worker failure degrades transparently: pending and future verifies fall\n * back to the inline implementation (the pool never hard-fails a write).\n * - Hand-rolled on `node:worker_threads` -- ~100 lines beats a piscina\n * dependency in a package whose runtime deps are deliberately minimal.\n *\n * @module\n */\n\nimport { existsSync } from 'node:fs';\nimport { cpus } from 'node:os';\nimport { fileURLToPath } from 'node:url';\nimport { Worker } from 'node:worker_threads';\nimport { performance } from 'node:perf_hooks';\nimport { verifiedSymbol } from 'nostr-tools/pure';\nimport type { NostrEvent } from 'nostr-tools/pure';\nimport { verifyEventSignature } from './verify-event.js';\n\n/** A pool that verifies event signatures off the main event loop. */\nexport interface VerifyPool {\n /**\n * Verify an event's id + BIP-340 signature. Never rejects; invalid or\n * structurally broken events resolve `false`. Semantics match\n * `verifyEventSignature`, including stamping the nostr-tools\n * verified-event cache symbol on the caller's object.\n */\n verify(event: NostrEvent): Promise<boolean>;\n /** Live worker count (0 = inline path). */\n readonly size: number;\n /** Terminate all workers. Idempotent. Pending verifies resolve inline. */\n destroy(): Promise<void>;\n}\n\n/**\n * Default pool size: one worker per CPU minus one core reserved for the\n * event loop (WS fan-out + HTTP). On a 1-core box this is 0 -- the inline\n * path -- because a worker would only add thread-hop overhead while\n * competing for the same core.\n */\nexport function defaultVerifyWorkers(): number {\n return Math.max(0, cpus().length - 1);\n}\n\n/** Options for {@link createVerifyPool}. */\nexport interface VerifyPoolOptions {\n /** Worker count (default {@link defaultVerifyWorkers}; 0 = inline). */\n size?: number;\n /**\n * Called with the wall-clock milliseconds of each verify -- including\n * pool queue + thread-hop time, i.e. the latency a write actually paid.\n * The launcher wires this into the /metrics registry.\n */\n onMeasure?: (ms: number) => void;\n}\n\n/**\n * Locate the compiled worker entry. At runtime (dist) it sits next to the\n * bundle; under vitest (src) the repo's build-before-test ordering has\n * already produced `dist/verify-worker.js`. Returns null when no compiled\n * worker exists -- the pool then falls back to the inline path.\n */\nfunction resolveWorkerUrl(): URL | null {\n for (const candidate of [\n new URL('./verify-worker.js', import.meta.url),\n new URL('../../dist/verify-worker.js', import.meta.url),\n ]) {\n try {\n if (existsSync(fileURLToPath(candidate))) return candidate;\n } catch {\n // Non-file URL (unusual bundler context) -- try the next candidate.\n }\n }\n return null;\n}\n\ninterface PoolWorker {\n worker: Worker;\n pending: Map<number, { resolve: (ok: boolean) => void; event: NostrEvent }>;\n}\n\n/**\n * Create a verify pool. See the module doc for semantics; see\n * {@link VerifyPoolOptions} for knobs.\n */\nexport function createVerifyPool(options: VerifyPoolOptions = {}): VerifyPool {\n const requestedSize = options.size ?? defaultVerifyWorkers();\n const onMeasure = options.onMeasure;\n\n const measured = <T>(fn: () => T): T => {\n if (!onMeasure) return fn();\n const start = performance.now();\n const result = fn();\n onMeasure(performance.now() - start);\n return result;\n };\n\n const inlineVerify = (event: NostrEvent): Promise<boolean> =>\n Promise.resolve(measured(() => verifyEventSignature(event)));\n\n const workerUrl = requestedSize > 0 ? resolveWorkerUrl() : null;\n if (requestedSize > 0 && !workerUrl) {\n console.warn(\n '[relay] verify pool: compiled worker (dist/verify-worker.js) not found -- ' +\n 'falling back to inline verification (build the package to enable workers)'\n );\n }\n\n const workers: PoolWorker[] = [];\n let seq = 0;\n let destroyed = false;\n\n /** Drop a worker from rotation, resolving its pending verifies inline. */\n const retireWorker = (pw: PoolWorker, reason: string): void => {\n const index = workers.indexOf(pw);\n if (index === -1) return;\n workers.splice(index, 1);\n if (!destroyed) {\n console.warn(\n `[relay] verify pool: worker retired (${reason}); ` +\n (workers.length > 0\n ? `${workers.length} worker(s) remain`\n : 'falling back to inline verification')\n );\n }\n for (const { resolve, event } of pw.pending.values()) {\n resolve(verifyEventSignature(event));\n }\n pw.pending.clear();\n };\n\n if (workerUrl) {\n for (let i = 0; i < requestedSize; i++) {\n const worker = new Worker(workerUrl);\n const pw: PoolWorker = { worker, pending: new Map() };\n worker.on('message', (reply: { seq: number; ok: boolean }) => {\n const entry = pw.pending.get(reply.seq);\n if (!entry) return;\n pw.pending.delete(reply.seq);\n // Mirror the inline path: stamp the verdict on the caller's object\n // (the worker verified a structured clone).\n entry.event[verifiedSymbol] = reply.ok;\n entry.resolve(reply.ok);\n });\n worker.on('error', (error: Error) =>\n retireWorker(pw, `error: ${error.message}`)\n );\n worker.on('exit', () => retireWorker(pw, 'exit'));\n workers.push(pw);\n }\n }\n\n const poolVerify = (event: NostrEvent): Promise<boolean> => {\n // Honor a prior verdict without a thread-hop (nostr-tools cache).\n const cached = event[verifiedSymbol];\n if (typeof cached === 'boolean') return Promise.resolve(cached);\n\n // Least-busy dispatch.\n let target = workers[0];\n if (!target) return inlineVerify(event);\n for (const pw of workers) {\n if (pw.pending.size < target.pending.size) target = pw;\n }\n\n const start = performance.now();\n return new Promise<boolean>((resolve) => {\n const id = ++seq;\n target.pending.set(id, {\n event,\n resolve: (ok) => {\n onMeasure?.(performance.now() - start);\n resolve(ok);\n },\n });\n target.worker.postMessage({ seq: id, event });\n });\n };\n\n return {\n verify(event: NostrEvent): Promise<boolean> {\n return workers.length > 0 ? poolVerify(event) : inlineVerify(event);\n },\n get size(): number {\n return workers.length;\n },\n async destroy(): Promise<void> {\n destroyed = true;\n const toTerminate = [...workers];\n for (const pw of toTerminate) {\n retireWorker(pw, 'destroy');\n }\n await Promise.all(toTerminate.map((pw) => pw.worker.terminate()));\n },\n };\n}\n","import type { WebSocket } from 'ws';\nimport type { Filter } from 'nostr-tools/filter';\nimport type { NostrEvent } from 'nostr-tools/pure';\nimport type { EventStore } from '../storage/index.js';\nimport type { RelayServerConfig } from '../types.js';\nimport { DEFAULT_RELAY_CONFIG } from '../types.js';\nimport { matchFilter } from '../filters/index.js';\n\n/**\n * Represents an active subscription from a client.\n */\nexport interface Subscription {\n /** Unique subscription identifier from the client */\n id: string;\n /** Filters applied to this subscription */\n filters: Filter[];\n}\n\n/**\n * Build a NIP-01 EVENT frame from an ALREADY-SERIALIZED event payload,\n * splicing in the (JSON-escaped) subscription id.\n *\n * Byte-identical to `JSON.stringify(['EVENT', subscriptionId, event])` --\n * pinned by tests -- but lets the broadcast fan-out serialize the event\n * ONCE and reuse the string across every matching subscriber (relay#91:\n * 500 subscribers previously meant 500 identical `JSON.stringify(event)`\n * calls per frame, measured pinning a core in the s500 benchmark run).\n *\n * @param subscriptionId - The per-subscriber NIP-01 subscription id.\n * @param eventJson - `JSON.stringify(event)` output to reuse.\n * @returns The full EVENT frame string for the wire.\n */\nexport function serializeEventFrame(\n subscriptionId: string,\n eventJson: string\n): string {\n return `[\"EVENT\",${JSON.stringify(subscriptionId)},${eventJson}]`;\n}\n\n/**\n * Handles NIP-01 messages for a single WebSocket connection.\n */\nexport class ConnectionHandler {\n private subscriptions = new Map<string, Subscription>();\n private config: Required<RelayServerConfig>;\n\n constructor(\n private ws: WebSocket,\n private eventStore: EventStore,\n config: Partial<RelayServerConfig> = {}\n ) {\n this.config = { ...DEFAULT_RELAY_CONFIG, ...config };\n }\n\n /**\n * Handle an incoming message from the WebSocket.\n */\n handleMessage(data: string): void {\n console.log(`[ConnectionHandler] Received message:`, data.slice(0, 150));\n let message: unknown[];\n\n try {\n const parsed = JSON.parse(data);\n if (!Array.isArray(parsed)) {\n this.sendNotice('error: invalid message format, expected JSON array');\n return;\n }\n message = parsed;\n } catch {\n this.sendNotice('error: invalid JSON');\n return;\n }\n\n const messageType = message[0];\n console.log(`[ConnectionHandler] Message type: ${messageType}`);\n\n if (messageType === 'REQ') {\n const subscriptionId = message[1];\n const filters = message.slice(2) as Filter[];\n this.handleReq(subscriptionId as string, filters);\n } else if (messageType === 'EVENT') {\n const event = message[1];\n this.handleEvent(event as NostrEvent);\n } else if (messageType === 'CLOSE') {\n const subscriptionId = message[1];\n this.handleClose(subscriptionId as string);\n } else {\n this.sendNotice(`error: unknown message type: ${messageType}`);\n }\n }\n\n /**\n * Handle a REQ message to create/update a subscription.\n */\n private handleReq(subscriptionId: string, filters: Filter[]): void {\n // Validate subscription ID\n if (typeof subscriptionId !== 'string' || subscriptionId.length === 0) {\n this.sendNotice('error: invalid subscription id');\n return;\n }\n\n // Check subscription limit (only for new subscriptions)\n if (!this.subscriptions.has(subscriptionId)) {\n if (\n this.subscriptions.size >= this.config.maxSubscriptionsPerConnection\n ) {\n this.sendNotice('error: too many subscriptions');\n return;\n }\n }\n\n // Check filter limit\n if (filters.length > this.config.maxFiltersPerSubscription) {\n this.sendNotice('error: too many filters');\n return;\n }\n\n // Store the subscription\n this.subscriptions.set(subscriptionId, {\n id: subscriptionId,\n filters,\n });\n\n // Query matching events\n console.log(\n `[ConnectionHandler] REQ: ${subscriptionId}, filters:`,\n JSON.stringify(filters).slice(0, 100)\n );\n const events = this.eventStore.query(filters);\n console.log(\n `[ConnectionHandler] Query returned ${events.length} events for ${subscriptionId}`\n );\n\n // Send matching events\n for (const event of events) {\n console.log(\n `[ConnectionHandler] Sending event ${event.id.slice(0, 16)}... to ${subscriptionId}`\n );\n this.sendEvent(subscriptionId, event);\n }\n\n // Send EOSE\n console.log(`[ConnectionHandler] Sending EOSE for ${subscriptionId}`);\n this.sendEose(subscriptionId);\n }\n\n /**\n * Handle an EVENT message from a WebSocket client.\n *\n * Rejects all external writes — the relay is ILP-gated (pay to write).\n * Events are only stored through the ILP packet handler which calls\n * eventStore.store() directly and then broadcastEvent() to notify subscribers.\n */\n private handleEvent(event: NostrEvent): void {\n this.sendOk(event.id, false, 'restricted: writes require ILP payment');\n }\n\n /**\n * Handle a CLOSE message to terminate a subscription.\n */\n private handleClose(subscriptionId: string): void {\n // Silently remove subscription (no error if it doesn't exist per NIP-01)\n this.subscriptions.delete(subscriptionId);\n }\n\n /**\n * Push a new event to all matching subscriptions on this connection.\n * Used when events are stored outside the WebSocket flow (e.g., via ILP).\n *\n * @param event - The event to fan out (used for filter matching).\n * @param eventJson - Optional pre-serialized `JSON.stringify(event)`.\n * `NostrRelayServer.broadcastEvent` serializes the event ONCE and passes\n * it here so a 500-subscriber fan-out costs one serialization, not 500\n * (relay#91). When omitted (direct callers), the event is serialized\n * on first matching send.\n */\n notifyNewEvent(event: NostrEvent, eventJson?: string): void {\n let json = eventJson;\n for (const sub of this.subscriptions.values()) {\n const matches = sub.filters.some((f) => matchFilter(event, f));\n if (matches) {\n // Serialize lazily: connections with no matching subscription (the\n // common case in a selective fan-out) never pay for it.\n json ??= JSON.stringify(event);\n this.send(serializeEventFrame(sub.id, json));\n }\n }\n }\n\n /**\n * Clean up all subscriptions for this connection.\n */\n cleanup(): void {\n this.subscriptions.clear();\n }\n\n /**\n * Get the number of active subscriptions.\n */\n getSubscriptionCount(): number {\n return this.subscriptions.size;\n }\n\n /**\n * Emit an outbound NIP-01 EVENT frame.\n *\n * The event MUST go on the wire as canonical NIP-01 JSON —\n * `[\"EVENT\", <subId>, {id, pubkey, created_at, kind, tags, content, sig}]`\n * with the event as a plain JSON object — so any standard nostr client can\n * parse it and verify `id`/`sig` from the wire bytes (#46). Never re-encode\n * the event (TOON text, double-JSON-stringify, etc.) at this boundary.\n * serializeEventFrame is byte-identical to the full JSON.stringify\n * envelope (pinned by tests).\n */\n private sendEvent(subscriptionId: string, event: NostrEvent): void {\n this.send(serializeEventFrame(subscriptionId, JSON.stringify(event)));\n }\n\n private sendEose(subscriptionId: string): void {\n this.send(['EOSE', subscriptionId]);\n }\n\n private sendOk(eventId: string, success: boolean, message: string): void {\n this.send(['OK', eventId, success, message]);\n }\n\n private sendNotice(message: string): void {\n this.send(['NOTICE', message]);\n }\n\n /** Send a message: pre-serialized frames go out as-is (relay#91). */\n private send(message: unknown[] | string): void {\n if (this.ws.readyState === 1) {\n // OPEN\n this.ws.send(\n typeof message === 'string' ? message : JSON.stringify(message)\n );\n }\n }\n}\n","import { readFileSync } from 'node:fs';\nimport type { WebSocket } from 'ws';\nimport { WebSocketServer } from 'ws';\nimport type { NostrEvent } from 'nostr-tools/pure';\nimport type { EventStore } from '../storage/index.js';\nimport type { RelayServerConfig } from '../types.js';\nimport { DEFAULT_RELAY_CONFIG } from '../types.js';\nimport { ConnectionHandler } from './ConnectionHandler.js';\n\n/**\n * File descriptors reserved for everything that is not a client WS\n * connection (SQLite, HTTP server sockets, stdio, worker threads...).\n */\nconst FD_HEADROOM = 128;\n\n/**\n * Read this process's soft \"Max open files\" limit from /proc/self/limits.\n * Returns null off-Linux or on any parse failure (the check is advisory).\n *\n * @internal Exported for unit testing.\n */\nexport function readOpenFilesSoftLimit(\n read: (path: string) => string = (p) => readFileSync(p, 'utf8')\n): number | null {\n try {\n const line = read('/proc/self/limits')\n .split('\\n')\n .find((l) => l.startsWith('Max open files'));\n const match = line?.match(/Max open files\\s+(\\S+)/);\n if (!match?.[1]) return null;\n if (match[1] === 'unlimited') return Infinity;\n const limit = parseInt(match[1], 10);\n return Number.isNaN(limit) ? null : limit;\n } catch {\n return null;\n }\n}\n\n/**\n * A NIP-01 compliant Nostr relay WebSocket server.\n * Handles client connections and routes messages to ConnectionHandlers.\n */\nexport class NostrRelayServer {\n private wss: WebSocketServer | null = null;\n private handlers = new Map<WebSocket, ConnectionHandler>();\n private config: Required<RelayServerConfig>;\n\n constructor(\n config: Partial<RelayServerConfig> = {},\n private eventStore: EventStore\n ) {\n this.config = { ...DEFAULT_RELAY_CONFIG, ...config };\n }\n\n /**\n * Start the WebSocket server.\n */\n async start(): Promise<void> {\n return new Promise((resolve, reject) => {\n try {\n this.wss = new WebSocketServer({\n port: this.config.port,\n host: this.config.host,\n });\n\n this.wss.on('connection', (ws: WebSocket) => {\n this.handleConnection(ws);\n });\n\n this.wss.on('error', (error: Error) => {\n console.error('[NostrRelayServer] Server error:', error.message);\n });\n\n this.wss.on('listening', () => {\n const address = this.wss?.address();\n if (address && typeof address === 'object') {\n console.log(`[NostrRelayServer] Listening on port ${address.port}`);\n }\n // Advisory fd-limit check (relay#90): each connection costs one\n // fd, so a maxConnections above the soft nofile limit would hit\n // EMFILE long before the configured cap.\n const fdLimit = readOpenFilesSoftLimit();\n if (\n fdLimit !== null &&\n Number.isFinite(fdLimit) &&\n this.config.maxConnections > fdLimit - FD_HEADROOM\n ) {\n console.warn(\n `[NostrRelayServer] maxConnections (${this.config.maxConnections}) ` +\n `exceeds the process fd soft limit (${fdLimit}) minus ` +\n `${FD_HEADROOM} headroom -- connections will fail with EMFILE ` +\n `before the cap. Raise \\`ulimit -n\\` or lower maxConnections.`\n );\n }\n resolve();\n });\n } catch (error) {\n reject(error);\n }\n });\n }\n\n /**\n * Stop the WebSocket server and close all connections.\n */\n async stop(): Promise<void> {\n return new Promise((resolve) => {\n if (!this.wss) {\n resolve();\n return;\n }\n\n // Clean up all connection handlers\n for (const [ws, handler] of this.handlers) {\n handler.cleanup();\n ws.close();\n }\n this.handlers.clear();\n\n this.wss.close(() => {\n this.wss = null;\n resolve();\n });\n });\n }\n\n /**\n * Get the port the server is listening on.\n * Returns 0 if the server is not started.\n */\n getPort(): number {\n if (!this.wss) return 0;\n const address = this.wss.address();\n if (address && typeof address === 'object') {\n return address.port;\n }\n return 0;\n }\n\n /**\n * Get the number of connected clients.\n */\n getClientCount(): number {\n return this.handlers.size;\n }\n\n /**\n * Broadcast an event to all connected clients with matching subscriptions.\n * Call this after storing an event outside the WebSocket flow (e.g., via ILP)\n * so that discovery subscribers are notified.\n *\n * Serialize-once fan-out (relay#91): the event payload is stringified ONE\n * time here and reused for every matching subscriber -- only the small\n * per-subscription `[\"EVENT\",<subId>,...]` envelope is spliced per send.\n * Previously each of N subscribers re-serialized the identical event\n * (N=500 pinned a core doing 500 identical stringifies per frame).\n */\n broadcastEvent(event: NostrEvent): void {\n const eventJson = JSON.stringify(event);\n for (const handler of this.handlers.values()) {\n handler.notifyNewEvent(event, eventJson);\n }\n }\n\n private handleConnection(ws: WebSocket): void {\n // Check max connections\n if (this.handlers.size >= this.config.maxConnections) {\n console.warn(\n `[NostrRelayServer] connection rejected: maxConnections ` +\n `(${this.config.maxConnections}) reached -- raise TOON_MAX_CONNECTIONS ` +\n `if this box has headroom (relay#90)`\n );\n ws.close(1013, 'max connections reached');\n return;\n }\n\n console.log('[NostrRelayServer] Client connected');\n\n const handler = new ConnectionHandler(ws, this.eventStore, this.config);\n this.handlers.set(ws, handler);\n\n ws.on('message', (data: Buffer | string) => {\n const message = typeof data === 'string' ? data : data.toString();\n handler.handleMessage(message);\n });\n\n ws.on('close', () => {\n console.log('[NostrRelayServer] Client disconnected');\n handler.cleanup();\n this.handlers.delete(ws);\n });\n\n ws.on('error', (error: Error) => {\n console.error('[NostrRelayServer] Client error:', error.message);\n handler.cleanup();\n this.handlers.delete(ws);\n });\n }\n}\n","/**\n * Subscribe to upstream relays and propagate events into the local EventStore.\n *\n * Follows the same lifecycle pattern as core's discovery tracker and SocialPeerDiscovery:\n * - Accept optional SimplePool for testability\n * - start() returns { unsubscribe } cleanup handle\n * - isUnsubscribed guard prevents processing after teardown\n */\n\nimport { SimplePool } from 'nostr-tools/pool';\nimport { verifyEvent } from 'nostr-tools/pure';\nimport type { NostrEvent } from 'nostr-tools/pure';\nimport type { Filter } from 'nostr-tools/filter';\nimport type { EventStore } from '../storage/index.js';\n\n/**\n * Configuration for RelaySubscriber.\n */\nexport interface RelaySubscriberConfig {\n /** Upstream relay URLs to subscribe to */\n relayUrls: string[];\n /** Nostr filter for which events to pull (e.g. kinds, authors) */\n filter: Filter;\n /** Verify event signatures before storing (default: true) */\n verifySignatures?: boolean;\n}\n\n/**\n * Subscribes to upstream Nostr relays and stores received events\n * in the local EventStore. Useful for relay-to-relay event propagation.\n */\nexport class RelaySubscriber {\n private readonly config: RelaySubscriberConfig;\n private readonly eventStore: EventStore;\n private readonly pool: SimplePool;\n private started = false;\n\n /**\n * @param config - Subscriber configuration\n * @param eventStore - Storage backend to write events into\n * @param pool - Optional SimplePool instance (creates new one if not provided)\n */\n constructor(\n config: RelaySubscriberConfig,\n eventStore: EventStore,\n pool?: SimplePool\n ) {\n this.config = config;\n this.eventStore = eventStore;\n this.pool = pool ?? new SimplePool();\n }\n\n /**\n * Start subscribing to the configured upstream relays.\n *\n * @returns Handle with unsubscribe() to stop the subscription\n * @throws Error if already started\n */\n start(): { unsubscribe: () => void } {\n if (this.started) {\n throw new Error('RelaySubscriber already started');\n }\n this.started = true;\n\n const shouldVerify = this.config.verifySignatures !== false;\n let isUnsubscribed = false;\n\n const subCloser = this.pool.subscribeMany(\n this.config.relayUrls,\n this.config.filter,\n {\n onevent: (event: NostrEvent) => {\n if (isUnsubscribed) return;\n\n if (shouldVerify && !verifyEvent(event)) {\n return;\n }\n\n try {\n this.eventStore.store(event);\n } catch (error) {\n console.warn(\n '[RelaySubscriber] Failed to store event:',\n error instanceof Error ? error.message : 'Unknown error'\n );\n }\n },\n }\n );\n\n return {\n unsubscribe: () => {\n if (!isUnsubscribed) {\n isUnsubscribed = true;\n subCloser.close();\n this.started = false;\n }\n },\n };\n }\n}\n","/**\n * Metrics for the relay's HTTP telemetry surface (relay#85).\n *\n * Served as JSON from `GET /metrics` on the write/health port, next to\n * `/health`. Two families, chosen because they are the TRIGGER METRICS for\n * future scaling decisions (2026-08-02 benchmarking, toon-meta\n * proto/spacetimedb-relay RESULTS.md):\n *\n * - **Event-loop delay**: the single Node loop carries every WS broadcast;\n * loop lag IS ephemeral (huddle-frame) tail latency. Sustained p99 growth\n * here is the signal to shed load or scale out.\n * - **Per-event verify time**: wall-clock ms per signature verification as\n * the write actually paid it (including verify-pool queue + thread-hop\n * when workers are enabled). Growth here is the signal to resize the\n * verify pool (TOON_VERIFY_WORKERS) or move boxes.\n *\n * The registry is deliberately dependency-free: `monitorEventLoopDelay`\n * from node:perf_hooks plus a fixed-size ring of recent verify durations\n * (percentiles over the last {@link VERIFY_WINDOW} samples -- bounded\n * memory, O(window log window) only when a snapshot is requested).\n *\n * @module\n */\n\nimport { monitorEventLoopDelay } from 'node:perf_hooks';\nimport type { IntervalHistogram } from 'node:perf_hooks';\n\n/** Recent-verify window size (samples) for percentile computation. */\nconst VERIFY_WINDOW = 2048;\n\n/** Aggregates for one duration family, in milliseconds. */\nexport interface DurationStats {\n /** Total samples recorded since startup. */\n count: number;\n /** Mean over ALL samples since startup. */\n meanMs: number;\n /** Max over ALL samples since startup. */\n maxMs: number;\n /** Median over the most recent window (up to {@link VERIFY_WINDOW}). */\n p50Ms: number;\n /** 99th percentile over the most recent window. */\n p99Ms: number;\n}\n\n/** The `GET /metrics` response shape. */\nexport interface MetricsSnapshot {\n timestamp: number;\n /**\n * Event-loop delay in ms (node:perf_hooks monitorEventLoopDelay since the\n * last snapshot reset -- lifetime of the process unless noted). `mean`,\n * `p50`, `p99`, `max` -- loop lag is ephemeral-frame tail latency.\n */\n eventLoopDelayMs: {\n mean: number;\n p50: number;\n p99: number;\n max: number;\n };\n /** Per-event signature-verify timing (trigger metric for pool sizing). */\n verify: {\n /** Active implementation: 'libsecp256k1-wasm' or 'noble-pure-js'. */\n implementation: string;\n /** Verify-pool worker count (0 = inline on the event loop). */\n workers: number;\n } & DurationStats;\n}\n\n/** Live registry behind `GET /metrics`. */\nexport interface MetricsRegistry {\n /** Record one verify duration in milliseconds. */\n recordVerify(ms: number): void;\n /** Build the current snapshot (cheap; safe to poll). */\n snapshot(): MetricsSnapshot;\n /** Update the reported worker count (pool may degrade at runtime). */\n setVerifyWorkers(workers: number): void;\n /** Disable the loop-delay histogram (call on relay stop). */\n stop(): void;\n}\n\nconst NS_PER_MS = 1e6;\n\nfunction percentileOf(sorted: number[], fraction: number): number {\n if (sorted.length === 0) return 0;\n const index = Math.min(\n sorted.length - 1,\n Math.ceil(fraction * sorted.length) - 1\n );\n return sorted[Math.max(0, index)] ?? 0;\n}\n\nfunction round(value: number): number {\n // The loop-delay histogram reports NaN before its first sample interval;\n // surface 0 rather than a JSON null.\n if (!Number.isFinite(value)) return 0;\n return Math.round(value * 1000) / 1000;\n}\n\n/**\n * Create the metrics registry. One per relay instance; the launcher wires\n * `recordVerify` into the verify pool's `onMeasure` and serves `snapshot()`\n * from `GET /metrics`.\n *\n * @param info - Static verify metadata surfaced in the snapshot.\n */\nexport function createMetricsRegistry(info: {\n verifyImplementation: string;\n verifyWorkers: number;\n}): MetricsRegistry {\n const loopDelay: IntervalHistogram = monitorEventLoopDelay({\n resolution: 20,\n });\n loopDelay.enable();\n\n let verifyWorkers = info.verifyWorkers;\n let count = 0;\n let totalMs = 0;\n let maxMs = 0;\n const window = new Array<number>(VERIFY_WINDOW);\n let windowFill = 0;\n let windowCursor = 0;\n\n return {\n recordVerify(ms: number): void {\n count += 1;\n totalMs += ms;\n if (ms > maxMs) maxMs = ms;\n window[windowCursor] = ms;\n windowCursor = (windowCursor + 1) % VERIFY_WINDOW;\n if (windowFill < VERIFY_WINDOW) windowFill += 1;\n },\n\n snapshot(): MetricsSnapshot {\n const recent = window.slice(0, windowFill).sort((a, b) => a - b);\n return {\n timestamp: Date.now(),\n eventLoopDelayMs: {\n mean: round(loopDelay.mean / NS_PER_MS),\n p50: round(loopDelay.percentile(50) / NS_PER_MS),\n p99: round(loopDelay.percentile(99) / NS_PER_MS),\n max: round(loopDelay.max / NS_PER_MS),\n },\n verify: {\n implementation: info.verifyImplementation,\n workers: verifyWorkers,\n count,\n meanMs: round(count > 0 ? totalMs / count : 0),\n maxMs: round(maxMs),\n p50Ms: round(percentileOf(recent, 0.5)),\n p99Ms: round(percentileOf(recent, 0.99)),\n },\n };\n },\n\n setVerifyWorkers(workers: number): void {\n verifyWorkers = workers;\n },\n\n stop(): void {\n loopDelay.disable();\n },\n };\n}\n","/**\n * Write handler for @toon-protocol/relay.\n *\n * Exposes a plain-HTTP write surface that accepts an event-as-JSON, trusts\n * (but does NOT validate) injected payment headers, verifies ONLY the event\n * signature for integrity, and stores the event.\n *\n * This handler is intentionally decoupled from any payment layer: it contains\n * no claim/settlement/ILP logic and imports none of it. Payment validation is\n * the upstream terminator's concern; by the time a request reaches this surface\n * the trusted `X-TOON-*` headers are assumed already proven. The handler\n * captures them purely for the response echo and a log line.\n *\n * Flow:\n * 1. Parse JSON body `{ event }` -> 400 on malformed/missing event\n * 2. Capture trusted X-TOON-Payer / X-TOON-Amount / X-TOON-Chain headers\n * 3. Verify the event signature (skipped in devMode) -> 422 on invalid sig.\n * Verification uses the fast WASM libsecp256k1 path (crypto/verify-event)\n * rather than noble pure-JS: post-#84 the synchronous ~1.3ms noble verify\n * on the single event loop WAS the write-path ceiling (~240-260 events/s\n * aggregate, relay#85 / connector#685 Phase G).\n * PAID-EPHEMERAL EXCEPTION (relay#85, decision 2026-08-02): for ephemeral\n * kinds (20000 <= kind < 30000) the schnorr verification is SKIPPED by\n * default -- only the SHA-256 id check runs -> 422 on id mismatch. See\n * the loud comment at the verify step for why this is safe, and\n * `verifyEphemeral` to turn full verification back on.\n * 4. Store the event in the EventStore -- unless its kind is ephemeral\n * (NIP-16: 20000 <= kind < 30000), which is delivered live and never\n * persisted. Skipping the store here is not only NIP-16 semantics: the\n * synchronous per-event disk write was the serialization point that\n * capped the whole paid-write pipeline at ~150 events/s globally\n * (connector#685), and ephemeral traffic -- audio frames -- is exactly\n * the traffic that hits that path hardest.\n * 5. Fire the optional onStored callback (ephemeral events included: it is\n * the live-broadcast hook, and ephemeral events exist only as that\n * broadcast)\n * 6. Respond 200 with the event id, storedAt timestamp, and echoed headers\n *\n * @module\n */\n\nimport type { Context } from 'hono';\nimport type { NostrEvent } from 'nostr-tools/pure';\nimport { verifyEventSignature, verifyEventId } from '../../crypto/index.js';\nimport type { EventStore } from '../../storage/index.js';\n\n/**\n * Whether `kind` is ephemeral per NIP-16 (20000 <= kind < 30000): delivered\n * to live subscribers but never persisted or served from REQ history.\n */\nfunction isEphemeralKind(kind: number): boolean {\n return kind >= 20000 && kind < 30000;\n}\n\n/**\n * Configuration for the write handler.\n */\nexport interface WriteHandlerConfig {\n /** Event store backend used to persist accepted events. */\n eventStore: EventStore;\n /** Whether dev mode is enabled (skips Schnorr signature verification). */\n devMode: boolean;\n /**\n * Run FULL schnorr verification on ephemeral kinds too (default: false --\n * i.e. the paid-ephemeral verify skip is ON by default).\n *\n * ! SECURITY INVARIANT -- READ BEFORE TOUCHING !\n * The default skip is safe ONLY because this write surface is payment-gated:\n * every request reaching `POST /write` has already passed the upstream\n * connector's claim gate (payment IS the admission/spam gate), and the\n * protocol rule is that clients trust the signature chain and verify every\n * event themselves -- never the relay. Relay-side schnorr on ephemeral\n * frames is therefore pure spam defense that payment already provides;\n * forging a speaker costs real money to emit frames every client discards.\n * The SHA-256 id check is ALWAYS kept (see handleWrite).\n *\n * If you ever add a FREE (non-payment-gated) ephemeral write lane, it MUST\n * NOT reuse this skip -- free spam with valid-looking ids would be\n * broadcast to every subscriber. Community operators who front this port\n * with anything other than a payment-gating connector should set\n * `verifyEphemeral: true` (TOON_VERIFY_EPHEMERAL=true).\n */\n verifyEphemeral?: boolean;\n /**\n * Signature verifier for non-skipped (persistent-kind) events. Defaults to\n * the inline `verifyEventSignature`; the launcher injects the worker-pool\n * verifier (`crypto/verify-pool.ts`) so verify bursts run off the event\n * loop (relay#85). May resolve asynchronously -- the handler awaits it.\n *\n * ORDERING NOTE: an async verifier means CONCURRENT requests can complete\n * out of arrival order. Per-session write ordering is enforced UPSTREAM:\n * the connector serializes each BTP session's POSTs (it does not send the\n * next request until the previous response arrives), so sequential\n * same-session writes can never reorder here. Pinned by the ordering test\n * in write-handler.test.ts -- do not weaken that contract upstream without\n * revisiting this.\n */\n verifyEvent?: (event: NostrEvent) => Promise<boolean> | boolean;\n /** Optional callback fired after an event is successfully stored. */\n onStored?: (event: NostrEvent) => void;\n /**\n * Log one line per accepted write (default: false). Off by default because\n * per-event console I/O on the single event loop is measurable tail jitter\n * at huddle frame rates (relay#85, connector#685 Phase G): every write's\n * log line goes through docker's json-file driver, i.e. residual per-event\n * disk I/O that #84 did not remove.\n */\n logWrites?: boolean;\n}\n\n/**\n * Write handler instance.\n */\nexport interface WriteHandler {\n /** Handle a plain-HTTP write request. */\n handleWrite(c: Context): Promise<Response>;\n}\n\n/**\n * Create a write handler.\n *\n * @param config - Handler configuration.\n * @returns A WriteHandler with a handleWrite method.\n */\nexport function createWriteHandler(config: WriteHandlerConfig): WriteHandler {\n const logWrites = config.logWrites ?? false;\n const verifyEphemeral = config.verifyEphemeral ?? false;\n const verifyEvent = config.verifyEvent ?? verifyEventSignature;\n return {\n async handleWrite(c: Context): Promise<Response> {\n // --- Parse request body ---\n let body: { event?: NostrEvent };\n try {\n body = (await c.req.json()) as { event?: NostrEvent };\n } catch {\n return c.json({ error: 'Invalid request body' }, 400);\n }\n\n if (!body.event) {\n return c.json({ error: 'Missing required field: event' }, 400);\n }\n\n const event = body.event;\n\n // --- Capture trusted payment headers (NOT validated here) ---\n const payer = c.req.header('X-TOON-Payer');\n const amount = c.req.header('X-TOON-Amount');\n const chain = c.req.header('X-TOON-Chain');\n\n if (logWrites) {\n console.log(\n `[write] event=${event.id} payer=${payer ?? '-'} amount=${amount ?? '-'} chain=${chain ?? '-'}`\n );\n }\n\n // --- Verify event signature (integrity only; skipped in devMode) ---\n //\n // !!! PAYMENT-GATED VERIFY BYPASS (relay#85, decided 2026-08-02) !!!\n // Ephemeral kinds (NIP-16, 20000 <= kind < 30000) skip schnorr entirely\n // by default: payment is already the admission gate (the upstream\n // connector's claim gate), and clients verify every signature\n // themselves -- the relay's verdict is never trusted. We KEEP the\n // SHA-256 id check so the broadcast bytes always match the id clients\n // index/verify by. This bypass is safe ONLY because POST /write is\n // payment-gated; a future FREE ephemeral lane MUST NOT reuse it.\n // Operators can restore full verification with verifyEphemeral\n // (TOON_VERIFY_EPHEMERAL=true).\n if (!config.devMode) {\n if (isEphemeralKind(event.kind) && !verifyEphemeral) {\n if (!verifyEventId(event)) {\n return c.json({ error: 'Invalid event id' }, 422);\n }\n } else if (!(await verifyEvent(event))) {\n return c.json({ error: 'Invalid event signature' }, 422);\n }\n }\n\n // --- Store the event (ephemeral kinds are broadcast-only, NIP-16) ---\n if (!isEphemeralKind(event.kind)) {\n config.eventStore.store(event);\n }\n\n // --- Fire the optional stored callback (the live-broadcast hook) ---\n config.onStored?.(event);\n\n // --- Build response (echo trusted headers) ---\n return c.json(\n {\n eventId: event.id,\n storedAt: Math.floor(Date.now() / 1000),\n payer,\n amount,\n chain,\n },\n 200\n );\n },\n };\n}\n","/**\n * Health response for the relay's HTTP server.\n *\n * The relay is a plain read/write app (no payment, connector, or settlement\n * layer), so the health response is deliberately minimal: liveness plus the\n * node's identity and version. It is served from `GET /health` on the write\n * port and is the target of the container healthcheck.\n *\n * @module\n */\n\nimport { VERSION } from '../version.js';\n\n/** Configuration for building a health response. */\nexport interface HealthConfig {\n /** Node's Nostr pubkey (64-char hex). */\n pubkey: string;\n}\n\n/** The health response shape. */\nexport interface HealthResponse {\n status: 'healthy';\n pubkey: string;\n capabilities: string[];\n version: string;\n timestamp: number;\n}\n\n/**\n * Build a health response for the relay.\n *\n * Pure function: takes a config and returns the response object, so it is\n * trivially unit-testable and reusable.\n *\n * @param config - Health configuration (the node's pubkey).\n * @returns The health response object.\n */\nexport function createHealthResponse(config: HealthConfig): HealthResponse {\n return {\n status: 'healthy',\n pubkey: config.pubkey,\n capabilities: ['relay'],\n version: VERSION,\n timestamp: Date.now(),\n };\n}\n","/**\n * startRelay() -- Programmatic API for starting a TOON relay node.\n *\n * The relay is a plain HTTP/WebSocket app. It does NOT speak ILP and contains\n * no payment, connector, settlement, or pricing logic: payment is enforced\n * entirely upstream by an external terminator (see the connector repo). By the\n * time a write reaches this process it is already proven paid, so the relay\n * simply stores the event and serves reads.\n *\n * Two surfaces:\n *\n * - `POST /write` (TOON_BLS_PORT, default 3100): accepts `{ event }` as JSON,\n * trusts the injected `X-TOON-Payer`/`-Amount`/`-Chain` headers WITHOUT\n * re-validating payment, verifies only the event's own signature for\n * integrity (paid ephemeral kinds skip schnorr by default and keep the\n * id check -- relay#85, see `verifyEphemeral`), and stores it.\n * `GET /health` and `GET /metrics` live on the same port.\n * - Free NIP-01 WebSocket reads (TOON_RELAY_PORT, default 7100).\n *\n * `startRelay()` returns a `RelayInstance` with an explicit `.stop()` for\n * lifecycle control (the CLI wraps this with process-signal handling).\n *\n * @module\n */\n\nimport { mkdirSync } from 'node:fs';\nimport { join } from 'node:path';\nimport { serve, type ServerType } from '@hono/node-server';\nimport { Hono, type Context } from 'hono';\nimport { getPublicKey } from 'nostr-tools/pure';\nimport { privateKeyFromSeedWords } from 'nostr-tools/nip06';\nimport type { Filter } from 'nostr-tools/filter';\nimport { verifyImplementation } from '../crypto/index.js';\nimport {\n createVerifyPool,\n defaultVerifyWorkers,\n} from '../crypto/verify-pool.js';\nimport { createMetricsRegistry } from './metrics.js';\nimport { SqliteEventStore } from '../storage/index.js';\nimport type { EventStore } from '../storage/index.js';\nimport { NostrRelayServer } from '../websocket/index.js';\nimport { DEFAULT_RELAY_CONFIG } from '../types.js';\nimport { RelaySubscriber } from '../subscriber/index.js';\nimport { createWriteHandler } from './handlers/write-handler.js';\nimport { createHealthResponse } from './health.js';\n\n// ---------- Configuration ----------\n\n/**\n * Configuration for starting a TOON relay node via `startRelay()`.\n *\n * Exactly one of `mnemonic` or `secretKey` must be provided -- it is the node's\n * Nostr identity (surfaced on `/health`).\n */\nexport interface RelayConfig {\n // --- Identity (exactly one required) ---\n\n /** 12-word or 24-word BIP-39 mnemonic phrase (NIP-06 derivation). */\n mnemonic?: string;\n /** 32-byte secp256k1 secret key. */\n secretKey?: Uint8Array;\n\n // --- Network ---\n\n /** WebSocket relay (read) port (default: 7100). */\n relayPort?: number;\n /** HTTP write/health port (default: 3100). */\n blsPort?: number;\n /**\n * WebSocket bind host (default: 0.0.0.0). Set to `127.0.0.1` to bind the read\n * port to localhost only (e.g. when an upstream proxy handles inbound).\n */\n host?: string;\n /**\n * Bind host for the HTTP write/health listener (default: 0.0.0.0).\n *\n * The write port MUST only be reachable via the payment-gating connector\n * (see `verifyEphemeral`): in the canonical compose deploy that is enforced\n * by NOT host-publishing the port (docker `expose:`, never `ports:` --\n * note that docker `ports:` publishes bypass ufw). When the relay runs\n * directly on a host, bind this to a loopback/internal address instead.\n * A non-internal bind while the ephemeral verify skip is active logs a\n * prominent startup warning (never a hard failure -- topologies vary).\n */\n writeHost?: string;\n /**\n * Maximum concurrent WebSocket read connections (default: 4096; env:\n * TOON_MAX_CONNECTIONS). Connections beyond the cap are closed with 1013.\n * Fd-limit-shaped, not memory-shaped -- see RelayServerConfig\n * .maxConnections for the sizing reasoning (relay#90).\n */\n maxConnections?: number;\n\n // --- Storage ---\n\n /** Data directory for the file-backed SQLite store (default: ./data). */\n dataDir?: string;\n /**\n * Pre-built EventStore. When provided, the relay uses it instead of building\n * the default file-backed `SqliteEventStore` under `dataDir` (useful for\n * tests via `InMemoryEventStore`, or to share a store when embedding). The\n * caller owns its lifecycle when supplied.\n */\n eventStore?: EventStore;\n\n // --- Development ---\n\n /** Skip event-signature verification on `POST /write` (default: false). */\n devMode?: boolean;\n /**\n * Run FULL schnorr verification on ephemeral kinds (default: false -- the\n * paid-ephemeral verify skip is ON by default, relay#85).\n *\n * The default skip is safe ONLY because `POST /write` is payment-gated by\n * the upstream connector and clients verify every signature themselves;\n * the SHA-256 event-id check always runs. Community operators fronting the\n * write port with anything other than a payment-gating connector should\n * set this to true (env: TOON_VERIFY_EPHEMERAL=true). See\n * `WriteHandlerConfig.verifyEphemeral` for the full invariant.\n */\n verifyEphemeral?: boolean;\n /**\n * Worker-thread verify pool size for persistent-kind signature\n * verification (default: `max(0, os.cpus().length - 1)`; env:\n * TOON_VERIFY_WORKERS). `0` -- automatic on 1-core boxes -- is the inline\n * escape hatch: verification runs synchronously on the event loop as\n * before. Workers keep bursty agent-writer verify load from stalling the\n * loop and jittering ephemeral frame latency (relay#85).\n */\n verifyWorkers?: number;\n\n // --- Observability ---\n\n /**\n * Log one line per accepted `POST /write` (default: false). Per-event\n * console I/O is measurable tail jitter on the write hot path (relay#85),\n * so this is a debug switch, not an access log.\n */\n logWrites?: boolean;\n}\n\n/**\n * Resolved configuration with all defaults applied.\n */\nexport interface ResolvedRelayConfig {\n relayPort: number;\n blsPort: number;\n host: string;\n writeHost: string;\n maxConnections: number;\n dataDir: string;\n devMode: boolean;\n verifyEphemeral: boolean;\n verifyWorkers: number;\n logWrites: boolean;\n}\n\n/**\n * A running TOON relay node instance returned by `startRelay()`.\n */\nexport interface RelayInstance {\n /** Whether the relay is currently running. */\n isRunning(): boolean;\n\n /** Gracefully stop the relay and release all resources. */\n stop(): Promise<void>;\n\n /**\n * Subscribe to a remote Nostr relay. Received events are stored in this\n * node's EventStore. Returns a handle for lifecycle management.\n *\n * @param relayUrl - WebSocket URL of the relay to subscribe to.\n * @param filter - Nostr filter (kinds, authors, etc.).\n * @returns A RelaySubscription handle.\n * @throws If the relay is not running.\n */\n subscribe(relayUrl: string, filter: Filter): RelaySubscription;\n\n /** The node's Nostr x-only public key (64-char hex). */\n pubkey: string;\n\n /** The resolved configuration with all defaults applied. */\n config: ResolvedRelayConfig;\n}\n\n/**\n * Handle for managing an outbound subscription to a remote Nostr relay.\n * Returned by `RelayInstance.subscribe()`.\n */\nexport interface RelaySubscription {\n /** Close the subscription and disconnect from the relay. */\n close(): void;\n /** The relay URL this subscription is connected to. */\n relayUrl: string;\n /** Whether this subscription is still active. */\n isActive(): boolean;\n}\n\n// ---------- Identity ----------\n\n/**\n * Derive the node's Nostr identity from the config. Exactly one of `mnemonic`\n * or `secretKey` must be set.\n *\n * @internal\n */\nfunction deriveIdentity(config: RelayConfig): {\n secretKey: Uint8Array;\n pubkey: string;\n} {\n const hasMnemonic = config.mnemonic !== undefined;\n const hasSecretKey = config.secretKey !== undefined;\n\n if (hasMnemonic && hasSecretKey) {\n throw new Error(\n 'RelayConfig: provide either mnemonic or secretKey, not both'\n );\n }\n if (!hasMnemonic && !hasSecretKey) {\n throw new Error('RelayConfig: one of mnemonic or secretKey is required');\n }\n\n const secretKey = hasMnemonic\n ? privateKeyFromSeedWords(config.mnemonic as string)\n : (config.secretKey as Uint8Array);\n\n return { secretKey, pubkey: getPublicKey(secretKey) };\n}\n\n// ---------- Write-port exposure guard (relay#85) ----------\n\n/**\n * Whether `host` is a bind address that cannot be reached from the public\n * internet directly: loopback, RFC1918 private, IPv6 unique-local/link-local.\n * `0.0.0.0` / `::` (all interfaces) and public addresses return false.\n *\n * Used by the startup exposure guard: with the paid-ephemeral verify skip\n * active, the write port must only be reachable via the payment-gating\n * connector. Note a \"false\" here is not proof of exposure -- inside a\n * container, 0.0.0.0 is required for the connector to dial the compose\n * network and the port is kept private by not host-publishing it -- which is\n * why the guard warns instead of failing.\n *\n * @internal Exported for unit testing.\n */\nexport function isInternalBindHost(host: string): boolean {\n const h = host.trim().toLowerCase();\n if (h === 'localhost' || h === '::1' || h === '[::1]') return true;\n if (h.startsWith('127.')) return true; // 127.0.0.0/8 loopback\n if (h.startsWith('10.')) return true; // 10.0.0.0/8 RFC1918\n if (h.startsWith('192.168.')) return true; // 192.168.0.0/16 RFC1918\n if (/^172\\.(1[6-9]|2\\d|3[01])\\./.test(h)) return true; // 172.16.0.0/12\n if (/^f[cd][0-9a-f]{2}:/.test(h)) return true; // fc00::/7 unique-local\n if (h.startsWith('fe80:')) return true; // link-local\n return false;\n}\n\n/**\n * Log the prominent write-port exposure warning when the paid-ephemeral\n * verify skip is active and the write listener binds a non-internal\n * interface. Deliberately a warning, not a hard failure: in the canonical\n * compose deploy the port binds 0.0.0.0 inside the container and is private\n * because it is never host-published (docker `expose:`, not `ports:`).\n *\n * @internal Exported for unit testing.\n */\nexport function warnIfWritePortExposed(\n writeHost: string,\n blsPort: number,\n options: { verifyEphemeral: boolean; devMode: boolean }\n): boolean {\n const skipActive = options.devMode || !options.verifyEphemeral;\n if (!skipActive || isInternalBindHost(writeHost)) {\n return false;\n }\n console.warn(\n [\n '',\n '!'.repeat(72),\n `[relay] WARNING: POST /write is binding ${writeHost}:${blsPort} (a`,\n '[relay] non-loopback/non-internal interface) while event verification',\n options.devMode\n ? '[relay] is fully DISABLED (devMode).'\n : '[relay] is SKIPPED for paid ephemeral kinds (relay#85 default).',\n '[relay] This is safe ONLY if the write port is reachable exclusively',\n '[relay] through the payment-gating connector. In docker, do NOT',\n '[relay] host-publish this port (`expose:`, never `ports:` -- published',\n '[relay] ports bypass ufw). If the port is directly reachable, either',\n '[relay] bind it internally (TOON_WRITE_HOST=127.0.0.1) or restore full',\n '[relay] verification (TOON_VERIFY_EPHEMERAL=true).',\n '!'.repeat(72),\n '',\n ].join('\\n')\n );\n return true;\n}\n\n// ---------- Subscription Helper ----------\n\n/**\n * Create a subscription to a remote Nostr relay, storing received events\n * in the local EventStore. Returns a RelaySubscription handle.\n *\n * @internal Exported for unit testing only. Use `RelayInstance.subscribe()` instead.\n */\nexport function createSubscription(\n relayUrl: string,\n filter: Filter,\n eventStore: EventStore,\n activeSubscriptions: Set<RelaySubscription>\n): RelaySubscription {\n // Validate WebSocket URL scheme to provide clear errors and prevent\n // non-WebSocket URLs from reaching SimplePool.\n // nosemgrep: javascript.lang.security.detect-insecure-websocket.detect-insecure-websocket -- validation check, not a connection\n if (!relayUrl.startsWith('ws://') && !relayUrl.startsWith('wss://')) {\n throw new Error(\n 'Invalid relay URL -- must use WebSocket scheme (ws or wss)'\n );\n }\n\n const subscriber = new RelaySubscriber(\n { relayUrls: [relayUrl], filter },\n eventStore\n );\n const handle = subscriber.start();\n\n let active = true;\n const subscription: RelaySubscription = {\n close() {\n if (!active) return;\n active = false;\n handle.unsubscribe();\n activeSubscriptions.delete(subscription);\n },\n relayUrl,\n isActive() {\n return active;\n },\n };\n\n activeSubscriptions.add(subscription);\n return subscription;\n}\n\n// ---------- Main API ----------\n\n/**\n * Start a TOON relay node with the given configuration.\n *\n * Wires the event store, the HTTP write/health server, and the NIP-01\n * WebSocket read server, then returns a `RelayInstance` for lifecycle control.\n *\n * @param config - Node configuration. One of `mnemonic`/`secretKey` is required.\n * @returns A running RelayInstance.\n * @throws If both or neither of mnemonic/secretKey are provided.\n *\n * @example\n * ```typescript\n * const relay = await startRelay({ secretKey });\n * // ... POST /write on 3100, read NIP-01 on 7100 ...\n * await relay.stop();\n * ```\n */\nexport async function startRelay(config: RelayConfig): Promise<RelayInstance> {\n // --- 1. Identity ---\n const identity = deriveIdentity(config);\n\n // --- 2. Resolve config ---\n const relayPort = config.relayPort ?? 7100;\n const blsPort = config.blsPort ?? 3100;\n const host = config.host ?? '0.0.0.0';\n const writeHost = config.writeHost ?? '0.0.0.0';\n const maxConnections =\n config.maxConnections ?? DEFAULT_RELAY_CONFIG.maxConnections;\n const dataDir = config.dataDir ?? './data';\n const devMode = config.devMode ?? false;\n const verifyEphemeral = config.verifyEphemeral ?? false;\n const verifyWorkers = config.verifyWorkers ?? defaultVerifyWorkers();\n const logWrites = config.logWrites ?? false;\n\n const resolvedConfig: ResolvedRelayConfig = {\n relayPort,\n blsPort,\n host,\n writeHost,\n maxConnections,\n dataDir,\n devMode,\n verifyEphemeral,\n verifyWorkers,\n logWrites,\n };\n\n // --- 3. Event store ---\n // Use the injected store as-is, or build a file-backed SqliteEventStore.\n let eventStore: EventStore;\n if (config.eventStore) {\n eventStore = config.eventStore;\n } else {\n mkdirSync(dataDir, { recursive: true });\n eventStore = new SqliteEventStore(join(dataDir, 'events.db'));\n }\n\n // --- 4. WebSocket read server (created first so /write can broadcast) ---\n const wsRelay = new NostrRelayServer(\n { port: relayPort, host, maxConnections },\n eventStore\n );\n\n // --- 5. HTTP write/health server ---\n const app = new Hono();\n\n app.get('/health', (c: Context) =>\n c.json(createHealthResponse({ pubkey: identity.pubkey }))\n );\n\n // Metrics registry + verify pool (relay#85): event-loop lag and per-event\n // verify time are the trigger metrics for scaling decisions; the pool\n // keeps persistent-kind verify bursts off the event loop.\n const metrics = createMetricsRegistry({\n verifyImplementation,\n verifyWorkers: 0, // updated once the pool reports its live size below\n });\n const verifyPool = createVerifyPool({\n size: verifyWorkers,\n onMeasure: (ms) => metrics.recordVerify(ms),\n });\n metrics.setVerifyWorkers(verifyPool.size);\n\n app.get('/metrics', (c: Context) => c.json(metrics.snapshot()));\n\n // POST /write: trust the upstream terminator's injected payment headers,\n // verify only the event signature, store, and broadcast to live WS readers.\n // Logged once: the noble fallback is a silent ~7x verify-throughput loss,\n // so make the active implementation visible at startup (relay#85).\n console.log(`[relay] event signature verify: ${verifyImplementation}`);\n console.log(\n `[relay] ephemeral-kind schnorr verify: ${\n devMode\n ? 'skipped (devMode)'\n : verifyEphemeral\n ? 'full (TOON_VERIFY_EPHEMERAL)'\n : 'skipped -- payment-gated write path, id check kept (relay#85)'\n }`\n );\n // Exposure guard (relay#85): the verify skip assumes the write port is only\n // reachable via the payment-gating connector.\n warnIfWritePortExposed(writeHost, blsPort, { verifyEphemeral, devMode });\n console.log(\n `[relay] verify pool: ${\n verifyPool.size > 0\n ? `${verifyPool.size} worker thread(s)`\n : 'inline (0 workers -- verification on the event loop)'\n }`\n );\n const writeHandler = createWriteHandler({\n eventStore,\n devMode,\n verifyEphemeral,\n verifyEvent: (event) => {\n // Keep the reported worker count honest if the pool degraded.\n metrics.setVerifyWorkers(verifyPool.size);\n return verifyPool.verify(event);\n },\n logWrites,\n onStored: (event) => {\n try {\n wsRelay.broadcastEvent(event);\n } catch {\n // Non-broadcastable payloads -- ignore.\n }\n },\n });\n app.post('/write', (c: Context) => writeHandler.handleWrite(c));\n\n // Resolve once the HTTP server is actually listening so callers (and tests)\n // never race a not-yet-bound port.\n const blsServer: ServerType = await new Promise<ServerType>((resolve) => {\n const server = serve(\n { fetch: app.fetch, port: blsPort, hostname: writeHost },\n () => resolve(server)\n );\n });\n\n // --- 6. Start the WS read server ---\n await wsRelay.start();\n\n // --- 7. Lifecycle ---\n let running = true;\n const activeSubscriptions = new Set<RelaySubscription>();\n\n const instance: RelayInstance = {\n isRunning() {\n return running;\n },\n\n subscribe(subscribeRelayUrl: string, filter: Filter): RelaySubscription {\n if (!running) {\n throw new Error('Cannot subscribe: relay is not running');\n }\n return createSubscription(\n subscribeRelayUrl,\n filter,\n eventStore,\n activeSubscriptions\n );\n },\n\n async stop() {\n if (!running) return;\n running = false;\n\n for (const sub of activeSubscriptions) {\n sub.close();\n }\n activeSubscriptions.clear();\n\n await wsRelay.stop();\n blsServer.close();\n metrics.stop();\n await verifyPool.destroy();\n\n // Only close a store we created; an injected store is the caller's.\n if (!config.eventStore) {\n eventStore.close?.();\n }\n },\n\n pubkey: identity.pubkey,\n config: resolvedConfig,\n };\n\n return instance;\n}\n"],"mappings":";;;;;;;AACO,IAAM,UAAU;;;AC8BhB,IAAM,uBAAoD;AAAA,EAC/D,MAAM;AAAA,EACN,MAAM;AAAA,EACN,gBAAgB;AAAA,EAChB,+BAA+B;AAAA,EAC/B,2BAA2B;AAAA,EAC3B,cAAc;AAChB;;;ACtBO,SAAS,YAAY,OAAmB,QAAyB;AAEtE,MAAI,OAAO,KAAK,MAAM,EAAE,WAAW,GAAG;AACpC,WAAO;AAAA,EACT;AAGA,MAAI,OAAO,QAAQ,UAAa,OAAO,IAAI,SAAS,GAAG;AACrD,UAAM,UAAU,OAAO,IAAI,KAAK,CAAC,OAAO,MAAM,GAAG,WAAW,EAAE,CAAC;AAC/D,QAAI,CAAC,QAAS,QAAO;AAAA,EACvB;AAGA,MAAI,OAAO,YAAY,UAAa,OAAO,QAAQ,SAAS,GAAG;AAC7D,UAAM,UAAU,OAAO,QAAQ;AAAA,MAAK,CAAC,WACnC,MAAM,OAAO,WAAW,MAAM;AAAA,IAChC;AACA,QAAI,CAAC,QAAS,QAAO;AAAA,EACvB;AAGA,MAAI,OAAO,UAAU,UAAa,OAAO,MAAM,SAAS,GAAG;AACzD,QAAI,CAAC,OAAO,MAAM,SAAS,MAAM,IAAI,EAAG,QAAO;AAAA,EACjD;AAGA,MAAI,OAAO,UAAU,QAAW;AAC9B,QAAI,MAAM,aAAa,OAAO,MAAO,QAAO;AAAA,EAC9C;AAGA,MAAI,OAAO,UAAU,QAAW;AAC9B,QAAI,MAAM,aAAa,OAAO,MAAO,QAAO;AAAA,EAC9C;AAGA,aAAW,OAAO,OAAO,KAAK,MAAM,GAAG;AACrC,QAAI,IAAI,WAAW,GAAG,KAAK,IAAI,WAAW,GAAG;AAC3C,YAAM,UAAU,IAAI,MAAM,CAAC;AAC3B,YAAM,eAAe,OAAO,GAAmB;AAE/C,UAAI,iBAAiB,UAAa,aAAa,SAAS,GAAG;AAEzD,cAAM,iBAAiB,MAAM,KAC1B,OAAO,CAAC,QAAQ,IAAI,CAAC,MAAM,OAAO,EAClC,IAAI,CAAC,QAAQ,IAAI,CAAC,CAAC;AAGtB,cAAM,WAAW,aAAa,KAAK,CAAC,MAAM,eAAe,SAAS,CAAC,CAAC;AACpE,YAAI,CAAC,SAAU,QAAO;AAAA,MACxB;AAAA,IACF;AAAA,EACF;AAEA,SAAO;AACT;;;ACjDO,IAAM,qBAAN,MAA+C;AAAA,EAC5C,SAAS,oBAAI,IAAwB;AAAA,EAE7C,MAAM,OAAyB;AAC7B,SAAK,OAAO,IAAI,MAAM,IAAI,KAAK;AAAA,EACjC;AAAA,EAEA,IAAI,IAAoC;AACtC,WAAO,KAAK,OAAO,IAAI,EAAE;AAAA,EAC3B;AAAA,EAEA,MAAM,SAAiC;AAErC,UAAM,YAAY,MAAM,KAAK,KAAK,OAAO,OAAO,CAAC;AAGjD,QAAI,QAAQ,WAAW,GAAG;AACxB,aAAO,UAAU,KAAK,CAAC,GAAG,MAAM,EAAE,aAAa,EAAE,UAAU;AAAA,IAC7D;AAGA,UAAM,iBAA+B,CAAC;AAEtC,eAAW,SAAS,WAAW;AAC7B,iBAAW,UAAU,SAAS;AAC5B,YAAI,YAAY,OAAO,MAAM,GAAG;AAC9B,yBAAe,KAAK,KAAK;AACzB;AAAA,QACF;AAAA,MACF;AAAA,IACF;AAGA,mBAAe,KAAK,CAAC,GAAG,MAAM,EAAE,aAAa,EAAE,UAAU;AAGzD,UAAM,cAAc,QAAQ,KAAK,CAAC,MAAM,EAAE,UAAU,MAAS;AAC7D,QAAI,aAAa,UAAU,QAAW;AACpC,aAAO,eAAe,MAAM,GAAG,YAAY,KAAK;AAAA,IAClD;AAEA,WAAO;AAAA,EACT;AAAA;AAAA;AAAA;AAAA,EAKA,QAAc;AAAA,EAEd;AACF;;;ACxEA,OAAO,cAAc;AAQrB,IAAM,aAAa;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAgBnB,IAAM,YAAY;AAAA,EAChB;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF;AAKA,SAAS,iBAAiB,IAA6B;AACrD,KAAG,KAAK,UAAU;AAClB,aAAW,YAAY,WAAW;AAChC,OAAG,KAAK,QAAQ;AAAA,EAClB;AACF;AAKO,IAAM,aAAN,cAAyB,MAAM;AAAA,EACpC,YACE,SACO,MACP;AACA,UAAM,OAAO;AAFN;AAGP,SAAK,OAAO;AAAA,EACd;AACF;AAMA,SAAS,kBAAkB,MAAuB;AAChD,SAAO,QAAQ,OAAS,QAAQ,SAAS,EAAE,QAAQ,SAAS,QAAQ;AACtE;AAMA,SAAS,+BAA+B,MAAuB;AAC7D,SAAQ,QAAQ,OAAS,QAAQ,SAAW,QAAQ,SAAS,QAAQ;AACvE;AAKA,SAAS,aAAa,MAA0B;AAC9C,QAAM,OAAO,KAAK,KAAK,CAAC,QAAQ,IAAI,CAAC,MAAM,GAAG;AAC9C,SAAO,OAAO,CAAC,KAAK;AACtB;AAMO,IAAM,mBAAN,MAA6C;AAAA,EAC1C;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA;AAAA;AAAA;AAAA;AAAA,EAMR,YAAY,SAAS,YAAY;AAC/B,QAAI;AACF,WAAK,KAAK,IAAI,SAAS,MAAM;AAW7B,WAAK,GAAG,OAAO,oBAAoB;AACnC,WAAK,GAAG,OAAO,sBAAsB;AAErC,uBAAiB,KAAK,EAAE;AAGxB,WAAK,aAAa,KAAK,GAAG,QAAQ;AAAA;AAAA;AAAA,OAGjC;AAED,WAAK,qBAAqB,KAAK,GAAG,QAAQ;AAAA;AAAA;AAAA,OAGzC;AAED,WAAK,UAAU,KAAK,GAAG,QAAQ,mCAAmC;AAElE,WAAK,yBAAyB,KAAK,GAAG;AAAA,QACpC;AAAA,MACF;AAEA,WAAK,6BAA6B,KAAK,GAAG;AAAA,QACxC;AAAA,MACF;AAEA,WAAK,sBAAsB,KAAK,GAAG;AAAA,QACjC;AAAA,MACF;AAEA,WAAK,0BAA0B,KAAK,GAAG;AAAA,QACrC;AAAA,MACF;AAAA,IACF,SAAS,OAAO;AACd,YAAM,IAAI;AAAA,QACR,kCAAkC,iBAAiB,QAAQ,MAAM,UAAU,OAAO,KAAK,CAAC;AAAA,QACxF;AAAA,MACF;AAAA,IACF;AAAA,EACF;AAAA;AAAA;AAAA;AAAA;AAAA,EAMA,MAAM,OAAyB;AAC7B,QAAI;AACF,YAAM,WAAW,KAAK,UAAU,MAAM,IAAI;AAC1C,YAAM,aAAa,KAAK,MAAM,KAAK,IAAI,IAAI,GAAI;AAE/C,UAAI,kBAAkB,MAAM,IAAI,GAAG;AAEjC,aAAK,sBAAsB,OAAO,UAAU,UAAU;AAAA,MACxD,WAAW,+BAA+B,MAAM,IAAI,GAAG;AAErD,aAAK,mCAAmC,OAAO,UAAU,UAAU;AAAA,MACrE,OAAO;AAIL,aAAK,mBAAmB;AAAA,UACtB,MAAM;AAAA,UACN,MAAM;AAAA,UACN,MAAM;AAAA,UACN,MAAM;AAAA,UACN;AAAA,UACA,MAAM;AAAA,UACN,MAAM;AAAA,UACN;AAAA,QACF;AAAA,MACF;AAAA,IACF,SAAS,OAAO;AACd,UAAI,iBAAiB,YAAY;AAC/B,cAAM;AAAA,MACR;AACA,YAAM,IAAI;AAAA,QACR,0BAA0B,iBAAiB,QAAQ,MAAM,UAAU,OAAO,KAAK,CAAC;AAAA,QAChF;AAAA,MACF;AAAA,IACF;AAAA,EACF;AAAA;AAAA;AAAA;AAAA;AAAA,EAMQ,sBACN,OACA,UACA,YACM;AACN,UAAM,WAAW,KAAK,oBAAoB,IAAI,MAAM,QAAQ,MAAM,IAAI;AAItE,QAAI,UAAU;AAEZ,UACE,MAAM,aAAa,SAAS,cAC3B,MAAM,eAAe,SAAS,cAAc,MAAM,KAAK,SAAS,IACjE;AAEA,cAAM,cAAc,KAAK,GAAG,YAAY,MAAM;AAC5C,eAAK,uBAAuB,IAAI,MAAM,QAAQ,MAAM,IAAI;AACxD,eAAK,WAAW;AAAA,YACd,MAAM;AAAA,YACN,MAAM;AAAA,YACN,MAAM;AAAA,YACN,MAAM;AAAA,YACN;AAAA,YACA,MAAM;AAAA,YACN,MAAM;AAAA,YACN;AAAA,UACF;AAAA,QACF,CAAC;AACD,oBAAY;AAAA,MACd;AAAA,IAEF,OAAO;AAEL,WAAK,WAAW;AAAA,QACd,MAAM;AAAA,QACN,MAAM;AAAA,QACN,MAAM;AAAA,QACN,MAAM;AAAA,QACN;AAAA,QACA,MAAM;AAAA,QACN,MAAM;AAAA,QACN;AAAA,MACF;AAAA,IACF;AAAA,EACF;AAAA;AAAA;AAAA;AAAA;AAAA,EAMQ,mCACN,OACA,UACA,YACM;AACN,UAAM,YAAY,aAAa,MAAM,IAAI;AAKzC,QAAI;AAEJ,QAAI,cAAc,IAAI;AAEpB,YAAM,aAAa,KAAK,GACrB;AAAA,QACC;AAAA,MACF,EACC,IAAI,MAAM,QAAQ,MAAM,IAAI;AAO/B,iBAAW,aAAa,YAAY;AAClC,cAAM,gBAAgB,KAAK,MAAM,UAAU,IAAI;AAC/C,cAAM,qBAAqB,aAAa,aAAa;AACrD,YAAI,uBAAuB,IAAI;AAC7B,qBAAW,EAAE,IAAI,UAAU,IAAI,YAAY,UAAU,WAAW;AAChE;AAAA,QACF;AAAA,MACF;AAAA,IACF,OAAO;AACL,YAAM,cAAc,UAAU,SAAS;AACvC,iBAAW,KAAK,wBAAwB;AAAA,QACtC,MAAM;AAAA,QACN,MAAM;AAAA,QACN;AAAA,MACF;AAAA,IACF;AAEA,QAAI,UAAU;AAEZ,UACE,MAAM,aAAa,SAAS,cAC3B,MAAM,eAAe,SAAS,cAAc,MAAM,KAAK,SAAS,IACjE;AAEA,cAAM,cAAc,KAAK,GAAG,YAAY,MAAM;AAC5C,eAAK,GAAG,QAAQ,iCAAiC,EAAE,IAAI,SAAS,EAAE;AAClE,eAAK,WAAW;AAAA,YACd,MAAM;AAAA,YACN,MAAM;AAAA,YACN,MAAM;AAAA,YACN,MAAM;AAAA,YACN;AAAA,YACA,MAAM;AAAA,YACN,MAAM;AAAA,YACN;AAAA,UACF;AAAA,QACF,CAAC;AACD,oBAAY;AAAA,MACd;AAAA,IAEF,OAAO;AAEL,WAAK,WAAW;AAAA,QACd,MAAM;AAAA,QACN,MAAM;AAAA,QACN,MAAM;AAAA,QACN,MAAM;AAAA,QACN;AAAA,QACA,MAAM;AAAA,QACN,MAAM;AAAA,QACN;AAAA,MACF;AAAA,IACF;AAAA,EACF;AAAA;AAAA;AAAA;AAAA,EAKA,IAAI,IAAoC;AACtC,QAAI;AACF,YAAM,MAAM,KAAK,QAAQ,IAAI,EAAE;AAY/B,UAAI,CAAC,KAAK;AACR,eAAO;AAAA,MACT;AAEA,aAAO;AAAA,QACL,IAAI,IAAI;AAAA,QACR,QAAQ,IAAI;AAAA,QACZ,MAAM,IAAI;AAAA,QACV,SAAS,IAAI;AAAA,QACb,MAAM,KAAK,MAAM,IAAI,IAAI;AAAA,QACzB,YAAY,IAAI;AAAA,QAChB,KAAK,IAAI;AAAA,MACX;AAAA,IACF,SAAS,OAAO;AACd,YAAM,IAAI;AAAA,QACR,wBAAwB,iBAAiB,QAAQ,MAAM,UAAU,OAAO,KAAK,CAAC;AAAA,QAC9E;AAAA,MACF;AAAA,IACF;AAAA,EACF;AAAA;AAAA;AAAA;AAAA,EAKA,MAAM,SAAiC;AACrC,QAAI;AACF,YAAM,EAAE,KAAK,OAAO,IAAI,KAAK,cAAc,OAAO;AAClD,YAAM,OAAO,KAAK,GAAG,QAAQ,GAAG;AAChC,YAAM,OAAO,KAAK,IAAI,GAAG,MAAM;AAU/B,aAAO,KAAK,IAAI,CAAC,SAAS;AAAA,QACxB,IAAI,IAAI;AAAA,QACR,QAAQ,IAAI;AAAA,QACZ,MAAM,IAAI;AAAA,QACV,SAAS,IAAI;AAAA,QACb,MAAM,KAAK,MAAM,IAAI,IAAI;AAAA,QACzB,YAAY,IAAI;AAAA,QAChB,KAAK,IAAI;AAAA,MACX,EAAE;AAAA,IACJ,SAAS,OAAO;AACd,YAAM,IAAI;AAAA,QACR,2BAA2B,iBAAiB,QAAQ,MAAM,UAAU,OAAO,KAAK,CAAC;AAAA,QACjF;AAAA,MACF;AAAA,IACF;AAAA,EACF;AAAA;AAAA;AAAA;AAAA,EAKQ,cAAc,SAAuD;AAC3E,QAAI,QAAQ,WAAW,GAAG;AACxB,aAAO;AAAA,QACL,KAAK;AAAA,QACL,QAAQ,CAAC;AAAA,MACX;AAAA,IACF;AAEA,UAAM,aAAuB,CAAC;AAC9B,UAAM,SAAoB,CAAC;AAE3B,eAAW,UAAU,SAAS;AAC5B,YAAM,mBAA6B,CAAC;AAEpC,UAAI,OAAO,KAAK,QAAQ;AAEtB,cAAM,eAAe,OAAO,IAAI,IAAI,MAAM,WAAW;AACrD,yBAAiB,KAAK,IAAI,aAAa,KAAK,MAAM,CAAC,GAAG;AACtD,eAAO,KAAK,GAAG,OAAO,IAAI,IAAI,CAAC,OAAO,GAAG,EAAE,GAAG,CAAC;AAAA,MACjD;AAEA,UAAI,OAAO,SAAS,QAAQ;AAC1B,cAAM,mBAAmB,OAAO,QAAQ,IAAI,MAAM,eAAe;AACjE,yBAAiB,KAAK,IAAI,iBAAiB,KAAK,MAAM,CAAC,GAAG;AAC1D,eAAO,KAAK,GAAG,OAAO,QAAQ,IAAI,CAAC,MAAM,GAAG,CAAC,GAAG,CAAC;AAAA,MACnD;AAEA,UAAI,OAAO,OAAO,QAAQ;AACxB,yBAAiB;AAAA,UACf,YAAY,OAAO,MAAM,IAAI,MAAM,GAAG,EAAE,KAAK,IAAI,CAAC;AAAA,QACpD;AACA,eAAO,KAAK,GAAG,OAAO,KAAK;AAAA,MAC7B;AAEA,UAAI,OAAO,UAAU,QAAW;AAC9B,yBAAiB,KAAK,iBAAiB;AACvC,eAAO,KAAK,OAAO,KAAK;AAAA,MAC1B;AAEA,UAAI,OAAO,UAAU,QAAW;AAC9B,yBAAiB,KAAK,iBAAiB;AACvC,eAAO,KAAK,OAAO,KAAK;AAAA,MAC1B;AAGA,iBAAW,CAAC,KAAK,MAAM,KAAK,OAAO,QAAQ,MAAM,GAAG;AAClD,YAAI,IAAI,WAAW,GAAG,KAAK,MAAM,QAAQ,MAAM,KAAK,OAAO,SAAS,GAAG;AACrE,gBAAM,UAAU,IAAI,MAAM,CAAC;AAC3B,gBAAM,gBAAgB,OAAO,IAAI,MAAM,aAAa;AACpD,2BAAiB,KAAK,IAAI,cAAc,KAAK,MAAM,CAAC,GAAG;AACvD,iBAAO,KAAK,GAAG,OAAO,IAAI,CAAC,MAAM,MAAM,OAAO,MAAM,CAAC,IAAI,CAAC;AAAA,QAC5D;AAAA,MACF;AAEA,UAAI,iBAAiB,SAAS,GAAG;AAC/B,mBAAW,KAAK,IAAI,iBAAiB,KAAK,OAAO,CAAC,GAAG;AAAA,MACvD;AAAA,IACF;AAEA,QAAI,MAAM;AACV,QAAI,WAAW,SAAS,GAAG;AACzB,aAAO,UAAU,WAAW,KAAK,MAAM,CAAC;AAAA,IAC1C;AACA,WAAO;AAGP,UAAM,cAAc,QAAQ,KAAK,CAAC,MAAM,EAAE,UAAU,MAAS;AAC7D,QAAI,aAAa,UAAU,QAAW;AACpC,aAAO;AACP,aAAO,KAAK,YAAY,KAAK;AAAA,IAC/B;AAEA,WAAO,EAAE,KAAK,OAAO;AAAA,EACvB;AAAA;AAAA;AAAA;AAAA,EAKA,QAAc;AACZ,SAAK,GAAG,MAAM;AAAA,EAChB;AACF;;;ACncA,SAAS,kBAAkB;AAC3B,SAAS,YAAY;AACrB,SAAS,qBAAqB;AAC9B,SAAS,cAAc;AACvB,SAAS,mBAAmB;AAC5B,SAAS,sBAAsB;AAyBxB,SAAS,uBAA+B;AAC7C,SAAO,KAAK,IAAI,GAAG,KAAK,EAAE,SAAS,CAAC;AACtC;AAoBA,SAAS,mBAA+B;AACtC,aAAW,aAAa;AAAA,IACtB,IAAI,IAAI,sBAAsB,YAAY,GAAG;AAAA,IAC7C,IAAI,IAAI,+BAA+B,YAAY,GAAG;AAAA,EACxD,GAAG;AACD,QAAI;AACF,UAAI,WAAW,cAAc,SAAS,CAAC,EAAG,QAAO;AAAA,IACnD,QAAQ;AAAA,IAER;AAAA,EACF;AACA,SAAO;AACT;AAWO,SAAS,iBAAiB,UAA6B,CAAC,GAAe;AAC5E,QAAM,gBAAgB,QAAQ,QAAQ,qBAAqB;AAC3D,QAAM,YAAY,QAAQ;AAE1B,QAAM,WAAW,CAAI,OAAmB;AACtC,QAAI,CAAC,UAAW,QAAO,GAAG;AAC1B,UAAM,QAAQ,YAAY,IAAI;AAC9B,UAAM,SAAS,GAAG;AAClB,cAAU,YAAY,IAAI,IAAI,KAAK;AACnC,WAAO;AAAA,EACT;AAEA,QAAM,eAAe,CAAC,UACpB,QAAQ,QAAQ,SAAS,MAAM,qBAAqB,KAAK,CAAC,CAAC;AAE7D,QAAM,YAAY,gBAAgB,IAAI,iBAAiB,IAAI;AAC3D,MAAI,gBAAgB,KAAK,CAAC,WAAW;AACnC,YAAQ;AAAA,MACN;AAAA,IAEF;AAAA,EACF;AAEA,QAAM,UAAwB,CAAC;AAC/B,MAAI,MAAM;AACV,MAAI,YAAY;AAGhB,QAAM,eAAe,CAAC,IAAgB,WAAyB;AAC7D,UAAM,QAAQ,QAAQ,QAAQ,EAAE;AAChC,QAAI,UAAU,GAAI;AAClB,YAAQ,OAAO,OAAO,CAAC;AACvB,QAAI,CAAC,WAAW;AACd,cAAQ;AAAA,QACN,wCAAwC,MAAM,SAC3C,QAAQ,SAAS,IACd,GAAG,QAAQ,MAAM,sBACjB;AAAA,MACR;AAAA,IACF;AACA,eAAW,EAAE,SAAS,MAAM,KAAK,GAAG,QAAQ,OAAO,GAAG;AACpD,cAAQ,qBAAqB,KAAK,CAAC;AAAA,IACrC;AACA,OAAG,QAAQ,MAAM;AAAA,EACnB;AAEA,MAAI,WAAW;AACb,aAAS,IAAI,GAAG,IAAI,eAAe,KAAK;AACtC,YAAM,SAAS,IAAI,OAAO,SAAS;AACnC,YAAM,KAAiB,EAAE,QAAQ,SAAS,oBAAI,IAAI,EAAE;AACpD,aAAO,GAAG,WAAW,CAAC,UAAwC;AAC5D,cAAM,QAAQ,GAAG,QAAQ,IAAI,MAAM,GAAG;AACtC,YAAI,CAAC,MAAO;AACZ,WAAG,QAAQ,OAAO,MAAM,GAAG;AAG3B,cAAM,MAAM,cAAc,IAAI,MAAM;AACpC,cAAM,QAAQ,MAAM,EAAE;AAAA,MACxB,CAAC;AACD,aAAO;AAAA,QAAG;AAAA,QAAS,CAAC,UAClB,aAAa,IAAI,UAAU,MAAM,OAAO,EAAE;AAAA,MAC5C;AACA,aAAO,GAAG,QAAQ,MAAM,aAAa,IAAI,MAAM,CAAC;AAChD,cAAQ,KAAK,EAAE;AAAA,IACjB;AAAA,EACF;AAEA,QAAM,aAAa,CAAC,UAAwC;AAE1D,UAAM,SAAS,MAAM,cAAc;AACnC,QAAI,OAAO,WAAW,UAAW,QAAO,QAAQ,QAAQ,MAAM;AAG9D,QAAI,SAAS,QAAQ,CAAC;AACtB,QAAI,CAAC,OAAQ,QAAO,aAAa,KAAK;AACtC,eAAW,MAAM,SAAS;AACxB,UAAI,GAAG,QAAQ,OAAO,OAAO,QAAQ,KAAM,UAAS;AAAA,IACtD;AAEA,UAAM,QAAQ,YAAY,IAAI;AAC9B,WAAO,IAAI,QAAiB,CAAC,YAAY;AACvC,YAAM,KAAK,EAAE;AACb,aAAO,QAAQ,IAAI,IAAI;AAAA,QACrB;AAAA,QACA,SAAS,CAAC,OAAO;AACf,sBAAY,YAAY,IAAI,IAAI,KAAK;AACrC,kBAAQ,EAAE;AAAA,QACZ;AAAA,MACF,CAAC;AACD,aAAO,OAAO,YAAY,EAAE,KAAK,IAAI,MAAM,CAAC;AAAA,IAC9C,CAAC;AAAA,EACH;AAEA,SAAO;AAAA,IACL,OAAO,OAAqC;AAC1C,aAAO,QAAQ,SAAS,IAAI,WAAW,KAAK,IAAI,aAAa,KAAK;AAAA,IACpE;AAAA,IACA,IAAI,OAAe;AACjB,aAAO,QAAQ;AAAA,IACjB;AAAA,IACA,MAAM,UAAyB;AAC7B,kBAAY;AACZ,YAAM,cAAc,CAAC,GAAG,OAAO;AAC/B,iBAAW,MAAM,aAAa;AAC5B,qBAAa,IAAI,SAAS;AAAA,MAC5B;AACA,YAAM,QAAQ,IAAI,YAAY,IAAI,CAAC,OAAO,GAAG,OAAO,UAAU,CAAC,CAAC;AAAA,IAClE;AAAA,EACF;AACF;;;ACvLO,SAAS,oBACd,gBACA,WACQ;AACR,SAAO,YAAY,KAAK,UAAU,cAAc,CAAC,IAAI,SAAS;AAChE;AAKO,IAAM,oBAAN,MAAwB;AAAA,EAI7B,YACU,IACA,YACR,SAAqC,CAAC,GACtC;AAHQ;AACA;AAGR,SAAK,SAAS,EAAE,GAAG,sBAAsB,GAAG,OAAO;AAAA,EACrD;AAAA,EATQ,gBAAgB,oBAAI,IAA0B;AAAA,EAC9C;AAAA;AAAA;AAAA;AAAA,EAaR,cAAc,MAAoB;AAChC,YAAQ,IAAI,yCAAyC,KAAK,MAAM,GAAG,GAAG,CAAC;AACvE,QAAI;AAEJ,QAAI;AACF,YAAM,SAAS,KAAK,MAAM,IAAI;AAC9B,UAAI,CAAC,MAAM,QAAQ,MAAM,GAAG;AAC1B,aAAK,WAAW,oDAAoD;AACpE;AAAA,MACF;AACA,gBAAU;AAAA,IACZ,QAAQ;AACN,WAAK,WAAW,qBAAqB;AACrC;AAAA,IACF;AAEA,UAAM,cAAc,QAAQ,CAAC;AAC7B,YAAQ,IAAI,qCAAqC,WAAW,EAAE;AAE9D,QAAI,gBAAgB,OAAO;AACzB,YAAM,iBAAiB,QAAQ,CAAC;AAChC,YAAM,UAAU,QAAQ,MAAM,CAAC;AAC/B,WAAK,UAAU,gBAA0B,OAAO;AAAA,IAClD,WAAW,gBAAgB,SAAS;AAClC,YAAM,QAAQ,QAAQ,CAAC;AACvB,WAAK,YAAY,KAAmB;AAAA,IACtC,WAAW,gBAAgB,SAAS;AAClC,YAAM,iBAAiB,QAAQ,CAAC;AAChC,WAAK,YAAY,cAAwB;AAAA,IAC3C,OAAO;AACL,WAAK,WAAW,gCAAgC,WAAW,EAAE;AAAA,IAC/D;AAAA,EACF;AAAA;AAAA;AAAA;AAAA,EAKQ,UAAU,gBAAwB,SAAyB;AAEjE,QAAI,OAAO,mBAAmB,YAAY,eAAe,WAAW,GAAG;AACrE,WAAK,WAAW,gCAAgC;AAChD;AAAA,IACF;AAGA,QAAI,CAAC,KAAK,cAAc,IAAI,cAAc,GAAG;AAC3C,UACE,KAAK,cAAc,QAAQ,KAAK,OAAO,+BACvC;AACA,aAAK,WAAW,+BAA+B;AAC/C;AAAA,MACF;AAAA,IACF;AAGA,QAAI,QAAQ,SAAS,KAAK,OAAO,2BAA2B;AAC1D,WAAK,WAAW,yBAAyB;AACzC;AAAA,IACF;AAGA,SAAK,cAAc,IAAI,gBAAgB;AAAA,MACrC,IAAI;AAAA,MACJ;AAAA,IACF,CAAC;AAGD,YAAQ;AAAA,MACN,4BAA4B,cAAc;AAAA,MAC1C,KAAK,UAAU,OAAO,EAAE,MAAM,GAAG,GAAG;AAAA,IACtC;AACA,UAAM,SAAS,KAAK,WAAW,MAAM,OAAO;AAC5C,YAAQ;AAAA,MACN,sCAAsC,OAAO,MAAM,eAAe,cAAc;AAAA,IAClF;AAGA,eAAW,SAAS,QAAQ;AAC1B,cAAQ;AAAA,QACN,qCAAqC,MAAM,GAAG,MAAM,GAAG,EAAE,CAAC,UAAU,cAAc;AAAA,MACpF;AACA,WAAK,UAAU,gBAAgB,KAAK;AAAA,IACtC;AAGA,YAAQ,IAAI,wCAAwC,cAAc,EAAE;AACpE,SAAK,SAAS,cAAc;AAAA,EAC9B;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EASQ,YAAY,OAAyB;AAC3C,SAAK,OAAO,MAAM,IAAI,OAAO,wCAAwC;AAAA,EACvE;AAAA;AAAA;AAAA;AAAA,EAKQ,YAAY,gBAA8B;AAEhD,SAAK,cAAc,OAAO,cAAc;AAAA,EAC1C;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAaA,eAAe,OAAmB,WAA0B;AAC1D,QAAI,OAAO;AACX,eAAW,OAAO,KAAK,cAAc,OAAO,GAAG;AAC7C,YAAM,UAAU,IAAI,QAAQ,KAAK,CAAC,MAAM,YAAY,OAAO,CAAC,CAAC;AAC7D,UAAI,SAAS;AAGX,iBAAS,KAAK,UAAU,KAAK;AAC7B,aAAK,KAAK,oBAAoB,IAAI,IAAI,IAAI,CAAC;AAAA,MAC7C;AAAA,IACF;AAAA,EACF;AAAA;AAAA;AAAA;AAAA,EAKA,UAAgB;AACd,SAAK,cAAc,MAAM;AAAA,EAC3B;AAAA;AAAA;AAAA;AAAA,EAKA,uBAA+B;AAC7B,WAAO,KAAK,cAAc;AAAA,EAC5B;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAaQ,UAAU,gBAAwB,OAAyB;AACjE,SAAK,KAAK,oBAAoB,gBAAgB,KAAK,UAAU,KAAK,CAAC,CAAC;AAAA,EACtE;AAAA,EAEQ,SAAS,gBAA8B;AAC7C,SAAK,KAAK,CAAC,QAAQ,cAAc,CAAC;AAAA,EACpC;AAAA,EAEQ,OAAO,SAAiB,SAAkB,SAAuB;AACvE,SAAK,KAAK,CAAC,MAAM,SAAS,SAAS,OAAO,CAAC;AAAA,EAC7C;AAAA,EAEQ,WAAW,SAAuB;AACxC,SAAK,KAAK,CAAC,UAAU,OAAO,CAAC;AAAA,EAC/B;AAAA;AAAA,EAGQ,KAAK,SAAmC;AAC9C,QAAI,KAAK,GAAG,eAAe,GAAG;AAE5B,WAAK,GAAG;AAAA,QACN,OAAO,YAAY,WAAW,UAAU,KAAK,UAAU,OAAO;AAAA,MAChE;AAAA,IACF;AAAA,EACF;AACF;;;AC/OA,SAAS,oBAAoB;AAE7B,SAAS,uBAAuB;AAWhC,IAAM,cAAc;AAQb,SAAS,uBACd,OAAiC,CAAC,MAAM,aAAa,GAAG,MAAM,GAC/C;AACf,MAAI;AACF,UAAM,OAAO,KAAK,mBAAmB,EAClC,MAAM,IAAI,EACV,KAAK,CAAC,MAAM,EAAE,WAAW,gBAAgB,CAAC;AAC7C,UAAM,QAAQ,MAAM,MAAM,wBAAwB;AAClD,QAAI,CAAC,QAAQ,CAAC,EAAG,QAAO;AACxB,QAAI,MAAM,CAAC,MAAM,YAAa,QAAO;AACrC,UAAM,QAAQ,SAAS,MAAM,CAAC,GAAG,EAAE;AACnC,WAAO,OAAO,MAAM,KAAK,IAAI,OAAO;AAAA,EACtC,QAAQ;AACN,WAAO;AAAA,EACT;AACF;AAMO,IAAM,mBAAN,MAAuB;AAAA,EAK5B,YACE,SAAqC,CAAC,GAC9B,YACR;AADQ;AAER,SAAK,SAAS,EAAE,GAAG,sBAAsB,GAAG,OAAO;AAAA,EACrD;AAAA,EATQ,MAA8B;AAAA,EAC9B,WAAW,oBAAI,IAAkC;AAAA,EACjD;AAAA;AAAA;AAAA;AAAA,EAYR,MAAM,QAAuB;AAC3B,WAAO,IAAI,QAAQ,CAAC,SAAS,WAAW;AACtC,UAAI;AACF,aAAK,MAAM,IAAI,gBAAgB;AAAA,UAC7B,MAAM,KAAK,OAAO;AAAA,UAClB,MAAM,KAAK,OAAO;AAAA,QACpB,CAAC;AAED,aAAK,IAAI,GAAG,cAAc,CAAC,OAAkB;AAC3C,eAAK,iBAAiB,EAAE;AAAA,QAC1B,CAAC;AAED,aAAK,IAAI,GAAG,SAAS,CAAC,UAAiB;AACrC,kBAAQ,MAAM,oCAAoC,MAAM,OAAO;AAAA,QACjE,CAAC;AAED,aAAK,IAAI,GAAG,aAAa,MAAM;AAC7B,gBAAM,UAAU,KAAK,KAAK,QAAQ;AAClC,cAAI,WAAW,OAAO,YAAY,UAAU;AAC1C,oBAAQ,IAAI,wCAAwC,QAAQ,IAAI,EAAE;AAAA,UACpE;AAIA,gBAAM,UAAU,uBAAuB;AACvC,cACE,YAAY,QACZ,OAAO,SAAS,OAAO,KACvB,KAAK,OAAO,iBAAiB,UAAU,aACvC;AACA,oBAAQ;AAAA,cACN,sCAAsC,KAAK,OAAO,cAAc,wCACxB,OAAO,WAC1C,WAAW;AAAA,YAElB;AAAA,UACF;AACA,kBAAQ;AAAA,QACV,CAAC;AAAA,MACH,SAAS,OAAO;AACd,eAAO,KAAK;AAAA,MACd;AAAA,IACF,CAAC;AAAA,EACH;AAAA;AAAA;AAAA;AAAA,EAKA,MAAM,OAAsB;AAC1B,WAAO,IAAI,QAAQ,CAAC,YAAY;AAC9B,UAAI,CAAC,KAAK,KAAK;AACb,gBAAQ;AACR;AAAA,MACF;AAGA,iBAAW,CAAC,IAAI,OAAO,KAAK,KAAK,UAAU;AACzC,gBAAQ,QAAQ;AAChB,WAAG,MAAM;AAAA,MACX;AACA,WAAK,SAAS,MAAM;AAEpB,WAAK,IAAI,MAAM,MAAM;AACnB,aAAK,MAAM;AACX,gBAAQ;AAAA,MACV,CAAC;AAAA,IACH,CAAC;AAAA,EACH;AAAA;AAAA;AAAA;AAAA;AAAA,EAMA,UAAkB;AAChB,QAAI,CAAC,KAAK,IAAK,QAAO;AACtB,UAAM,UAAU,KAAK,IAAI,QAAQ;AACjC,QAAI,WAAW,OAAO,YAAY,UAAU;AAC1C,aAAO,QAAQ;AAAA,IACjB;AACA,WAAO;AAAA,EACT;AAAA;AAAA;AAAA;AAAA,EAKA,iBAAyB;AACvB,WAAO,KAAK,SAAS;AAAA,EACvB;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAaA,eAAe,OAAyB;AACtC,UAAM,YAAY,KAAK,UAAU,KAAK;AACtC,eAAW,WAAW,KAAK,SAAS,OAAO,GAAG;AAC5C,cAAQ,eAAe,OAAO,SAAS;AAAA,IACzC;AAAA,EACF;AAAA,EAEQ,iBAAiB,IAAqB;AAE5C,QAAI,KAAK,SAAS,QAAQ,KAAK,OAAO,gBAAgB;AACpD,cAAQ;AAAA,QACN,2DACM,KAAK,OAAO,cAAc;AAAA,MAElC;AACA,SAAG,MAAM,MAAM,yBAAyB;AACxC;AAAA,IACF;AAEA,YAAQ,IAAI,qCAAqC;AAEjD,UAAM,UAAU,IAAI,kBAAkB,IAAI,KAAK,YAAY,KAAK,MAAM;AACtE,SAAK,SAAS,IAAI,IAAI,OAAO;AAE7B,OAAG,GAAG,WAAW,CAAC,SAA0B;AAC1C,YAAM,UAAU,OAAO,SAAS,WAAW,OAAO,KAAK,SAAS;AAChE,cAAQ,cAAc,OAAO;AAAA,IAC/B,CAAC;AAED,OAAG,GAAG,SAAS,MAAM;AACnB,cAAQ,IAAI,wCAAwC;AACpD,cAAQ,QAAQ;AAChB,WAAK,SAAS,OAAO,EAAE;AAAA,IACzB,CAAC;AAED,OAAG,GAAG,SAAS,CAAC,UAAiB;AAC/B,cAAQ,MAAM,oCAAoC,MAAM,OAAO;AAC/D,cAAQ,QAAQ;AAChB,WAAK,SAAS,OAAO,EAAE;AAAA,IACzB,CAAC;AAAA,EACH;AACF;;;AC7LA,SAAS,kBAAkB;AAC3B,SAAS,mBAAmB;AAqBrB,IAAM,kBAAN,MAAsB;AAAA,EACV;AAAA,EACA;AAAA,EACA;AAAA,EACT,UAAU;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAOlB,YACE,QACA,YACA,MACA;AACA,SAAK,SAAS;AACd,SAAK,aAAa;AAClB,SAAK,OAAO,QAAQ,IAAI,WAAW;AAAA,EACrC;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAQA,QAAqC;AACnC,QAAI,KAAK,SAAS;AAChB,YAAM,IAAI,MAAM,iCAAiC;AAAA,IACnD;AACA,SAAK,UAAU;AAEf,UAAM,eAAe,KAAK,OAAO,qBAAqB;AACtD,QAAI,iBAAiB;AAErB,UAAM,YAAY,KAAK,KAAK;AAAA,MAC1B,KAAK,OAAO;AAAA,MACZ,KAAK,OAAO;AAAA,MACZ;AAAA,QACE,SAAS,CAAC,UAAsB;AAC9B,cAAI,eAAgB;AAEpB,cAAI,gBAAgB,CAAC,YAAY,KAAK,GAAG;AACvC;AAAA,UACF;AAEA,cAAI;AACF,iBAAK,WAAW,MAAM,KAAK;AAAA,UAC7B,SAAS,OAAO;AACd,oBAAQ;AAAA,cACN;AAAA,cACA,iBAAiB,QAAQ,MAAM,UAAU;AAAA,YAC3C;AAAA,UACF;AAAA,QACF;AAAA,MACF;AAAA,IACF;AAEA,WAAO;AAAA,MACL,aAAa,MAAM;AACjB,YAAI,CAAC,gBAAgB;AACnB,2BAAiB;AACjB,oBAAU,MAAM;AAChB,eAAK,UAAU;AAAA,QACjB;AAAA,MACF;AAAA,IACF;AAAA,EACF;AACF;;;AC5EA,SAAS,6BAA6B;AAItC,IAAM,gBAAgB;AAmDtB,IAAM,YAAY;AAElB,SAAS,aAAa,QAAkB,UAA0B;AAChE,MAAI,OAAO,WAAW,EAAG,QAAO;AAChC,QAAM,QAAQ,KAAK;AAAA,IACjB,OAAO,SAAS;AAAA,IAChB,KAAK,KAAK,WAAW,OAAO,MAAM,IAAI;AAAA,EACxC;AACA,SAAO,OAAO,KAAK,IAAI,GAAG,KAAK,CAAC,KAAK;AACvC;AAEA,SAAS,MAAM,OAAuB;AAGpC,MAAI,CAAC,OAAO,SAAS,KAAK,EAAG,QAAO;AACpC,SAAO,KAAK,MAAM,QAAQ,GAAI,IAAI;AACpC;AASO,SAAS,sBAAsB,MAGlB;AAClB,QAAM,YAA+B,sBAAsB;AAAA,IACzD,YAAY;AAAA,EACd,CAAC;AACD,YAAU,OAAO;AAEjB,MAAI,gBAAgB,KAAK;AACzB,MAAI,QAAQ;AACZ,MAAI,UAAU;AACd,MAAI,QAAQ;AACZ,QAAM,SAAS,IAAI,MAAc,aAAa;AAC9C,MAAI,aAAa;AACjB,MAAI,eAAe;AAEnB,SAAO;AAAA,IACL,aAAa,IAAkB;AAC7B,eAAS;AACT,iBAAW;AACX,UAAI,KAAK,MAAO,SAAQ;AACxB,aAAO,YAAY,IAAI;AACvB,sBAAgB,eAAe,KAAK;AACpC,UAAI,aAAa,cAAe,eAAc;AAAA,IAChD;AAAA,IAEA,WAA4B;AAC1B,YAAM,SAAS,OAAO,MAAM,GAAG,UAAU,EAAE,KAAK,CAAC,GAAG,MAAM,IAAI,CAAC;AAC/D,aAAO;AAAA,QACL,WAAW,KAAK,IAAI;AAAA,QACpB,kBAAkB;AAAA,UAChB,MAAM,MAAM,UAAU,OAAO,SAAS;AAAA,UACtC,KAAK,MAAM,UAAU,WAAW,EAAE,IAAI,SAAS;AAAA,UAC/C,KAAK,MAAM,UAAU,WAAW,EAAE,IAAI,SAAS;AAAA,UAC/C,KAAK,MAAM,UAAU,MAAM,SAAS;AAAA,QACtC;AAAA,QACA,QAAQ;AAAA,UACN,gBAAgB,KAAK;AAAA,UACrB,SAAS;AAAA,UACT;AAAA,UACA,QAAQ,MAAM,QAAQ,IAAI,UAAU,QAAQ,CAAC;AAAA,UAC7C,OAAO,MAAM,KAAK;AAAA,UAClB,OAAO,MAAM,aAAa,QAAQ,GAAG,CAAC;AAAA,UACtC,OAAO,MAAM,aAAa,QAAQ,IAAI,CAAC;AAAA,QACzC;AAAA,MACF;AAAA,IACF;AAAA,IAEA,iBAAiB,SAAuB;AACtC,sBAAgB;AAAA,IAClB;AAAA,IAEA,OAAa;AACX,gBAAU,QAAQ;AAAA,IACpB;AAAA,EACF;AACF;;;AC/GA,SAAS,gBAAgB,MAAuB;AAC9C,SAAO,QAAQ,OAAS,OAAO;AACjC;AAwEO,SAAS,mBAAmB,QAA0C;AAC3E,QAAM,YAAY,OAAO,aAAa;AACtC,QAAM,kBAAkB,OAAO,mBAAmB;AAClD,QAAMA,eAAc,OAAO,eAAe;AAC1C,SAAO;AAAA,IACL,MAAM,YAAY,GAA+B;AAE/C,UAAI;AACJ,UAAI;AACF,eAAQ,MAAM,EAAE,IAAI,KAAK;AAAA,MAC3B,QAAQ;AACN,eAAO,EAAE,KAAK,EAAE,OAAO,uBAAuB,GAAG,GAAG;AAAA,MACtD;AAEA,UAAI,CAAC,KAAK,OAAO;AACf,eAAO,EAAE,KAAK,EAAE,OAAO,gCAAgC,GAAG,GAAG;AAAA,MAC/D;AAEA,YAAM,QAAQ,KAAK;AAGnB,YAAM,QAAQ,EAAE,IAAI,OAAO,cAAc;AACzC,YAAM,SAAS,EAAE,IAAI,OAAO,eAAe;AAC3C,YAAM,QAAQ,EAAE,IAAI,OAAO,cAAc;AAEzC,UAAI,WAAW;AACb,gBAAQ;AAAA,UACN,iBAAiB,MAAM,EAAE,UAAU,SAAS,GAAG,WAAW,UAAU,GAAG,UAAU,SAAS,GAAG;AAAA,QAC/F;AAAA,MACF;AAcA,UAAI,CAAC,OAAO,SAAS;AACnB,YAAI,gBAAgB,MAAM,IAAI,KAAK,CAAC,iBAAiB;AACnD,cAAI,CAAC,cAAc,KAAK,GAAG;AACzB,mBAAO,EAAE,KAAK,EAAE,OAAO,mBAAmB,GAAG,GAAG;AAAA,UAClD;AAAA,QACF,WAAW,CAAE,MAAMA,aAAY,KAAK,GAAI;AACtC,iBAAO,EAAE,KAAK,EAAE,OAAO,0BAA0B,GAAG,GAAG;AAAA,QACzD;AAAA,MACF;AAGA,UAAI,CAAC,gBAAgB,MAAM,IAAI,GAAG;AAChC,eAAO,WAAW,MAAM,KAAK;AAAA,MAC/B;AAGA,aAAO,WAAW,KAAK;AAGvB,aAAO,EAAE;AAAA,QACP;AAAA,UACE,SAAS,MAAM;AAAA,UACf,UAAU,KAAK,MAAM,KAAK,IAAI,IAAI,GAAI;AAAA,UACtC;AAAA,UACA;AAAA,UACA;AAAA,QACF;AAAA,QACA;AAAA,MACF;AAAA,IACF;AAAA,EACF;AACF;;;ACjKO,SAAS,qBAAqB,QAAsC;AACzE,SAAO;AAAA,IACL,QAAQ;AAAA,IACR,QAAQ,OAAO;AAAA,IACf,cAAc,CAAC,OAAO;AAAA,IACtB,SAAS;AAAA,IACT,WAAW,KAAK,IAAI;AAAA,EACtB;AACF;;;ACpBA,SAAS,iBAAiB;AAC1B,SAAS,YAAY;AACrB,SAAS,aAA8B;AACvC,SAAS,YAA0B;AACnC,SAAS,oBAAoB;AAC7B,SAAS,+BAA+B;AAgLxC,SAAS,eAAe,QAGtB;AACA,QAAM,cAAc,OAAO,aAAa;AACxC,QAAM,eAAe,OAAO,cAAc;AAE1C,MAAI,eAAe,cAAc;AAC/B,UAAM,IAAI;AAAA,MACR;AAAA,IACF;AAAA,EACF;AACA,MAAI,CAAC,eAAe,CAAC,cAAc;AACjC,UAAM,IAAI,MAAM,uDAAuD;AAAA,EACzE;AAEA,QAAM,YAAY,cACd,wBAAwB,OAAO,QAAkB,IAChD,OAAO;AAEZ,SAAO,EAAE,WAAW,QAAQ,aAAa,SAAS,EAAE;AACtD;AAkBO,SAAS,mBAAmB,MAAuB;AACxD,QAAM,IAAI,KAAK,KAAK,EAAE,YAAY;AAClC,MAAI,MAAM,eAAe,MAAM,SAAS,MAAM,QAAS,QAAO;AAC9D,MAAI,EAAE,WAAW,MAAM,EAAG,QAAO;AACjC,MAAI,EAAE,WAAW,KAAK,EAAG,QAAO;AAChC,MAAI,EAAE,WAAW,UAAU,EAAG,QAAO;AACrC,MAAI,6BAA6B,KAAK,CAAC,EAAG,QAAO;AACjD,MAAI,qBAAqB,KAAK,CAAC,EAAG,QAAO;AACzC,MAAI,EAAE,WAAW,OAAO,EAAG,QAAO;AAClC,SAAO;AACT;AAWO,SAAS,uBACd,WACA,SACA,SACS;AACT,QAAM,aAAa,QAAQ,WAAW,CAAC,QAAQ;AAC/C,MAAI,CAAC,cAAc,mBAAmB,SAAS,GAAG;AAChD,WAAO;AAAA,EACT;AACA,UAAQ;AAAA,IACN;AAAA,MACE;AAAA,MACA,IAAI,OAAO,EAAE;AAAA,MACb,2CAA2C,SAAS,IAAI,OAAO;AAAA,MAC/D;AAAA,MACA,QAAQ,UACJ,yCACA;AAAA,MACJ;AAAA,MACA;AAAA,MACA;AAAA,MACA;AAAA,MACA;AAAA,MACA;AAAA,MACA,IAAI,OAAO,EAAE;AAAA,MACb;AAAA,IACF,EAAE,KAAK,IAAI;AAAA,EACb;AACA,SAAO;AACT;AAUO,SAAS,mBACd,UACA,QACA,YACA,qBACmB;AAInB,MAAI,CAAC,SAAS,WAAW,OAAO,KAAK,CAAC,SAAS,WAAW,QAAQ,GAAG;AACnE,UAAM,IAAI;AAAA,MACR;AAAA,IACF;AAAA,EACF;AAEA,QAAM,aAAa,IAAI;AAAA,IACrB,EAAE,WAAW,CAAC,QAAQ,GAAG,OAAO;AAAA,IAChC;AAAA,EACF;AACA,QAAM,SAAS,WAAW,MAAM;AAEhC,MAAI,SAAS;AACb,QAAM,eAAkC;AAAA,IACtC,QAAQ;AACN,UAAI,CAAC,OAAQ;AACb,eAAS;AACT,aAAO,YAAY;AACnB,0BAAoB,OAAO,YAAY;AAAA,IACzC;AAAA,IACA;AAAA,IACA,WAAW;AACT,aAAO;AAAA,IACT;AAAA,EACF;AAEA,sBAAoB,IAAI,YAAY;AACpC,SAAO;AACT;AAqBA,eAAsB,WAAW,QAA6C;AAE5E,QAAM,WAAW,eAAe,MAAM;AAGtC,QAAM,YAAY,OAAO,aAAa;AACtC,QAAM,UAAU,OAAO,WAAW;AAClC,QAAM,OAAO,OAAO,QAAQ;AAC5B,QAAM,YAAY,OAAO,aAAa;AACtC,QAAM,iBACJ,OAAO,kBAAkB,qBAAqB;AAChD,QAAM,UAAU,OAAO,WAAW;AAClC,QAAM,UAAU,OAAO,WAAW;AAClC,QAAM,kBAAkB,OAAO,mBAAmB;AAClD,QAAM,gBAAgB,OAAO,iBAAiB,qBAAqB;AACnE,QAAM,YAAY,OAAO,aAAa;AAEtC,QAAM,iBAAsC;AAAA,IAC1C;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,EACF;AAIA,MAAI;AACJ,MAAI,OAAO,YAAY;AACrB,iBAAa,OAAO;AAAA,EACtB,OAAO;AACL,cAAU,SAAS,EAAE,WAAW,KAAK,CAAC;AACtC,iBAAa,IAAI,iBAAiB,KAAK,SAAS,WAAW,CAAC;AAAA,EAC9D;AAGA,QAAM,UAAU,IAAI;AAAA,IAClB,EAAE,MAAM,WAAW,MAAM,eAAe;AAAA,IACxC;AAAA,EACF;AAGA,QAAM,MAAM,IAAI,KAAK;AAErB,MAAI;AAAA,IAAI;AAAA,IAAW,CAAC,MAClB,EAAE,KAAK,qBAAqB,EAAE,QAAQ,SAAS,OAAO,CAAC,CAAC;AAAA,EAC1D;AAKA,QAAM,UAAU,sBAAsB;AAAA,IACpC;AAAA,IACA,eAAe;AAAA;AAAA,EACjB,CAAC;AACD,QAAM,aAAa,iBAAiB;AAAA,IAClC,MAAM;AAAA,IACN,WAAW,CAAC,OAAO,QAAQ,aAAa,EAAE;AAAA,EAC5C,CAAC;AACD,UAAQ,iBAAiB,WAAW,IAAI;AAExC,MAAI,IAAI,YAAY,CAAC,MAAe,EAAE,KAAK,QAAQ,SAAS,CAAC,CAAC;AAM9D,UAAQ,IAAI,mCAAmC,oBAAoB,EAAE;AACrE,UAAQ;AAAA,IACN,0CACE,UACI,sBACA,kBACE,iCACA,+DACR;AAAA,EACF;AAGA,yBAAuB,WAAW,SAAS,EAAE,iBAAiB,QAAQ,CAAC;AACvE,UAAQ;AAAA,IACN,wBACE,WAAW,OAAO,IACd,GAAG,WAAW,IAAI,sBAClB,sDACN;AAAA,EACF;AACA,QAAM,eAAe,mBAAmB;AAAA,IACtC;AAAA,IACA;AAAA,IACA;AAAA,IACA,aAAa,CAAC,UAAU;AAEtB,cAAQ,iBAAiB,WAAW,IAAI;AACxC,aAAO,WAAW,OAAO,KAAK;AAAA,IAChC;AAAA,IACA;AAAA,IACA,UAAU,CAAC,UAAU;AACnB,UAAI;AACF,gBAAQ,eAAe,KAAK;AAAA,MAC9B,QAAQ;AAAA,MAER;AAAA,IACF;AAAA,EACF,CAAC;AACD,MAAI,KAAK,UAAU,CAAC,MAAe,aAAa,YAAY,CAAC,CAAC;AAI9D,QAAM,YAAwB,MAAM,IAAI,QAAoB,CAAC,YAAY;AACvE,UAAM,SAAS;AAAA,MACb,EAAE,OAAO,IAAI,OAAO,MAAM,SAAS,UAAU,UAAU;AAAA,MACvD,MAAM,QAAQ,MAAM;AAAA,IACtB;AAAA,EACF,CAAC;AAGD,QAAM,QAAQ,MAAM;AAGpB,MAAI,UAAU;AACd,QAAM,sBAAsB,oBAAI,IAAuB;AAEvD,QAAM,WAA0B;AAAA,IAC9B,YAAY;AACV,aAAO;AAAA,IACT;AAAA,IAEA,UAAU,mBAA2B,QAAmC;AACtE,UAAI,CAAC,SAAS;AACZ,cAAM,IAAI,MAAM,wCAAwC;AAAA,MAC1D;AACA,aAAO;AAAA,QACL;AAAA,QACA;AAAA,QACA;AAAA,QACA;AAAA,MACF;AAAA,IACF;AAAA,IAEA,MAAM,OAAO;AACX,UAAI,CAAC,QAAS;AACd,gBAAU;AAEV,iBAAW,OAAO,qBAAqB;AACrC,YAAI,MAAM;AAAA,MACZ;AACA,0BAAoB,MAAM;AAE1B,YAAM,QAAQ,KAAK;AACnB,gBAAU,MAAM;AAChB,cAAQ,KAAK;AACb,YAAM,WAAW,QAAQ;AAGzB,UAAI,CAAC,OAAO,YAAY;AACtB,mBAAW,QAAQ;AAAA,MACrB;AAAA,IACF;AAAA,IAEA,QAAQ,SAAS;AAAA,IACjB,QAAQ;AAAA,EACV;AAEA,SAAO;AACT;","names":["verifyEvent"]}
@@ -0,0 +1,92 @@
1
+ // src/crypto/verify-event.ts
2
+ import { createHash } from "crypto";
3
+ import { createRequire } from "module";
4
+ import {
5
+ serializeEvent,
6
+ verifiedSymbol,
7
+ verifyEvent as nobleVerifyEvent
8
+ } from "nostr-tools/pure";
9
+ var SELF_TEST_EVENT = {
10
+ kind: 1,
11
+ created_at: 1754e6,
12
+ tags: [],
13
+ content: "toon-relay fast-verify self-test",
14
+ pubkey: "6a04ab98d9e4774ad806e302dddeb63bea16b5cb5f223ee77478e861bb583eb3",
15
+ id: "ff38bb1eb0dbd275934b16675751e8ed547812b7ca8c0cf7fe89ca666c85176a",
16
+ sig: "faa9977f43a6b5bd183b41963658f5258c22c8b7a60ad99779d4d82ddaadb53682a851f55f855f40c7faae7fd00c4a71eed14f3fdfb60a2751fe1b2a414dbbd3"
17
+ };
18
+ function verifyWith(verifySchnorr, event) {
19
+ try {
20
+ if (typeof event[verifiedSymbol] === "boolean") {
21
+ return event[verifiedSymbol];
22
+ }
23
+ if (typeof event.id !== "string" || typeof event.sig !== "string") {
24
+ return false;
25
+ }
26
+ const hash = createHash("sha256").update(serializeEvent(event), "utf8").digest();
27
+ if (hash.toString("hex") !== event.id) {
28
+ return event[verifiedSymbol] = false;
29
+ }
30
+ if (event.sig.length !== 128) {
31
+ return false;
32
+ }
33
+ const sig = Buffer.from(event.sig, "hex");
34
+ const pubkey = Buffer.from(event.pubkey, "hex");
35
+ if (sig.length !== 64 || pubkey.length !== 32) {
36
+ return false;
37
+ }
38
+ return event[verifiedSymbol] = verifySchnorr(hash, pubkey, sig);
39
+ } catch {
40
+ return false;
41
+ }
42
+ }
43
+ function loadFastVerifier() {
44
+ try {
45
+ const require2 = createRequire(import.meta.url);
46
+ const tiny = require2("tiny-secp256k1");
47
+ const candidate = tiny.verifySchnorr;
48
+ if (typeof candidate !== "function") {
49
+ return null;
50
+ }
51
+ const tampered = {
52
+ ...SELF_TEST_EVENT,
53
+ sig: "0".repeat(128)
54
+ };
55
+ if (!verifyWith(candidate, SELF_TEST_EVENT) || verifyWith(candidate, tampered)) {
56
+ return null;
57
+ }
58
+ return candidate;
59
+ } catch {
60
+ return null;
61
+ }
62
+ }
63
+ var fastVerifySchnorr = loadFastVerifier();
64
+ var verifyImplementation = fastVerifySchnorr ? "libsecp256k1-wasm" : "noble-pure-js";
65
+ function verifyEventId(event) {
66
+ try {
67
+ if (typeof event.id !== "string") {
68
+ return false;
69
+ }
70
+ const hash = createHash("sha256").update(serializeEvent(event), "utf8").digest();
71
+ return hash.toString("hex") === event.id;
72
+ } catch {
73
+ return false;
74
+ }
75
+ }
76
+ function verifyEventSignature(event) {
77
+ if (fastVerifySchnorr) {
78
+ return verifyWith(fastVerifySchnorr, event);
79
+ }
80
+ try {
81
+ return nobleVerifyEvent(event);
82
+ } catch {
83
+ return false;
84
+ }
85
+ }
86
+
87
+ export {
88
+ verifyImplementation,
89
+ verifyEventId,
90
+ verifyEventSignature
91
+ };
92
+ //# sourceMappingURL=chunk-SMT6G3XD.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/crypto/verify-event.ts"],"sourcesContent":["/**\n * Event-signature verification for the paid-write hot path (relay#85).\n *\n * nostr-tools' `verifyEvent` runs BIP-340 schnorr verification in pure-JS\n * noble at ~1.3ms per event -- synchronously, on the relay's single Node\n * event loop. Post-#84 that verify IS the write-path ceiling: the devnet\n * relay caps at ~240-260 events/s aggregate, CPU-bound (connector#685\n * Phase G measurements).\n *\n * This module verifies with libsecp256k1 compiled to WASM (`tiny-secp256k1`,\n * ~0.20ms per event on the same workload -- ~7x faster) and keeps noble as a\n * fallback: if the WASM module fails to load or fails a startup self-test on\n * a known-good/known-bad vector pair, verification transparently degrades to\n * `verifyEvent` from nostr-tools. The relay never hard-fails because of the\n * fast path. WASM was chosen over a native addon deliberately: it needs no\n * platform toolchain in the Docker build, and the `secp256k1` native-addon\n * package exposes no schnorr API at all.\n *\n * Semantics match nostr-tools `verifyEvent` -- serialize per NIP-01, SHA-256,\n * compare against `event.id`, then BIP-340-verify `event.sig` -- with one\n * deliberate improvement: a structurally invalid event returns `false` here\n * (nostr-tools' `getEventHash` throws instead). `verifyEventSignature` never\n * throws.\n *\n * @module\n */\n\nimport { createHash } from 'node:crypto';\nimport { createRequire } from 'node:module';\nimport {\n serializeEvent,\n verifiedSymbol,\n verifyEvent as nobleVerifyEvent,\n} from 'nostr-tools/pure';\nimport type { NostrEvent } from 'nostr-tools/pure';\n\n/**\n * BIP-340 verify: 32-byte message hash, 32-byte x-only pubkey, 64-byte\n * signature. Shape of `tiny-secp256k1`'s `verifySchnorr`.\n */\ntype VerifySchnorr = (\n hash: Uint8Array,\n pubkey: Uint8Array,\n signature: Uint8Array\n) => boolean;\n\n/**\n * Deterministic self-test vector: a valid signed event and a tampered copy.\n * Used at load time to prove the WASM verifier actually works on this\n * platform before it is trusted on the hot path.\n */\nconst SELF_TEST_EVENT: NostrEvent = {\n kind: 1,\n created_at: 1754000000,\n tags: [],\n content: 'toon-relay fast-verify self-test',\n pubkey: '6a04ab98d9e4774ad806e302dddeb63bea16b5cb5f223ee77478e861bb583eb3',\n id: 'ff38bb1eb0dbd275934b16675751e8ed547812b7ca8c0cf7fe89ca666c85176a',\n sig: 'faa9977f43a6b5bd183b41963658f5258c22c8b7a60ad99779d4d82ddaadb53682a851f55f855f40c7faae7fd00c4a71eed14f3fdfb60a2751fe1b2a414dbbd3',\n};\n\n/**\n * Verify an event with a given schnorr implementation: NIP-01 serialize,\n * SHA-256 (Node's native hash, not pure JS), id check, then BIP-340 verify.\n * Returns false (never throws) on structurally invalid input.\n */\nfunction verifyWith(verifySchnorr: VerifySchnorr, event: NostrEvent): boolean {\n try {\n // Mirror nostr-tools' verified-event cache: honor a prior verdict and\n // stamp ours, so downstream code that re-verifies (or deep-compares) the\n // event sees exactly what noble's verifyEvent would have produced.\n if (typeof event[verifiedSymbol] === 'boolean') {\n return event[verifiedSymbol];\n }\n if (typeof event.id !== 'string' || typeof event.sig !== 'string') {\n return false;\n }\n // serializeEvent validates structure (types, pubkey format) and throws on\n // bad input -- caught below. Reusing it guarantees byte-parity with the\n // noble path's getEventHash forever.\n const hash = createHash('sha256')\n .update(serializeEvent(event), 'utf8')\n .digest();\n if (hash.toString('hex') !== event.id) {\n return (event[verifiedSymbol] = false);\n }\n // event.sig must be exactly 64 hex-decoded bytes. Buffer.from(_, 'hex')\n // stops at the first invalid character, so the length check also rejects\n // non-hex input; the explicit string-length check rejects odd/overlong\n // strings that would still decode to 64 bytes.\n if (event.sig.length !== 128) {\n return false;\n }\n const sig = Buffer.from(event.sig, 'hex');\n const pubkey = Buffer.from(event.pubkey, 'hex');\n if (sig.length !== 64 || pubkey.length !== 32) {\n return false;\n }\n return (event[verifiedSymbol] = verifySchnorr(hash, pubkey, sig));\n } catch {\n return false;\n }\n}\n\n/**\n * Load tiny-secp256k1 (synchronous WASM instantiation) and prove it against\n * the self-test vector pair. Returns null -- meaning \"use the noble\n * fallback\" -- on any load or self-test failure.\n */\nfunction loadFastVerifier(): VerifySchnorr | null {\n try {\n const require = createRequire(import.meta.url);\n const tiny = require('tiny-secp256k1') as {\n verifySchnorr?: VerifySchnorr;\n };\n const candidate = tiny.verifySchnorr;\n if (typeof candidate !== 'function') {\n return null;\n }\n const tampered: NostrEvent = {\n ...SELF_TEST_EVENT,\n sig: '0'.repeat(128),\n };\n if (\n !verifyWith(candidate, SELF_TEST_EVENT) ||\n verifyWith(candidate, tampered)\n ) {\n return null;\n }\n return candidate;\n } catch {\n return null;\n }\n}\n\nconst fastVerifySchnorr = loadFastVerifier();\n\n/**\n * Which verify implementation is active. Surfaced so the launcher can log it\n * once at startup (the noble fallback is a silent ~7x throughput loss\n * otherwise).\n */\nexport const verifyImplementation: 'libsecp256k1-wasm' | 'noble-pure-js' =\n fastVerifySchnorr ? 'libsecp256k1-wasm' : 'noble-pure-js';\n\n/**\n * Verify ONLY that `event.id` is the correct NIP-01 SHA-256 hash of the\n * event's serialized form. Does NOT check the schnorr signature and does NOT\n * stamp nostr-tools' `verifiedSymbol` cache (an id check is not a signature\n * verdict).\n *\n * This is the integrity floor for the paid-ephemeral skip-verify path\n * (relay#85): when the relay skips schnorr for payment-gated ephemeral kinds,\n * it still refuses events whose id does not match their content, so a paid\n * writer cannot make the relay broadcast a frame whose bytes disagree with\n * the id that clients index/verify by.\n *\n * Never throws; structurally invalid events return false.\n *\n * @param event - The event whose id to check.\n * @returns True iff `event.id` equals the NIP-01 SHA-256 of the event.\n */\nexport function verifyEventId(event: NostrEvent): boolean {\n try {\n if (typeof event.id !== 'string') {\n return false;\n }\n // serializeEvent validates structure and throws on bad input -- caught\n // below. Same serializer as the full-verify path, so the two checks can\n // never disagree about what bytes an id covers.\n const hash = createHash('sha256')\n .update(serializeEvent(event), 'utf8')\n .digest();\n return hash.toString('hex') === event.id;\n } catch {\n return false;\n }\n}\n\n/**\n * Verify a Nostr event's id and BIP-340 signature.\n *\n * Drop-in replacement for nostr-tools' `verifyEvent` on the write hot path:\n * same accept/reject semantics for well-formed events, ~7x faster via WASM\n * libsecp256k1 when available, noble pure-JS otherwise. Never throws;\n * structurally invalid events return false.\n *\n * @param event - The event to verify.\n * @returns True iff the id matches the NIP-01 hash and the signature is valid.\n */\nexport function verifyEventSignature(event: NostrEvent): boolean {\n if (fastVerifySchnorr) {\n return verifyWith(fastVerifySchnorr, event);\n }\n try {\n return nobleVerifyEvent(event);\n } catch {\n return false;\n }\n}\n"],"mappings":";AA2BA,SAAS,kBAAkB;AAC3B,SAAS,qBAAqB;AAC9B;AAAA,EACE;AAAA,EACA;AAAA,EACA,eAAe;AAAA,OACV;AAkBP,IAAM,kBAA8B;AAAA,EAClC,MAAM;AAAA,EACN,YAAY;AAAA,EACZ,MAAM,CAAC;AAAA,EACP,SAAS;AAAA,EACT,QAAQ;AAAA,EACR,IAAI;AAAA,EACJ,KAAK;AACP;AAOA,SAAS,WAAW,eAA8B,OAA4B;AAC5E,MAAI;AAIF,QAAI,OAAO,MAAM,cAAc,MAAM,WAAW;AAC9C,aAAO,MAAM,cAAc;AAAA,IAC7B;AACA,QAAI,OAAO,MAAM,OAAO,YAAY,OAAO,MAAM,QAAQ,UAAU;AACjE,aAAO;AAAA,IACT;AAIA,UAAM,OAAO,WAAW,QAAQ,EAC7B,OAAO,eAAe,KAAK,GAAG,MAAM,EACpC,OAAO;AACV,QAAI,KAAK,SAAS,KAAK,MAAM,MAAM,IAAI;AACrC,aAAQ,MAAM,cAAc,IAAI;AAAA,IAClC;AAKA,QAAI,MAAM,IAAI,WAAW,KAAK;AAC5B,aAAO;AAAA,IACT;AACA,UAAM,MAAM,OAAO,KAAK,MAAM,KAAK,KAAK;AACxC,UAAM,SAAS,OAAO,KAAK,MAAM,QAAQ,KAAK;AAC9C,QAAI,IAAI,WAAW,MAAM,OAAO,WAAW,IAAI;AAC7C,aAAO;AAAA,IACT;AACA,WAAQ,MAAM,cAAc,IAAI,cAAc,MAAM,QAAQ,GAAG;AAAA,EACjE,QAAQ;AACN,WAAO;AAAA,EACT;AACF;AAOA,SAAS,mBAAyC;AAChD,MAAI;AACF,UAAMA,WAAU,cAAc,YAAY,GAAG;AAC7C,UAAM,OAAOA,SAAQ,gBAAgB;AAGrC,UAAM,YAAY,KAAK;AACvB,QAAI,OAAO,cAAc,YAAY;AACnC,aAAO;AAAA,IACT;AACA,UAAM,WAAuB;AAAA,MAC3B,GAAG;AAAA,MACH,KAAK,IAAI,OAAO,GAAG;AAAA,IACrB;AACA,QACE,CAAC,WAAW,WAAW,eAAe,KACtC,WAAW,WAAW,QAAQ,GAC9B;AACA,aAAO;AAAA,IACT;AACA,WAAO;AAAA,EACT,QAAQ;AACN,WAAO;AAAA,EACT;AACF;AAEA,IAAM,oBAAoB,iBAAiB;AAOpC,IAAM,uBACX,oBAAoB,sBAAsB;AAmBrC,SAAS,cAAc,OAA4B;AACxD,MAAI;AACF,QAAI,OAAO,MAAM,OAAO,UAAU;AAChC,aAAO;AAAA,IACT;AAIA,UAAM,OAAO,WAAW,QAAQ,EAC7B,OAAO,eAAe,KAAK,GAAG,MAAM,EACpC,OAAO;AACV,WAAO,KAAK,SAAS,KAAK,MAAM,MAAM;AAAA,EACxC,QAAQ;AACN,WAAO;AAAA,EACT;AACF;AAaO,SAAS,qBAAqB,OAA4B;AAC/D,MAAI,mBAAmB;AACrB,WAAO,WAAW,mBAAmB,KAAK;AAAA,EAC5C;AACA,MAAI;AACF,WAAO,iBAAiB,KAAK;AAAA,EAC/B,QAAQ;AACN,WAAO;AAAA,EACT;AACF;","names":["require"]}
package/dist/cli.js CHANGED
@@ -1,7 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  startRelay
4
- } from "./chunk-745ADETR.js";
4
+ } from "./chunk-QZQRHQEQ.js";
5
+ import "./chunk-SMT6G3XD.js";
5
6
 
6
7
  // src/launcher/cli.ts
7
8
  import { parseArgs } from "util";
@@ -16,8 +17,32 @@ Options:
16
17
  --relay-port <port> WebSocket read port (default: 7100)
17
18
  --bls-port <port> HTTP write/health port (default: 3100)
18
19
  --host <host> WebSocket bind host (default: 0.0.0.0)
20
+ --write-host <host> HTTP write/health bind host (default: 0.0.0.0).
21
+ The write port must only be reachable via the
22
+ payment-gating connector; bind it to a loopback/
23
+ internal address when not isolated by docker
24
+ networking (relay#85 exposure guard)
19
25
  --data-dir <path> Data directory for the SQLite store (default: ./data)
20
26
  --dev-mode Skip event-signature verification on POST /write
27
+ --verify-ephemeral Run FULL schnorr verification on ephemeral kinds
28
+ (20000 <= kind < 30000). Default OFF: the write
29
+ path is payment-gated and clients verify
30
+ signatures themselves, so ephemeral kinds skip
31
+ schnorr and keep only the SHA-256 id check
32
+ (relay#85). Set this when the write port is
33
+ fronted by anything other than a payment-gating
34
+ connector
35
+ --verify-workers <n> Worker threads for persistent-kind signature
36
+ verification (default: CPU count - 1, minimum 0).
37
+ 0 = verify inline on the event loop (automatic on
38
+ 1-core boxes; the escape hatch back to the
39
+ pre-pool behavior, relay#85)
40
+ --max-connections <n> Maximum concurrent WebSocket read connections
41
+ (default: 4096; each costs one file descriptor --
42
+ mind ulimit -n, relay#90)
43
+ --log-writes Log one line per accepted POST /write (debug; off
44
+ by default -- per-event logging is write-path
45
+ tail jitter, relay#85)
21
46
  --help Show this help message
22
47
 
23
48
  Environment Variables:
@@ -27,8 +52,13 @@ Environment Variables:
27
52
  TOON_RELAY_PORT Same as --relay-port
28
53
  TOON_BLS_PORT Same as --bls-port
29
54
  TOON_HOST Same as --host
55
+ TOON_WRITE_HOST Same as --write-host
30
56
  TOON_DATA_DIR Same as --data-dir
31
57
  TOON_DEV_MODE Same as --dev-mode (set to "true")
58
+ TOON_VERIFY_EPHEMERAL Same as --verify-ephemeral (set to "true")
59
+ TOON_VERIFY_WORKERS Same as --verify-workers
60
+ TOON_MAX_CONNECTIONS Same as --max-connections
61
+ TOON_LOG_WRITES Same as --log-writes (set to "true")
32
62
 
33
63
  Security:
34
64
  Prefer TOON_MNEMONIC / TOON_SECRET_KEY / NOSTR_SECRET_KEY environment
@@ -45,8 +75,13 @@ function parseCli() {
45
75
  "relay-port": { type: "string" },
46
76
  "bls-port": { type: "string" },
47
77
  host: { type: "string" },
78
+ "write-host": { type: "string" },
48
79
  "data-dir": { type: "string" },
49
80
  "dev-mode": { type: "boolean" },
81
+ "verify-ephemeral": { type: "boolean" },
82
+ "verify-workers": { type: "string" },
83
+ "max-connections": { type: "string" },
84
+ "log-writes": { type: "boolean" },
50
85
  help: { type: "boolean" }
51
86
  },
52
87
  strict: true,
@@ -83,9 +118,7 @@ function parseCli() {
83
118
  process.exit(1);
84
119
  }
85
120
  if (mnemonic && secretKey) {
86
- console.error(
87
- "Error: provide either a mnemonic or a secret key, not both"
88
- );
121
+ console.error("Error: provide either a mnemonic or a secret key, not both");
89
122
  process.exit(1);
90
123
  }
91
124
  const relayPortStr = values["relay-port"] ?? process.env["TOON_RELAY_PORT"] ?? void 0;
@@ -101,16 +134,36 @@ function parseCli() {
101
134
  process.exit(1);
102
135
  }
103
136
  const host = values.host ?? process.env["TOON_HOST"] ?? void 0;
137
+ const writeHost = values["write-host"] ?? process.env["TOON_WRITE_HOST"] ?? void 0;
104
138
  const dataDir = values["data-dir"] ?? process.env["TOON_DATA_DIR"] ?? void 0;
105
139
  const devMode = values["dev-mode"] ?? (process.env["TOON_DEV_MODE"] === "true" ? true : void 0);
140
+ const verifyEphemeral = values["verify-ephemeral"] ?? (process.env["TOON_VERIFY_EPHEMERAL"] === "true" ? true : void 0);
141
+ const verifyWorkersStr = values["verify-workers"] ?? process.env["TOON_VERIFY_WORKERS"] ?? void 0;
142
+ const verifyWorkers = verifyWorkersStr ? parseInt(verifyWorkersStr, 10) : void 0;
143
+ if (verifyWorkers !== void 0 && (Number.isNaN(verifyWorkers) || verifyWorkers < 0 || verifyWorkers > 256)) {
144
+ console.error("Error: --verify-workers must be an integer >= 0");
145
+ process.exit(1);
146
+ }
147
+ const maxConnectionsStr = values["max-connections"] ?? process.env["TOON_MAX_CONNECTIONS"] ?? void 0;
148
+ const maxConnections = maxConnectionsStr ? parseInt(maxConnectionsStr, 10) : void 0;
149
+ if (maxConnections !== void 0 && (Number.isNaN(maxConnections) || maxConnections <= 0)) {
150
+ console.error("Error: --max-connections must be a positive integer");
151
+ process.exit(1);
152
+ }
153
+ const logWrites = values["log-writes"] ?? (process.env["TOON_LOG_WRITES"] === "true" ? true : void 0);
106
154
  const config = {
107
155
  ...mnemonic && { mnemonic },
108
156
  ...secretKey && { secretKey },
109
157
  ...relayPort !== void 0 && { relayPort },
110
158
  ...blsPort !== void 0 && { blsPort },
111
159
  ...host && { host },
160
+ ...writeHost && { writeHost },
112
161
  ...dataDir && { dataDir },
113
- ...devMode !== void 0 && { devMode }
162
+ ...devMode !== void 0 && { devMode },
163
+ ...verifyEphemeral !== void 0 && { verifyEphemeral },
164
+ ...verifyWorkers !== void 0 && { verifyWorkers },
165
+ ...maxConnections !== void 0 && { maxConnections },
166
+ ...logWrites !== void 0 && { logWrites }
114
167
  };
115
168
  return config;
116
169
  }
package/dist/cli.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/launcher/cli.ts"],"sourcesContent":["#!/usr/bin/env node\n\n/**\n * CLI entrypoint for @toon-protocol/relay.\n *\n * Thin wrapper around startRelay() that parses CLI flags and environment\n * variables, then delegates to relay.ts. The relay is a plain read/write app:\n * free NIP-01 WebSocket reads plus an HTTP `POST /write` surface. Payment is\n * enforced upstream by an external terminator, so there are no connector,\n * ILP, chain, or pricing options here.\n *\n * Usage:\n * relay --secret-key <hex>\n * NOSTR_SECRET_KEY=<hex> relay\n *\n * Environment variables override defaults; CLI flags override environment variables.\n */\n\nimport { parseArgs } from 'node:util';\nimport { startRelay } from './relay.js';\nimport type { RelayConfig, RelayInstance } from './relay.js';\n\n// ---------- CLI Parsing ----------\n\nfunction printHelp(): void {\n console.log(\n `\nUsage: relay [options]\n\nOptions:\n --mnemonic <words> BIP-39 mnemonic (12 or 24 words; NIP-06 derivation)\n --secret-key <hex> 32-byte secret key in hex\n --relay-port <port> WebSocket read port (default: 7100)\n --bls-port <port> HTTP write/health port (default: 3100)\n --host <host> WebSocket bind host (default: 0.0.0.0)\n --data-dir <path> Data directory for the SQLite store (default: ./data)\n --dev-mode Skip event-signature verification on POST /write\n --help Show this help message\n\nEnvironment Variables:\n TOON_MNEMONIC Same as --mnemonic\n TOON_SECRET_KEY Same as --secret-key\n NOSTR_SECRET_KEY Alias for TOON_SECRET_KEY (identity); TOON_SECRET_KEY wins\n TOON_RELAY_PORT Same as --relay-port\n TOON_BLS_PORT Same as --bls-port\n TOON_HOST Same as --host\n TOON_DATA_DIR Same as --data-dir\n TOON_DEV_MODE Same as --dev-mode (set to \"true\")\n\nSecurity:\n Prefer TOON_MNEMONIC / TOON_SECRET_KEY / NOSTR_SECRET_KEY environment\n variables over --mnemonic / --secret-key CLI flags. CLI arguments are visible\n to other users on the system via process listings (e.g. ps aux). See CWE-214.\n`.trim()\n );\n}\n\nfunction parseCli(): RelayConfig {\n const { values } = parseArgs({\n options: {\n mnemonic: { type: 'string' },\n 'secret-key': { type: 'string' },\n 'relay-port': { type: 'string' },\n 'bls-port': { type: 'string' },\n host: { type: 'string' },\n 'data-dir': { type: 'string' },\n 'dev-mode': { type: 'boolean' },\n help: { type: 'boolean' },\n },\n strict: true,\n allowPositionals: false,\n });\n\n if (values.help) {\n printHelp();\n process.exit(0);\n }\n\n // Warn about process-listing exposure (CWE-214) when secrets are passed via CLI flags\n if (values.mnemonic) {\n console.warn(\n 'Warning: --mnemonic is visible in process listings. ' +\n 'Prefer TOON_MNEMONIC environment variable for production use.'\n );\n }\n if (values['secret-key']) {\n console.warn(\n 'Warning: --secret-key is visible in process listings. ' +\n 'Prefer TOON_SECRET_KEY environment variable for production use.'\n );\n }\n\n const mnemonic = values.mnemonic ?? process.env['TOON_MNEMONIC'] ?? undefined;\n\n // Identity secret key. NOSTR_SECRET_KEY is accepted as an alias for\n // TOON_SECRET_KEY so the container honors the same identity env the connector\n // compose uses. TOON_SECRET_KEY wins when both are set.\n const secretKeyHex =\n values['secret-key'] ??\n process.env['TOON_SECRET_KEY'] ??\n process.env['NOSTR_SECRET_KEY'] ??\n undefined;\n\n let secretKey: Uint8Array | undefined;\n if (secretKeyHex) {\n if (secretKeyHex.length !== 64 || !/^[0-9a-fA-F]{64}$/.test(secretKeyHex)) {\n console.error('Error: --secret-key must be a 64-character hex string');\n process.exit(1);\n }\n secretKey = Uint8Array.from(Buffer.from(secretKeyHex, 'hex'));\n }\n\n if (!mnemonic && !secretKey) {\n console.error(\n 'Error: one of --mnemonic (or TOON_MNEMONIC) or --secret-key ' +\n '(or TOON_SECRET_KEY / NOSTR_SECRET_KEY) is required'\n );\n process.exit(1);\n }\n if (mnemonic && secretKey) {\n console.error(\n 'Error: provide either a mnemonic or a secret key, not both'\n );\n process.exit(1);\n }\n\n const relayPortStr =\n values['relay-port'] ?? process.env['TOON_RELAY_PORT'] ?? undefined;\n const relayPort = relayPortStr ? parseInt(relayPortStr, 10) : undefined;\n if (\n relayPort !== undefined &&\n (Number.isNaN(relayPort) || relayPort <= 0 || relayPort > 65535)\n ) {\n console.error('Error: --relay-port must be an integer between 1 and 65535');\n process.exit(1);\n }\n\n const blsPortStr =\n values['bls-port'] ?? process.env['TOON_BLS_PORT'] ?? undefined;\n const blsPort = blsPortStr ? parseInt(blsPortStr, 10) : undefined;\n if (\n blsPort !== undefined &&\n (Number.isNaN(blsPort) || blsPort <= 0 || blsPort > 65535)\n ) {\n console.error('Error: --bls-port must be an integer between 1 and 65535');\n process.exit(1);\n }\n\n const host = values.host ?? process.env['TOON_HOST'] ?? undefined;\n\n const dataDir =\n values['data-dir'] ?? process.env['TOON_DATA_DIR'] ?? undefined;\n\n const devMode =\n values['dev-mode'] ??\n (process.env['TOON_DEV_MODE'] === 'true' ? true : undefined);\n\n const config: RelayConfig = {\n ...(mnemonic && { mnemonic }),\n ...(secretKey && { secretKey }),\n ...(relayPort !== undefined && { relayPort }),\n ...(blsPort !== undefined && { blsPort }),\n ...(host && { host }),\n ...(dataDir && { dataDir }),\n ...(devMode !== undefined && { devMode }),\n };\n\n return config;\n}\n\n// ---------- Main ----------\n\nasync function main(): Promise<void> {\n const config = parseCli();\n\n console.log('\\n' + '='.repeat(50));\n console.log('TOON Relay Starting');\n console.log('='.repeat(50) + '\\n');\n\n const instance: RelayInstance = await startRelay(config);\n\n console.log('\\n' + '='.repeat(50));\n console.log('TOON Relay Ready');\n console.log('='.repeat(50));\n console.log(` Pubkey: ${instance.pubkey}`);\n console.log(` Reads: ws://localhost:${instance.config.relayPort}`);\n console.log(` Writes: http://localhost:${instance.config.blsPort}/write`);\n console.log(` Health: http://localhost:${instance.config.blsPort}/health`);\n console.log('='.repeat(50) + '\\n');\n\n // Wire graceful shutdown\n const shutdown = async (signal: string): Promise<void> => {\n console.log(`\\n[Shutdown] Received ${signal}`);\n await instance.stop();\n console.log('[Shutdown] Complete');\n process.exit(0);\n };\n\n process.on('SIGINT', () => {\n shutdown('SIGINT').catch(console.error);\n });\n process.on('SIGTERM', () => {\n shutdown('SIGTERM').catch(console.error);\n });\n}\n\nmain().catch((error: unknown) => {\n console.error('[Fatal] Startup error:', error);\n process.exit(1);\n});\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../src/launcher/cli.ts"],"sourcesContent":["#!/usr/bin/env node\n\n/**\n * CLI entrypoint for @toon-protocol/relay.\n *\n * Thin wrapper around startRelay() that parses CLI flags and environment\n * variables, then delegates to relay.ts. The relay is a plain read/write app:\n * free NIP-01 WebSocket reads plus an HTTP `POST /write` surface. Payment is\n * enforced upstream by an external terminator, so there are no connector,\n * ILP, chain, or pricing options here.\n *\n * Usage:\n * relay --secret-key <hex>\n * NOSTR_SECRET_KEY=<hex> relay\n *\n * Environment variables override defaults; CLI flags override environment variables.\n */\n\nimport { parseArgs } from 'node:util';\nimport { startRelay } from './relay.js';\nimport type { RelayConfig, RelayInstance } from './relay.js';\n\n// ---------- CLI Parsing ----------\n\nfunction printHelp(): void {\n console.log(\n `\nUsage: relay [options]\n\nOptions:\n --mnemonic <words> BIP-39 mnemonic (12 or 24 words; NIP-06 derivation)\n --secret-key <hex> 32-byte secret key in hex\n --relay-port <port> WebSocket read port (default: 7100)\n --bls-port <port> HTTP write/health port (default: 3100)\n --host <host> WebSocket bind host (default: 0.0.0.0)\n --write-host <host> HTTP write/health bind host (default: 0.0.0.0).\n The write port must only be reachable via the\n payment-gating connector; bind it to a loopback/\n internal address when not isolated by docker\n networking (relay#85 exposure guard)\n --data-dir <path> Data directory for the SQLite store (default: ./data)\n --dev-mode Skip event-signature verification on POST /write\n --verify-ephemeral Run FULL schnorr verification on ephemeral kinds\n (20000 <= kind < 30000). Default OFF: the write\n path is payment-gated and clients verify\n signatures themselves, so ephemeral kinds skip\n schnorr and keep only the SHA-256 id check\n (relay#85). Set this when the write port is\n fronted by anything other than a payment-gating\n connector\n --verify-workers <n> Worker threads for persistent-kind signature\n verification (default: CPU count - 1, minimum 0).\n 0 = verify inline on the event loop (automatic on\n 1-core boxes; the escape hatch back to the\n pre-pool behavior, relay#85)\n --max-connections <n> Maximum concurrent WebSocket read connections\n (default: 4096; each costs one file descriptor --\n mind ulimit -n, relay#90)\n --log-writes Log one line per accepted POST /write (debug; off\n by default -- per-event logging is write-path\n tail jitter, relay#85)\n --help Show this help message\n\nEnvironment Variables:\n TOON_MNEMONIC Same as --mnemonic\n TOON_SECRET_KEY Same as --secret-key\n NOSTR_SECRET_KEY Alias for TOON_SECRET_KEY (identity); TOON_SECRET_KEY wins\n TOON_RELAY_PORT Same as --relay-port\n TOON_BLS_PORT Same as --bls-port\n TOON_HOST Same as --host\n TOON_WRITE_HOST Same as --write-host\n TOON_DATA_DIR Same as --data-dir\n TOON_DEV_MODE Same as --dev-mode (set to \"true\")\n TOON_VERIFY_EPHEMERAL Same as --verify-ephemeral (set to \"true\")\n TOON_VERIFY_WORKERS Same as --verify-workers\n TOON_MAX_CONNECTIONS Same as --max-connections\n TOON_LOG_WRITES Same as --log-writes (set to \"true\")\n\nSecurity:\n Prefer TOON_MNEMONIC / TOON_SECRET_KEY / NOSTR_SECRET_KEY environment\n variables over --mnemonic / --secret-key CLI flags. CLI arguments are visible\n to other users on the system via process listings (e.g. ps aux). See CWE-214.\n`.trim()\n );\n}\n\nfunction parseCli(): RelayConfig {\n const { values } = parseArgs({\n options: {\n mnemonic: { type: 'string' },\n 'secret-key': { type: 'string' },\n 'relay-port': { type: 'string' },\n 'bls-port': { type: 'string' },\n host: { type: 'string' },\n 'write-host': { type: 'string' },\n 'data-dir': { type: 'string' },\n 'dev-mode': { type: 'boolean' },\n 'verify-ephemeral': { type: 'boolean' },\n 'verify-workers': { type: 'string' },\n 'max-connections': { type: 'string' },\n 'log-writes': { type: 'boolean' },\n help: { type: 'boolean' },\n },\n strict: true,\n allowPositionals: false,\n });\n\n if (values.help) {\n printHelp();\n process.exit(0);\n }\n\n // Warn about process-listing exposure (CWE-214) when secrets are passed via CLI flags\n if (values.mnemonic) {\n console.warn(\n 'Warning: --mnemonic is visible in process listings. ' +\n 'Prefer TOON_MNEMONIC environment variable for production use.'\n );\n }\n if (values['secret-key']) {\n console.warn(\n 'Warning: --secret-key is visible in process listings. ' +\n 'Prefer TOON_SECRET_KEY environment variable for production use.'\n );\n }\n\n const mnemonic = values.mnemonic ?? process.env['TOON_MNEMONIC'] ?? undefined;\n\n // Identity secret key. NOSTR_SECRET_KEY is accepted as an alias for\n // TOON_SECRET_KEY so the container honors the same identity env the connector\n // compose uses. TOON_SECRET_KEY wins when both are set.\n const secretKeyHex =\n values['secret-key'] ??\n process.env['TOON_SECRET_KEY'] ??\n process.env['NOSTR_SECRET_KEY'] ??\n undefined;\n\n let secretKey: Uint8Array | undefined;\n if (secretKeyHex) {\n if (secretKeyHex.length !== 64 || !/^[0-9a-fA-F]{64}$/.test(secretKeyHex)) {\n console.error('Error: --secret-key must be a 64-character hex string');\n process.exit(1);\n }\n secretKey = Uint8Array.from(Buffer.from(secretKeyHex, 'hex'));\n }\n\n if (!mnemonic && !secretKey) {\n console.error(\n 'Error: one of --mnemonic (or TOON_MNEMONIC) or --secret-key ' +\n '(or TOON_SECRET_KEY / NOSTR_SECRET_KEY) is required'\n );\n process.exit(1);\n }\n if (mnemonic && secretKey) {\n console.error('Error: provide either a mnemonic or a secret key, not both');\n process.exit(1);\n }\n\n const relayPortStr =\n values['relay-port'] ?? process.env['TOON_RELAY_PORT'] ?? undefined;\n const relayPort = relayPortStr ? parseInt(relayPortStr, 10) : undefined;\n if (\n relayPort !== undefined &&\n (Number.isNaN(relayPort) || relayPort <= 0 || relayPort > 65535)\n ) {\n console.error('Error: --relay-port must be an integer between 1 and 65535');\n process.exit(1);\n }\n\n const blsPortStr =\n values['bls-port'] ?? process.env['TOON_BLS_PORT'] ?? undefined;\n const blsPort = blsPortStr ? parseInt(blsPortStr, 10) : undefined;\n if (\n blsPort !== undefined &&\n (Number.isNaN(blsPort) || blsPort <= 0 || blsPort > 65535)\n ) {\n console.error('Error: --bls-port must be an integer between 1 and 65535');\n process.exit(1);\n }\n\n const host = values.host ?? process.env['TOON_HOST'] ?? undefined;\n\n const writeHost =\n values['write-host'] ?? process.env['TOON_WRITE_HOST'] ?? undefined;\n\n const dataDir =\n values['data-dir'] ?? process.env['TOON_DATA_DIR'] ?? undefined;\n\n const devMode =\n values['dev-mode'] ??\n (process.env['TOON_DEV_MODE'] === 'true' ? true : undefined);\n\n // Paid-ephemeral verify skip (relay#85): full verification on ephemeral\n // kinds is opt-in (the skip is the default; see RelayConfig.verifyEphemeral\n // for the payment-gated invariant that makes that safe).\n const verifyEphemeral =\n values['verify-ephemeral'] ??\n (process.env['TOON_VERIFY_EPHEMERAL'] === 'true' ? true : undefined);\n\n // Verify-pool size (relay#85). 0 is a meaningful value (inline path), so\n // only an absent/blank setting falls through to the default.\n const verifyWorkersStr =\n values['verify-workers'] ?? process.env['TOON_VERIFY_WORKERS'] ?? undefined;\n const verifyWorkers = verifyWorkersStr\n ? parseInt(verifyWorkersStr, 10)\n : undefined;\n if (\n verifyWorkers !== undefined &&\n (Number.isNaN(verifyWorkers) || verifyWorkers < 0 || verifyWorkers > 256)\n ) {\n console.error('Error: --verify-workers must be an integer >= 0');\n process.exit(1);\n }\n\n // WS connection cap (relay#90). Each connection costs one fd; the server\n // logs an advisory warning at startup if the cap exceeds the soft limit.\n const maxConnectionsStr =\n values['max-connections'] ??\n process.env['TOON_MAX_CONNECTIONS'] ??\n undefined;\n const maxConnections = maxConnectionsStr\n ? parseInt(maxConnectionsStr, 10)\n : undefined;\n if (\n maxConnections !== undefined &&\n (Number.isNaN(maxConnections) || maxConnections <= 0)\n ) {\n console.error('Error: --max-connections must be a positive integer');\n process.exit(1);\n }\n\n const logWrites =\n values['log-writes'] ??\n (process.env['TOON_LOG_WRITES'] === 'true' ? true : undefined);\n\n const config: RelayConfig = {\n ...(mnemonic && { mnemonic }),\n ...(secretKey && { secretKey }),\n ...(relayPort !== undefined && { relayPort }),\n ...(blsPort !== undefined && { blsPort }),\n ...(host && { host }),\n ...(writeHost && { writeHost }),\n ...(dataDir && { dataDir }),\n ...(devMode !== undefined && { devMode }),\n ...(verifyEphemeral !== undefined && { verifyEphemeral }),\n ...(verifyWorkers !== undefined && { verifyWorkers }),\n ...(maxConnections !== undefined && { maxConnections }),\n ...(logWrites !== undefined && { logWrites }),\n };\n\n return config;\n}\n\n// ---------- Main ----------\n\nasync function main(): Promise<void> {\n const config = parseCli();\n\n console.log('\\n' + '='.repeat(50));\n console.log('TOON Relay Starting');\n console.log('='.repeat(50) + '\\n');\n\n const instance: RelayInstance = await startRelay(config);\n\n console.log('\\n' + '='.repeat(50));\n console.log('TOON Relay Ready');\n console.log('='.repeat(50));\n console.log(` Pubkey: ${instance.pubkey}`);\n console.log(` Reads: ws://localhost:${instance.config.relayPort}`);\n console.log(` Writes: http://localhost:${instance.config.blsPort}/write`);\n console.log(` Health: http://localhost:${instance.config.blsPort}/health`);\n console.log('='.repeat(50) + '\\n');\n\n // Wire graceful shutdown\n const shutdown = async (signal: string): Promise<void> => {\n console.log(`\\n[Shutdown] Received ${signal}`);\n await instance.stop();\n console.log('[Shutdown] Complete');\n process.exit(0);\n };\n\n process.on('SIGINT', () => {\n shutdown('SIGINT').catch(console.error);\n });\n process.on('SIGTERM', () => {\n shutdown('SIGTERM').catch(console.error);\n });\n}\n\nmain().catch((error: unknown) => {\n console.error('[Fatal] Startup error:', error);\n process.exit(1);\n});\n"],"mappings":"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","names":[]}