@toon-protocol/relay 2.0.2 → 2.1.0

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/nips/expiration.ts","../src/nips/deletion.ts","../src/storage/InMemoryEventStore.ts","../src/storage/SqliteEventStore.ts","../src/nips/blocklist.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/payment-attribution.ts","../src/launcher/handlers/write-handler.ts","../src/launcher/rate-limiter.ts","../src/launcher/handlers/write-ephemeral-handler.ts","../src/launcher/health.ts","../src/launcher/relay.ts"],"sourcesContent":["/**\n * Package version, surfaced on `GET /health`.\n *\n * `__RELAY_VERSION__` is replaced at build time with `package.json`'s\n * `version` -- by tsup for the shipped bundle, and by vitest for the tests\n * (both configs read the same file).\n *\n * It is injected rather than written down here because `changeset version`\n * bumps `package.json` and nothing else. A hand-maintained copy in the source\n * drifts the moment a release is cut, and then `/health` reports a version the\n * image is not running -- which is exactly what happened before this: a\n * hardcoded `0.1.0` served by a shipped `2.0.2`.\n */\ndeclare const __RELAY_VERSION__: string;\n\nexport const VERSION: string = __RELAY_VERSION__;\n","/**\n * Configuration options for the Nostr relay.\n */\nexport interface RelayServerConfig {\n /** Port to listen on (default: 7100) */\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 * Enforce NIP-40 expiration on the live broadcast path (default: true).\n *\n * The stored-history path is enforced by the EventStore; this flag covers\n * the other way an event reaches a subscriber — the fan-out of a freshly\n * written event. Both are driven from the same launcher setting so a relay\n * cannot serve an expired event on one path while hiding it on the other.\n */\n enforceExpiration?: boolean;\n}\n\n/**\n * Default relay configuration values.\n */\nexport const DEFAULT_RELAY_CONFIG: Required<RelayServerConfig> = {\n port: 7100,\n host: '0.0.0.0',\n maxConnections: 4096,\n maxSubscriptionsPerConnection: 20,\n maxFiltersPerSubscription: 10,\n databasePath: ':memory:',\n enforceExpiration: true,\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","/**\n * NIP-40 — \"Expiration Timestamp\".\n *\n * An event MAY carry `[\"expiration\", \"<unix seconds>\"]`. Past that timestamp\n * the relay SHOULD stop serving it. Until relay#137 this relay parsed no such\n * tag and enforced nothing, so an announce that said \"I am valid for ten\n * minutes\" was still handed to every discovering client a week later (two\n * such kind:10032 announces — one of them a `g.toon.relay` from an identity\n * that no longer exists — were live on devnet when this was written).\n *\n * This module is deliberately pure: parsing and the expiry predicate only.\n * WHERE enforcement happens (serve-time filtering, the background reaper) is\n * the storage layer's and launcher's business.\n *\n * @module\n */\n\nimport type { NostrEvent } from 'nostr-tools/pure';\n\n/** The NIP-40 tag name. */\nexport const EXPIRATION_TAG = 'expiration';\n\n/**\n * Read an event's NIP-40 expiration timestamp, in unix seconds.\n *\n * FAIL-OPEN on anything malformed. A tag whose value is not a non-negative\n * integer (empty, `\"soon\"`, `\"1.5\"`, `\"-1\"`, absent) yields `undefined`, i.e.\n * \"never expires\" — the same treatment an event with no tag at all gets.\n * Dropping an event because its own author wrote a bad timestamp would turn a\n * publisher-side typo into an unrecoverable read outage, and NIP-40 asks\n * relays to honour a valid expiration, not to police an invalid one.\n *\n * The FIRST syntactically valid expiration tag wins when several are present\n * (NIP-40 does not define multi-tag behaviour; the event is signed by the\n * author about the author's own event, so there is no adversary to defend\n * against here — only a need to be deterministic).\n *\n * @param event - Any Nostr event (only `tags` is read).\n * @returns Unix-seconds expiry, or undefined when the event never expires.\n */\nexport function getExpiration(event: { tags: string[][] }): number | undefined {\n for (const tag of event.tags) {\n if (tag[0] !== EXPIRATION_TAG) continue;\n const raw = tag[1];\n if (raw === undefined) continue;\n // Reject anything that is not a plain non-negative integer literal.\n // `Number()` alone would happily accept '1e9', ' 12 ', '0x10' and '1.0'.\n if (!/^\\d+$/.test(raw)) continue;\n const seconds = Number(raw);\n if (!Number.isSafeInteger(seconds)) continue;\n return seconds;\n }\n return undefined;\n}\n\n/**\n * Whether an event is past its NIP-40 expiration at `nowSeconds`.\n *\n * Events with no (or a malformed) expiration are never expired.\n *\n * @param event - The event to test.\n * @param nowSeconds - Current unix time in seconds.\n * @returns True when the event must no longer be served.\n */\nexport function isExpired(\n event: Pick<NostrEvent, 'tags'>,\n nowSeconds: number\n): boolean {\n const expiration = getExpiration(event);\n return expiration !== undefined && expiration <= nowSeconds;\n}\n","/**\n * NIP-09 — \"Event Deletion Request\".\n *\n * A kind:5 event asks the relay to stop serving events the SAME pubkey\n * published, named either by id (`e` tags) or by addressable coordinate\n * (`a` tags, `<kind>:<pubkey>:<d-identifier>`).\n *\n * THE AUTHORIZATION RULE IS THE WHOLE NIP: a deletion request may only ever\n * retract events signed by its own author. A kind:5 that names someone else's\n * event id is not an error and not a partial success — the named target is\n * simply not deleted. Anything looser turns a public relay into a surface\n * where one key can erase another's history.\n *\n * This module is pure: it parses targets and answers \"may this deletion\n * request retract this event?\". Applying that answer to stored rows (and\n * remembering it, so a re-publish cannot resurrect the event) belongs to the\n * storage layer.\n *\n * @module\n */\n\nimport type { NostrEvent } from 'nostr-tools/pure';\n\n/** The NIP-09 deletion-request kind. */\nexport const DELETION_KIND = 5;\n\n/** Whether `kind` is a NIP-09 deletion request. */\nexport function isDeletionKind(kind: number): boolean {\n return kind === DELETION_KIND;\n}\n\n/**\n * A NIP-01 addressable coordinate, `<kind>:<pubkey>:<d-identifier>`, as\n * carried by a NIP-09 `a` tag.\n */\nexport interface AddressCoordinate {\n /** Event kind the coordinate addresses. */\n kind: number;\n /** Author pubkey (64-char lowercase hex). */\n pubkey: string;\n /** The `d` tag value; the empty string when the kind carries no `d`. */\n identifier: string;\n}\n\n/** Targets named by a deletion request. */\nexport interface DeletionTargets {\n /** Event ids from `e` tags (64-char lowercase hex, de-duplicated). */\n ids: string[];\n /** Coordinates from `a` tags (de-duplicated by their raw tag value). */\n addresses: AddressCoordinate[];\n}\n\n/** 64-char lowercase hex — the canonical wire form of an id or pubkey. */\nconst HEX_64 = /^[0-9a-f]{64}$/;\n\n/**\n * Parse an `a`-tag value into a coordinate.\n *\n * @param value - Raw tag value, `<kind>:<pubkey>:<d-identifier>`.\n * @returns The coordinate, or undefined when the value is malformed.\n */\nexport function parseAddressCoordinate(\n value: string\n): AddressCoordinate | undefined {\n // The identifier itself may contain ':' — split off only the first two\n // fields and keep the remainder verbatim.\n const firstSep = value.indexOf(':');\n if (firstSep < 0) return undefined;\n const secondSep = value.indexOf(':', firstSep + 1);\n if (secondSep < 0) return undefined;\n\n const kindPart = value.slice(0, firstSep);\n const pubkey = value.slice(firstSep + 1, secondSep);\n const identifier = value.slice(secondSep + 1);\n\n if (!/^\\d+$/.test(kindPart)) return undefined;\n const kind = Number(kindPart);\n if (!Number.isSafeInteger(kind)) return undefined;\n if (!HEX_64.test(pubkey)) return undefined;\n\n return { kind, pubkey, identifier };\n}\n\n/**\n * Collect the targets a deletion request names.\n *\n * Malformed tags are skipped rather than failing the whole request: a client\n * that emits one bad `a` tag alongside three good ones still gets the three.\n *\n * @param event - A kind:5 event (the kind is not re-checked here).\n * @returns De-duplicated ids and coordinates.\n */\nexport function parseDeletionTargets(\n event: Pick<NostrEvent, 'tags'>\n): DeletionTargets {\n const ids = new Set<string>();\n const addresses = new Map<string, AddressCoordinate>();\n\n for (const tag of event.tags) {\n const value = tag[1];\n if (value === undefined) continue;\n\n if (tag[0] === 'e') {\n if (HEX_64.test(value)) ids.add(value);\n } else if (tag[0] === 'a') {\n const coordinate = parseAddressCoordinate(value);\n if (coordinate) addresses.set(value, coordinate);\n }\n }\n\n return { ids: [...ids], addresses: [...addresses.values()] };\n}\n\n/**\n * Whether `deletion` is allowed to retract `target`.\n *\n * Two conditions, both required:\n *\n * 1. Same author. This is the trust boundary — see the module comment.\n * 2. `target.created_at <= deletion.created_at`. A deletion request cannot\n * pre-emptively retract a future event; without this, one kind:5 would\n * permanently silence every later event a key publishes.\n *\n * @param target - The stored event being considered for retraction.\n * @param deletion - The kind:5 deletion request.\n * @returns True when the retraction is authorized.\n */\nexport function isDeletableBy(\n target: Pick<NostrEvent, 'pubkey' | 'created_at'>,\n deletion: Pick<NostrEvent, 'pubkey' | 'created_at'>\n): boolean {\n return (\n target.pubkey === deletion.pubkey &&\n target.created_at <= deletion.created_at\n );\n}\n","import type { NostrEvent } from 'nostr-tools/pure';\nimport type { Filter } from 'nostr-tools/filter';\nimport { matchFilter } from '../filters/index.js';\nimport { isExpired } from '../nips/expiration.js';\nimport {\n isDeletionKind,\n isDeletableBy,\n parseDeletionTargets,\n} from '../nips/deletion.js';\n\n/**\n * Construction options shared by every EventStore implementation.\n */\nexport interface EventStoreOptions {\n /**\n * Enforce NIP-40 expiration at serve time (default: true).\n *\n * When true, events past their `expiration` tag are not returned by\n * `get()` or `query()`. Set false as a KILL SWITCH: it restores the\n * pre-NIP-40 behaviour of serving every stored event forever, which is the\n * only recourse if enforcement ever starves discovery (see the launcher's\n * `enforceExpiration` docs for the failure mode this guards against).\n */\n enforceExpiration?: boolean;\n /**\n * Operator-blocked event ids (64-char lowercase hex). Blocked events are\n * refused on write and swept from storage. See `nips/blocklist.ts` for why\n * this is scoped to ids and to startup configuration.\n */\n blockedEventIds?: Iterable<string>;\n}\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 /**\n * Permanently drop events expired for longer than `graceSeconds` (NIP-40).\n * Optional: a backend may serve-filter only. Returns rows removed.\n */\n reapExpired?(nowSeconds: number, graceSeconds?: number): number;\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 /** NIP-09 id tombstones: event id -> the pubkey that requested deletion. */\n private deletedIds = new Map<string, string>();\n /** NIP-09 address tombstones: `<kind>:<pubkey>:<d>` -> deletion created_at. */\n private deletedAddresses = new Map<string, number>();\n private readonly enforceExpiration: boolean;\n private readonly blockedEventIds: ReadonlySet<string>;\n\n constructor(options: EventStoreOptions = {}) {\n this.enforceExpiration = options.enforceExpiration ?? true;\n this.blockedEventIds = new Set(options.blockedEventIds ?? []);\n }\n\n store(event: NostrEvent): void {\n if (this.blockedEventIds.has(event.id)) return;\n if (this.isRetracted(event)) return;\n\n if (isDeletionKind(event.kind)) {\n this.applyDeletion(event);\n }\n\n this.events.set(event.id, event);\n }\n\n get(id: string): NostrEvent | undefined {\n const event = this.events.get(id);\n if (!event) return undefined;\n if (this.enforceExpiration && isExpired(event, nowSeconds())) {\n return undefined;\n }\n return event;\n }\n\n query(filters: Filter[]): NostrEvent[] {\n const now = nowSeconds();\n // Get all events, minus anything NIP-40 says we may no longer serve.\n const allEvents = Array.from(this.events.values()).filter(\n (event) => !this.enforceExpiration || !isExpired(event, now)\n );\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 * Drop events expired for longer than `graceSeconds` (NIP-40).\n *\n * @param now - Current unix time in seconds.\n * @param graceSeconds - Extra time an expired event is kept.\n * @returns The number of events removed.\n */\n reapExpired(now: number, graceSeconds = 0): number {\n let removed = 0;\n for (const [id, event] of this.events) {\n if (isExpired(event, now - graceSeconds)) {\n this.events.delete(id);\n removed++;\n }\n }\n return removed;\n }\n\n /** The NIP-01 addressable coordinate of an event, `<kind>:<pubkey>:<d>`. */\n private static coordinateOf(event: NostrEvent): string {\n const identifier = event.tags.find((tag) => tag[0] === 'd')?.[1] ?? '';\n return `${event.kind}:${event.pubkey}:${identifier}`;\n }\n\n /** Whether a NIP-09 request already retracted this event (same author). */\n private isRetracted(event: NostrEvent): boolean {\n if (this.deletedIds.get(event.id) === event.pubkey) return true;\n const deletedAt = this.deletedAddresses.get(\n InMemoryEventStore.coordinateOf(event)\n );\n return deletedAt !== undefined && event.created_at <= deletedAt;\n }\n\n /** Apply a kind:5 request to the author's OWN events only. */\n private applyDeletion(deletion: NostrEvent): void {\n const targets = parseDeletionTargets(deletion);\n\n for (const id of targets.ids) {\n this.deletedIds.set(id, deletion.pubkey);\n const target = this.events.get(id);\n if (target && isDeletableBy(target, deletion)) {\n this.events.delete(id);\n }\n }\n\n for (const address of targets.addresses) {\n if (address.pubkey !== deletion.pubkey) continue;\n const coordinate = `${address.kind}:${address.pubkey}:${address.identifier}`;\n this.deletedAddresses.set(\n coordinate,\n Math.max(\n this.deletedAddresses.get(coordinate) ?? deletion.created_at,\n deletion.created_at\n )\n );\n for (const [id, target] of this.events) {\n if (\n InMemoryEventStore.coordinateOf(target) === coordinate &&\n isDeletableBy(target, deletion)\n ) {\n this.events.delete(id);\n }\n }\n }\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\n/** Current unix time in seconds. */\nfunction nowSeconds(): number {\n return Math.floor(Date.now() / 1000);\n}\n","import Database from 'better-sqlite3';\nimport type { NostrEvent } from 'nostr-tools/pure';\nimport type { Filter } from 'nostr-tools/filter';\nimport type { EventStore, EventStoreOptions } from './InMemoryEventStore.js';\nimport { getExpiration } from '../nips/expiration.js';\nimport {\n isDeletionKind,\n isDeletableBy,\n parseDeletionTargets,\n} from '../nips/deletion.js';\n\n/**\n * SQL schema for the events table.\n *\n * `expires_at` is the event's NIP-40 expiration in unix seconds, NULL when\n * the event never expires. It is a denormalized copy of the `expiration` tag\n * so that \"do not serve expired events\" is a WHERE clause the query planner\n * can use with an index, rather than a JSON parse of every candidate row —\n * serve-time expiry enforcement is on the hot read path.\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 expires_at INTEGER\n)\n`;\n\n/**\n * NIP-09 tombstones, by event id.\n *\n * A row here means \"pubkey P asked us to delete event E\". The pubkey is kept\n * so a deletion request can tombstone an id this relay has never seen (a\n * legitimate case: the delete outraces the event, or reaches a relay that\n * never carried it) WITHOUT that becoming a way to pre-block someone else's\n * event — on arrival the event is only refused when its own pubkey matches\n * the pubkey that requested the deletion.\n */\nconst DELETED_EVENTS_SCHEMA_SQL = `\nCREATE TABLE IF NOT EXISTS deleted_events (\n event_id TEXT PRIMARY KEY,\n pubkey TEXT NOT NULL,\n deleted_at INTEGER NOT NULL\n)\n`;\n\n/**\n * NIP-09 tombstones, by addressable coordinate (`<kind>:<pubkey>:<d>`).\n *\n * `deleted_at` is the deletion request's `created_at`: it retracts matching\n * events at or before that moment and no later ones, so a node can delete its\n * current announce and immediately publish a fresh one.\n */\nconst DELETED_ADDRESSES_SCHEMA_SQL = `\nCREATE TABLE IF NOT EXISTS deleted_addresses (\n coordinate TEXT PRIMARY KEY,\n deleted_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 // Partial index: only the small minority of events that expire at all.\n 'CREATE INDEX IF NOT EXISTS idx_events_expires_at ON events(expires_at) WHERE expires_at IS NOT NULL',\n];\n\n/**\n * Add `expires_at` to a pre-NIP-40 database and backfill it from the stored\n * tags.\n *\n * This runs against LIVE relay databases that already hold every event the\n * node has ever accepted, so the backfill is scoped by `tags LIKE\n * '%\"expiration\"%'` — on the devnet relay that narrows a table dominated by\n * huddle-frame events down to a handful of announces. It is a one-time cost\n * on the first boot after upgrading; subsequent boots see the column and skip\n * out immediately.\n */\nfunction migrateExpiresAtColumn(db: Database.Database): void {\n const columns = db.prepare('PRAGMA table_info(events)').all() as {\n name: string;\n }[];\n if (columns.some((column) => column.name === 'expires_at')) return;\n\n db.exec('ALTER TABLE events ADD COLUMN expires_at INTEGER');\n\n const candidates = db\n .prepare(`SELECT id, tags FROM events WHERE tags LIKE '%\"expiration\"%'`)\n .all() as { id: string; tags: string }[];\n\n const update = db.prepare('UPDATE events SET expires_at = ? WHERE id = ?');\n const backfill = db.transaction(() => {\n for (const row of candidates) {\n let tags: string[][];\n try {\n tags = JSON.parse(row.tags) as string[][];\n } catch {\n continue;\n }\n const expiresAt = getExpiration({ tags });\n if (expiresAt !== undefined) update.run(expiresAt, row.id);\n }\n });\n backfill();\n}\n\n/**\n * Initialize the database schema.\n */\nfunction initializeSchema(db: Database.Database): void {\n db.exec(SCHEMA_SQL);\n db.exec(DELETED_EVENTS_SCHEMA_SQL);\n db.exec(DELETED_ADDRESSES_SCHEMA_SQL);\n migrateExpiresAtColumn(db);\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 private tombstoneIdStmt: Database.Statement;\n private getTombstoneStmt: Database.Statement;\n private tombstoneAddressStmt: Database.Statement;\n private getAddressTombstoneStmt: Database.Statement;\n private deleteExpiredStmt: Database.Statement;\n private readonly enforceExpiration: boolean;\n private readonly blockedEventIds: ReadonlySet<string>;\n\n /**\n * Create a new SqliteEventStore.\n * @param dbPath - Path to the database file. Use ':memory:' for in-memory database.\n * @param options - Expiry-enforcement and operator-blocklist settings.\n */\n constructor(dbPath = ':memory:', options: EventStoreOptions = {}) {\n this.enforceExpiration = options.enforceExpiration ?? true;\n this.blockedEventIds = new Set(options.blockedEventIds ?? []);\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, expires_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, expires_at)\n VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)\n `);\n\n this.getStmt = this.db.prepare('SELECT * FROM events WHERE id = ?');\n\n this.tombstoneIdStmt = this.db.prepare(\n 'INSERT OR REPLACE INTO deleted_events (event_id, pubkey, deleted_at) VALUES (?, ?, ?)'\n );\n this.getTombstoneStmt = this.db.prepare(\n 'SELECT pubkey FROM deleted_events WHERE event_id = ?'\n );\n // A later deletion request must not lower an earlier one's watermark,\n // so keep the MAX of the two `created_at` values.\n this.tombstoneAddressStmt = this.db.prepare(\n `INSERT INTO deleted_addresses (coordinate, deleted_at) VALUES (?, ?)\n ON CONFLICT(coordinate) DO UPDATE SET deleted_at = MAX(deleted_at, excluded.deleted_at)`\n );\n this.getAddressTombstoneStmt = this.db.prepare(\n 'SELECT deleted_at FROM deleted_addresses WHERE coordinate = ?'\n );\n this.deleteExpiredStmt = this.db.prepare(\n 'DELETE FROM events WHERE expires_at IS NOT NULL AND expires_at <= ?'\n );\n\n // Sweep any operator-blocked ids that this database already holds.\n // Refusing them on write only helps for events that have not arrived\n // yet; the whole point of the blocklist is litter that is ALREADY\n // stored (see nips/blocklist.ts).\n if (this.blockedEventIds.size > 0) {\n const purge = this.db.prepare('DELETE FROM events WHERE id = ?');\n const purgeAll = this.db.transaction(() => {\n for (const id of this.blockedEventIds) purge.run(id);\n });\n purgeAll();\n }\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 *\n * Handles replaceable and parameterized replaceable events according to\n * NIP-01, applies NIP-09 deletion requests, and refuses events the operator\n * has blocked or that a previous NIP-09 request already retracted.\n */\n store(event: NostrEvent): void {\n try {\n // --- Operator blocklist (see nips/blocklist.ts) ---\n if (this.blockedEventIds.has(event.id)) return;\n\n // --- NIP-09: refuse re-publication of an already-deleted event ---\n if (this.isRetracted(event)) return;\n\n const tagsJson = JSON.stringify(event.tags);\n const receivedAt = Math.floor(Date.now() / 1000);\n\n // --- NIP-09: a kind:5 retracts the author's OWN events, then persists\n // itself as an ordinary event so it can propagate to other relays. ---\n if (isDeletionKind(event.kind)) {\n this.applyDeletion(event);\n }\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.runInsert(this.insertOrIgnoreStmt, event, tagsJson, receivedAt);\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.runInsert(this.insertStmt, event, tagsJson, receivedAt);\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.runInsert(this.insertStmt, event, tagsJson, receivedAt);\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.runInsert(this.insertStmt, event, tagsJson, receivedAt);\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.runInsert(this.insertStmt, event, tagsJson, receivedAt);\n }\n }\n\n /**\n * Bind an event to one of the prepared INSERT statements.\n *\n * The `expires_at` column is derived here, at the single point every write\n * funnels through, so no insert path can forget it.\n */\n private runInsert(\n stmt: Database.Statement,\n event: NostrEvent,\n tagsJson: string,\n receivedAt: number\n ): void {\n stmt.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 getExpiration(event) ?? null\n );\n }\n\n /**\n * The NIP-01 addressable coordinate of an event, `<kind>:<pubkey>:<d>`.\n * The `d` value is the empty string for events that carry no `d` tag —\n * which is every kind:10032 announce on the network today.\n */\n private static coordinateOf(event: NostrEvent): string {\n return `${event.kind}:${event.pubkey}:${getDTagValue(event.tags)}`;\n }\n\n /**\n * Whether a NIP-09 deletion request already retracted this event, so it\n * must not be re-admitted.\n *\n * The id tombstone only bites when the arriving event's OWN pubkey matches\n * the pubkey that asked for the deletion; otherwise anyone could pre-block\n * an id they merely predicted. Address tombstones already carry the\n * author's pubkey inside the coordinate.\n */\n private isRetracted(event: NostrEvent): boolean {\n const tombstone = this.getTombstoneStmt.get(event.id) as\n | { pubkey: string }\n | undefined;\n if (tombstone && tombstone.pubkey === event.pubkey) return true;\n\n const address = this.getAddressTombstoneStmt.get(\n SqliteEventStore.coordinateOf(event)\n ) as { deleted_at: number } | undefined;\n return address !== undefined && event.created_at <= address.deleted_at;\n }\n\n /**\n * Apply a NIP-09 deletion request: remove the author's own targeted events\n * and record tombstones so a re-publish cannot resurrect them.\n *\n * Every statement here is scoped by `pubkey = <the requester>`, which is\n * what makes a cross-author deletion a no-op rather than a privilege.\n */\n private applyDeletion(deletion: NostrEvent): void {\n const targets = parseDeletionTargets(deletion);\n if (targets.ids.length === 0 && targets.addresses.length === 0) return;\n\n const deleteById = this.db.prepare(\n 'DELETE FROM events WHERE id = ? AND pubkey = ? AND created_at <= ?'\n );\n\n const apply = this.db.transaction(() => {\n for (const id of targets.ids) {\n // Tombstone unconditionally (the event may not have arrived yet) —\n // isRetracted() enforces the same-author rule on arrival.\n this.tombstoneIdStmt.run(id, deletion.pubkey, deletion.created_at);\n deleteById.run(id, deletion.pubkey, deletion.created_at);\n }\n\n for (const address of targets.addresses) {\n // A coordinate naming somebody else's pubkey is ignored outright.\n if (address.pubkey !== deletion.pubkey) continue;\n const coordinate = `${address.kind}:${address.pubkey}:${address.identifier}`;\n this.tombstoneAddressStmt.run(coordinate, deletion.created_at);\n for (const row of this.findByCoordinate(address.kind, address.pubkey)) {\n if (\n getDTagValue(row.tags) === address.identifier &&\n isDeletableBy(row, deletion)\n ) {\n this.db.prepare('DELETE FROM events WHERE id = ?').run(row.id);\n }\n }\n }\n });\n apply();\n }\n\n /**\n * Rows for a (kind, pubkey) pair with their parsed tags, so the caller can\n * compare `d` values in code. SQL cannot distinguish `[\"d\",\"\"]` from a\n * missing `d` tag, and both mean \"the empty identifier\".\n */\n private findByCoordinate(\n kind: number,\n pubkey: string\n ): { id: string; pubkey: string; created_at: number; tags: string[][] }[] {\n const rows = this.db\n .prepare(\n 'SELECT id, pubkey, created_at, tags FROM events WHERE pubkey = ? AND kind = ?'\n )\n .all(pubkey, kind) as {\n id: string;\n pubkey: string;\n created_at: number;\n tags: string;\n }[];\n return rows.map((row) => ({\n id: row.id,\n pubkey: row.pubkey,\n created_at: row.created_at,\n tags: JSON.parse(row.tags) as string[][],\n }));\n }\n\n /**\n * NIP-40 reaper: permanently delete events whose expiration is further than\n * `graceSeconds` in the past.\n *\n * The grace window is the safety net for enforcement itself. Serve-time\n * filtering is instantly reversible (flip `enforceExpiration` off and every\n * still-present event is served again); a DELETE is not. Keeping recently\n * expired events on disk for a while means an operator who discovers that\n * enforcement broke discovery can undo it without having lost the data.\n *\n * @param nowSeconds - Current unix time in seconds.\n * @param graceSeconds - Extra time to keep an expired event on disk.\n * @returns The number of rows deleted.\n */\n reapExpired(nowSeconds: number, graceSeconds = 0): number {\n try {\n const result = this.deleteExpiredStmt.run(nowSeconds - graceSeconds);\n return result.changes;\n } catch (error) {\n throw new RelayError(\n `Failed to reap expired events: ${error instanceof Error ? error.message : String(error)}`,\n 'STORAGE_ERROR'\n );\n }\n }\n\n /**\n * Retrieve an event by its ID.\n *\n * Returns undefined for an event that is past its NIP-40 expiration while\n * enforcement is on, even though the row may still be on disk inside the\n * reaper's grace window.\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 expires_at: number | null;\n }\n | undefined;\n\n if (!row) {\n return undefined;\n }\n\n if (\n this.enforceExpiration &&\n row.expires_at !== null &&\n row.expires_at <= Math.floor(Date.now() / 1000)\n ) {\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 // NIP-40 enforcement is a WHERE clause, not a post-filter, so that a\n // filter's `limit` is applied to the events actually served. Post-filtering\n // would quietly return fewer than `limit` results and, worse, let a page\n // of expired announces displace live ones.\n const params: unknown[] = [];\n let liveClause = '';\n if (this.enforceExpiration) {\n liveClause = '(expires_at IS NULL OR expires_at > ?)';\n params.push(Math.floor(Date.now() / 1000));\n }\n\n if (filters.length === 0) {\n return {\n sql:\n `SELECT * FROM events${liveClause ? ` WHERE ${liveClause}` : ''}` +\n ' ORDER BY created_at DESC',\n params,\n };\n }\n\n const conditions: string[] = [];\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 const whereParts: string[] = [];\n if (liveClause) whereParts.push(liveClause);\n if (conditions.length > 0) whereParts.push(`(${conditions.join(' OR ')})`);\n\n let sql = 'SELECT * FROM events';\n if (whereParts.length > 0) {\n sql += ` WHERE ${whereParts.join(' AND ')}`;\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 * Operator event blocklist — the escape hatch for litter that NEITHER NIP\n * can clear.\n *\n * WHY THIS EXISTS. NIP-01 replacement needs the author's key. NIP-09 deletion\n * needs the author's key. When a node publishes an announce and then the key\n * is lost — a throwaway proof rig wiped off an operator workstation, say —\n * the announce is unretractable BY CONSTRUCTION, and if it also carries no\n * NIP-40 `expiration` tag then nothing in the protocol will ever remove it.\n * That is exactly the state devnet was in: a kind:5094-era swap maker\n * advertising `g.toon.swap.sol` at `ws://127.0.0.1:3401` — a loopback address\n * that resolves to whatever machine READS it — with no expiry and no key.\n *\n * WHY IT IS SHAPED LIKE THIS. Any mechanism that lets an operator remove\n * other people's events is a censorship surface, so the scope is drawn as\n * narrowly as the job allows:\n *\n * - **Event ids only, never pubkeys.** Blocking a pubkey silences an\n * identity's entire past and future output with one line of config.\n * Blocking a 64-hex id removes exactly one event that the operator had to\n * name explicitly, having already seen it. A key that is still alive can\n * simply publish again, so this cannot be used to suppress a live\n * participant — only to sweep a specific dead artifact.\n * - **Config at startup, not an API.** There is no admin endpoint, no\n * authenticated mutation, nothing network-reachable. The list arrives as\n * process configuration (`TOON_BLOCKED_EVENT_IDS`) and changing it means\n * restarting the process with a changed deployment — an act that lands in\n * a git history and a deploy log rather than in an unlogged HTTP call.\n * - **Loud.** The launcher prints every blocked id at startup. A relay that\n * is withholding events should say so on every boot.\n *\n * A blocked id is refused on write, filtered on read, and swept from the\n * database — but the block still lives only in ONE relay's configuration.\n * Other relays serving the same event are unaffected, which is the correct\n * outcome: this is an operator declining to carry a specific artifact, not a\n * protocol-level retraction.\n *\n * @module\n */\n\n/** 64-char lowercase hex — the canonical wire form of an event id. */\nconst HEX_64 = /^[0-9a-f]{64}$/;\n\n/**\n * Parse an operator blocklist from its configuration string.\n *\n * Accepts a comma- and/or whitespace-separated list of 64-char hex event ids.\n * Case is normalized to lowercase. Entries that are not well-formed event ids\n * are reported separately rather than silently dropped: a typo in a blocklist\n * must be visible, because its failure mode (an event the operator believes\n * is blocked but is still being served) is silent otherwise.\n *\n * @param raw - Raw configuration value, or undefined.\n * @returns The accepted ids and any rejected entries.\n */\nexport function parseBlockedEventIds(raw: string | undefined): {\n ids: string[];\n invalid: string[];\n} {\n const ids = new Set<string>();\n const invalid: string[] = [];\n\n for (const entry of (raw ?? '').split(/[\\s,]+/)) {\n if (entry === '') continue;\n const normalized = entry.toLowerCase();\n if (HEX_64.test(normalized)) {\n ids.add(normalized);\n } else {\n invalid.push(entry);\n }\n }\n\n return { ids: [...ids], invalid };\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';\nimport { isExpired } from '../nips/expiration.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 // NIP-40 (relay#137): an event that arrives already past its own\n // `expiration` is never fanned out. Without this, live subscribers would\n // receive an event that a REQ one second later would refuse to serve.\n if (\n this.config.enforceExpiration &&\n isExpired(event, Math.floor(Date.now() / 1000))\n ) {\n return;\n }\n\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 * Free ephemeral write lane (relay#129, `POST /write-ephemeral`). Always\n * present -- the lane is always mounted -- and static for the process\n * lifetime: it has no payment gate, so these bounds ARE its admission\n * control, and the acceptance criteria requires them to be visible here\n * alongside the startup log line.\n */\n ephemeralWriteLane: {\n enabled: true;\n rateLimit: { maxRequests: number; windowMs: number };\n maxBodyBytes: number;\n };\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 + ephemeral-lane bounds surfaced in\n * the snapshot (relay#129).\n */\nexport function createMetricsRegistry(info: {\n verifyImplementation: string;\n verifyWorkers: number;\n ephemeralRateLimit: { maxRequests: number; windowMs: number };\n ephemeralMaxBodyBytes: 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 ephemeralWriteLane: {\n enabled: true,\n rateLimit: info.ephemeralRateLimit,\n maxBodyBytes: info.ephemeralMaxBodyBytes,\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 * The connector's payment statement, as read off a delivery to `POST /write`.\n *\n * A terminating connector states three headers on a delivery whose payment it\n * verified at its OWN client edge (`toon-protocol/connector` ADR 0040):\n *\n * | header | value |\n * | --------------- | ----------------------------------------------------------- |\n * | `X-TOON-Payer` | `evm:0x<64 hex>` or `solana:<base58>` -- the client CHANNEL |\n * | | key whose covering claim that connector verified |\n * | `X-TOON-Amount` | the route's flat price (ADR 0020), decimal, base units |\n * | `X-TOON-Chain` | that key's namespace -- `evm` or `solana` |\n *\n * ! ABSENCE IS NOT \"UNPAID\" ! The headers are present ONLY when this\n * connector was the hop that took the payment and the route's price is\n * non-zero. They are absent -- not empty -- on a peer-wire arrival, a\n * forwarded packet, and every `price = 0` route (which is why the free\n * ephemeral lane never sees them). A handler that read absence as \"nobody\n * paid\" would reject exactly the deliveries a longer path produced.\n *\n * The relay does not, and cannot, re-validate any of this: it holds no chain\n * state and speaks no ILP. The connector's statement IS the trust model. What\n * this module adds is that a malformed statement is treated as no statement\n * at all -- a garbled value from a stale or hostile caller becomes `undefined`\n * rather than something the relay records and echoes as fact.\n *\n * History worth not repeating: relay#122 removed the earlier reading of these\n * same header NAMES, correctly, because the TypeScript-era connector set\n * `X-TOON-Payer` to the PREVIOUS HOP -- which on any path longer than one hop\n * named the wrong party. ADR 0040's successor is a chain-verified channel key\n * that is never stated by a hop that did not take the payment, so the value\n * now means what its name says (relay#133).\n *\n * @module\n */\n\nimport type { Context } from 'hono';\n\n/** A payment the terminating connector states it verified. */\nexport interface PaymentAttribution {\n /** The client channel key, namespaced: `evm:0x<64 hex>` / `solana:<base58>`. */\n payer: string;\n /** The route's flat price in base units, decimal. */\n amount: string;\n /** The payer key's namespace. */\n chain: 'evm' | 'solana';\n}\n\n/** `evm:` + exactly 64 lower-case hex characters, `0x`-prefixed. */\nconst EVM_PAYER = /^evm:0x[0-9a-f]{64}$/;\n/** `solana:` + a base58 public key (no 0, O, I or l in the alphabet). */\nconst SOLANA_PAYER = /^solana:[1-9A-HJ-NP-Za-km-z]{32,44}$/;\n/** Base units are a decimal integer -- no sign, no exponent, no separators. */\nconst AMOUNT = /^[0-9]+$/;\n\n/**\n * Read the connector's payment statement off a request.\n *\n * Returns `undefined` unless ALL THREE headers are present, individually\n * well-formed, and mutually consistent (the payer's namespace must be the\n * chain it claims). Anything else -- one header, two headers, a payer from a\n * chain the `X-TOON-Chain` header disagrees with, a non-decimal amount -- is\n * discarded whole. Partial attribution is worse than none: it would record\n * half a fact as if it were the whole one.\n *\n * This never rejects the request. A caller that states nothing, or states\n * nonsense, still gets its write handled on the merits of the event itself;\n * the payment gate is upstream and has already run by the time anything\n * reaches this process.\n */\nexport function readPaymentAttribution(\n c: Context\n): PaymentAttribution | undefined {\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 (!payer || !amount || !chain) return undefined;\n if (chain !== 'evm' && chain !== 'solana') return undefined;\n if (!AMOUNT.test(amount)) return undefined;\n\n const payerMatchesChain =\n chain === 'evm' ? EVM_PAYER.test(payer) : SOLANA_PAYER.test(payer);\n if (!payerMatchesChain) return undefined;\n\n return { payer, amount, chain };\n}\n","/**\n * Write handler for @toon-protocol/relay.\n *\n * Exposes a plain-HTTP write surface that accepts an event-as-JSON, verifies\n * ONLY the event 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\n * surface it is already proven paid.\n *\n * What the terminator DOES state, it records. A terminating connector states\n * `X-TOON-Payer` / `X-TOON-Amount` / `X-TOON-Chain` on a delivery whose\n * payment it verified at its own client edge (`toon-protocol/connector`\n * ADR 0040, relay#133); the handler reads them, echoes a well-formed triple\n * back on the 200, and treats their absence as \"this hop was not the one\n * paid\" -- NEVER as \"unpaid\". See payment-attribution.ts for the contract and\n * for why the same header names were right to distrust before ADR 0040.\n *\n * Flow:\n * 1. Parse JSON body `{ event }` -> 400 on malformed/missing event\n * 2. 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 * 3. 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 * 4. 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 * 5. Respond 200 with the event id, storedAt timestamp, and -- when the\n * connector stated one -- the payment it attributed the write to\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';\nimport { readPaymentAttribution } from './payment-attribution.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 // The connector's own statement about the payment it verified for this\n // delivery, if it made one (ADR 0040). Read before verification so the\n // log line below carries it even for a write that goes on to fail.\n const payment = readPaymentAttribution(c);\n\n if (logWrites) {\n const attribution = payment\n ? ` payer=${payment.payer} amount=${payment.amount} chain=${payment.chain}`\n : '';\n console.log(`[write] event=${event.id} handler=write${attribution}`);\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 ---\n //\n // `payment` is present only when the connector stated a well-formed\n // triple; the key is omitted entirely otherwise, so a client can never\n // read a null/empty payer as an assertion that nobody paid.\n return c.json(\n {\n eventId: event.id,\n storedAt: Math.floor(Date.now() / 1000),\n ...(payment ? { payment } : {}),\n },\n 200\n );\n },\n };\n}\n","/**\n * A minimal in-memory sliding-window rate limiter (relay#129).\n *\n * Built for the free ephemeral write lane (`POST /write-ephemeral`,\n * handlers/write-ephemeral-handler.ts): with no payment gate, request volume\n * is the only admission control that lane has, so every request must be\n * checked against a per-key budget before any other work happens.\n * Dependency-free and small enough to own outright rather than pull in a\n * library, matching the shape the rest of the launcher already uses for\n * self-contained stateful helpers (crypto/verify-pool.ts, launcher/metrics.ts).\n *\n * Sliding-window LOG, not a fixed-window counter: a fixed window lets a\n * caller burst up to 2x the limit across a window boundary (all of window\n * N's budget at :59, all of window N+1's budget at :00). The log costs one\n * array per active key and O(requests-in-window) work per call, which is\n * fine at the request volumes a bound like this is meant to police.\n *\n * @module\n */\n\n/** Options for {@link createRateLimiter}. */\nexport interface RateLimiterOptions {\n /** Max requests allowed per key within the trailing window. */\n maxRequests: number;\n /** Trailing window length in milliseconds. */\n windowMs: number;\n /** Clock override for deterministic tests (default: `Date.now`). */\n now?: () => number;\n}\n\n/** A live rate limiter. One instance per bound; keys are caller-defined. */\nexport interface RateLimiter {\n /**\n * Check `key`'s budget. Returns `true` and records this call towards the\n * budget when the key is under its limit; returns `false` (and records\n * nothing) when the key is over budget. Never throws.\n */\n allow(key: string): boolean;\n}\n\n/**\n * Create a sliding-window rate limiter.\n *\n * @param options - Bound + optional clock override.\n */\nexport function createRateLimiter(options: RateLimiterOptions): RateLimiter {\n const { maxRequests, windowMs } = options;\n const now = options.now ?? Date.now;\n const hits = new Map<string, number[]>();\n\n return {\n allow(key: string): boolean {\n const t = now();\n const windowStart = t - windowMs;\n // Drop hits that aged out of the trailing window, then keep the pruned\n // log either way -- a rejected call still shrinks the key's entry.\n const timestamps = (hits.get(key) ?? []).filter(\n (ts) => ts >= windowStart\n );\n hits.set(key, timestamps);\n\n if (timestamps.length >= maxRequests) {\n return false;\n }\n\n timestamps.push(t);\n return true;\n },\n };\n}\n","/**\n * Free ephemeral write handler for @toon-protocol/relay (relay#129, ephemeral\n * epic toon-meta#393 E2).\n *\n * A SECOND write surface, `POST /write-ephemeral`, distinct from the paid\n * `POST /write` (write-handler.ts). It exists because the connector cannot\n * carry two prices on one `handler_url` (`ConflictingHandlerPrice`,\n * connector `route.rs:378-399`) -- a free lane needs its own endpoint,\n * terminated by its own zero-priced route in the deploy config (see\n * `deploy/connector.toml`'s `g.toon.relay.ephemeral` route).\n *\n * Differences from the paid handler, all deliberate:\n *\n * - Accepts ONLY ephemeral kinds (NIP-16, 20000 <= kind < 30000) -- anything\n * else is a 400. Persistent kinds have no business on a free lane; letting\n * them through would be a free ride around pay-to-write.\n * - NEVER stores. Ephemeral kinds are never persisted on the paid path\n * either (NIP-16; write-handler.ts), so there is nothing this lane would\n * ever write to an EventStore -- it does not take one as a dependency.\n * - Schnorr verification is ALWAYS FULL, with no skip and no config knob to\n * add one. The paid path's ephemeral verify-skip (write-handler.ts) is\n * safe ONLY because payment is the admission gate; this lane has no\n * payment gate, so signature verification IS its only defense against\n * forged-signature spam before the bounds below even apply. Reusing that\n * skip here would let anyone broadcast garbage to every subscriber for\n * free -- exactly the case write-handler.ts's own invariant comment warns\n * against.\n * - Bounds, because free + broadcast = spam surface: a per-key sliding-\n * window rate limit (rate-limiter.ts) and a request-body size cap, both\n * config-gated with conservative defaults (see\n * {@link EphemeralWriteHandlerConfig}).\n *\n * Flow:\n * 1. Rate-limit check, keyed by remote address (falling back to a shared\n * bucket when connection info is unavailable -- see `defaultClientKey`)\n * -- BEFORE any body is read, so a rate-limited caller costs as little\n * work as possible -> 429 over budget.\n * 2. Body-size check against `maxBodyBytes` -- BEFORE JSON parsing, so an\n * oversized payload is never deserialized -> 413 too large.\n * 3. Parse JSON body `{ event }` -> 400 on malformed/missing event.\n * 4. Reject non-ephemeral kinds -> 400.\n * 5. Full schnorr verification, never skipped -> 422 on invalid signature.\n * 6. Fire the optional onBroadcast callback (the live-broadcast hook).\n * Nothing is ever stored.\n * 7. Respond 200 with the event id.\n *\n * @module\n */\n\nimport type { Context } from 'hono';\nimport { getConnInfo } from '@hono/node-server/conninfo';\nimport type { NostrEvent } from 'nostr-tools/pure';\nimport { verifyEventSignature } from '../../crypto/index.js';\nimport { createRateLimiter } from '../rate-limiter.js';\nimport type { RateLimiter } from '../rate-limiter.js';\n\n/**\n * Whether `kind` is ephemeral per NIP-16 (20000 <= kind < 30000) -- the ONLY\n * kinds this lane accepts.\n */\nfunction isEphemeralKind(kind: number): boolean {\n return kind >= 20000 && kind < 30000;\n}\n\n/**\n * Conservative default rate-limit bound: 200 requests per 10-second window\n * per key. Sized for presence/typing traffic (the epic's motivating\n * workload, toon-meta#393), not huddle-frame rates -- that traffic stays on\n * the paid path. Deliberately generous rather than tight: this is a spam\n * ceiling, not a fairness scheduler, and a false-positive reject on\n * legitimate ephemeral traffic (a dropped typing indicator) is silent and\n * has no client-side retry signal.\n */\nexport const DEFAULT_EPHEMERAL_RATE_LIMIT: {\n maxRequests: number;\n windowMs: number;\n} = {\n maxRequests: 200,\n windowMs: 10_000,\n};\n\n/**\n * Conservative default body-size cap in bytes. Ephemeral events on this lane\n * (presence heartbeats, typing indicators) are small JSON; 8 KiB comfortably\n * covers a signed Nostr event with generous tag/content headroom while\n * bounding worst-case memory per request on an unpaid surface.\n */\nexport const DEFAULT_EPHEMERAL_MAX_BODY_BYTES = 8 * 1024;\n\n/** Configuration for the ephemeral write handler. */\nexport interface EphemeralWriteHandlerConfig {\n /**\n * Signature verifier. Defaults to the inline `verifyEventSignature`; the\n * launcher injects the worker-pool verifier (crypto/verify-pool.ts),\n * shared with the paid handler, so verify bursts run off the event loop\n * (relay#85). May resolve asynchronously -- the handler awaits it.\n *\n * There is NO devMode/skip option here, unlike the paid handler --\n * verification on this lane is always full (see the module doc).\n */\n verifyEvent?: (event: NostrEvent) => Promise<boolean> | boolean;\n /** Optional callback fired after an event passes all checks (broadcast hook). */\n onBroadcast?: (event: NostrEvent) => void;\n /** Log one line per accepted write (default: false), matching write-handler.ts. */\n logWrites?: boolean;\n /** Rate-limit bound (default {@link DEFAULT_EPHEMERAL_RATE_LIMIT}). */\n rateLimit?: { maxRequests: number; windowMs: number };\n /** Request body size cap in bytes (default {@link DEFAULT_EPHEMERAL_MAX_BODY_BYTES}). */\n maxBodyBytes?: number;\n /**\n * Test-only: inject a rate limiter directly (e.g. with a fake clock)\n * instead of letting the handler build one from `rateLimit`.\n */\n rateLimiter?: RateLimiter;\n /**\n * Test-only: override how a request is keyed for rate limiting. Defaults\n * to `defaultClientKey` (remote address via `getConnInfo`, falling back to\n * a shared bucket).\n */\n getClientKey?: (c: Context) => string;\n}\n\n/** Ephemeral write handler instance. */\nexport interface EphemeralWriteHandler {\n /** Handle a plain-HTTP ephemeral write request. */\n handleWrite(c: Context): Promise<Response>;\n}\n\n/**\n * Default rate-limit key: the caller's remote address via `@hono/node-server`'s\n * connection-info helper. Falls back to a single shared bucket ('unknown')\n * when connection info isn't available -- e.g. a bare `app.fetch()` call in\n * unit tests, or any transport that doesn't expose a socket. That fallback\n * degrades to a global cap rather than an unbounded one, which is the safe\n * direction for a rate limiter to fail.\n *\n * In production this handler sits behind the connector (deploy/docker-compose.yml\n * never host-publishes :3100), so the observed remote address is the\n * connector's own -- the connector does not forward per-client identity to\n * the relay (`toon-protocol/connector` ADR 0006/0036) -- making this a\n * de facto lane-wide cap in the canonical deploy, not a true per-end-user\n * one. That is a known, accepted shape: the bound exists to cap the blast\n * radius of the free lane as a whole, not to fairness-schedule individual\n * end users.\n *\n * @internal Exported for unit testing.\n */\nexport function defaultClientKey(c: Context): string {\n try {\n return getConnInfo(c).remote.address ?? 'unknown';\n } catch {\n return 'unknown';\n }\n}\n\n/**\n * Create the ephemeral write handler.\n *\n * @param config - Handler configuration.\n * @returns An EphemeralWriteHandler with a handleWrite method.\n */\nexport function createEphemeralWriteHandler(\n config: EphemeralWriteHandlerConfig = {}\n): EphemeralWriteHandler {\n const logWrites = config.logWrites ?? false;\n const verifyEvent = config.verifyEvent ?? verifyEventSignature;\n const maxBodyBytes = config.maxBodyBytes ?? DEFAULT_EPHEMERAL_MAX_BODY_BYTES;\n const rateLimiter =\n config.rateLimiter ??\n createRateLimiter(config.rateLimit ?? DEFAULT_EPHEMERAL_RATE_LIMIT);\n const getClientKey = config.getClientKey ?? defaultClientKey;\n\n return {\n async handleWrite(c: Context): Promise<Response> {\n // --- Rate limit (before any body is read) ---\n if (!rateLimiter.allow(getClientKey(c))) {\n return c.json({ error: 'Rate limit exceeded' }, 429);\n }\n\n // --- Size cap (before JSON parsing) ---\n const contentLengthHeader = c.req.header('content-length');\n if (\n contentLengthHeader !== undefined &&\n Number(contentLengthHeader) > maxBodyBytes\n ) {\n return c.json({ error: 'Request body too large' }, 413);\n }\n\n let rawBody: string;\n try {\n rawBody = await c.req.text();\n } catch {\n return c.json({ error: 'Invalid request body' }, 400);\n }\n if (Buffer.byteLength(rawBody, 'utf8') > maxBodyBytes) {\n return c.json({ error: 'Request body too large' }, 413);\n }\n\n // --- Parse request body ---\n let body: { event?: NostrEvent };\n try {\n body = JSON.parse(rawBody) 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 // --- Ephemeral-only gate: reject anything else before spending a\n // verify on it. A non-ephemeral kind on this lane would be a free ride\n // around pay-to-write. ---\n if (!isEphemeralKind(event.kind)) {\n return c.json(\n {\n error:\n 'Only ephemeral kinds (20000-29999) are accepted on this lane',\n },\n 400\n );\n }\n\n if (logWrites) {\n console.log(`[write] event=${event.id} handler=write-ephemeral`);\n }\n\n // --- Full schnorr verification -- NEVER skipped on this lane (relay#129) ---\n if (!(await verifyEvent(event))) {\n return c.json({ error: 'Invalid event signature' }, 422);\n }\n\n // --- Broadcast only; nothing is ever stored (NIP-16) ---\n config.onBroadcast?.(event);\n\n return c.json(\n {\n eventId: event.id,\n broadcastAt: Math.floor(Date.now() / 1000),\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 * Three surfaces:\n *\n * - `POST /write` (TOON_BLS_PORT, default 3100): accepts `{ event }` as JSON.\n * By the time a request reaches this surface it is already proven paid;\n * the terminating connector asserts nothing about that payment to this\n * relay -- no payer, amount, or chain (`toon-protocol/connector` ADR\n * 0036) -- so the handler 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 * - `POST /write-ephemeral` (same port, relay#129): the FREE ephemeral\n * write lane. Accepts only ephemeral kinds (NIP-16, 20000 <= kind <\n * 30000), always runs FULL schnorr verification (no skip -- this lane\n * has no payment gate), and never stores. Bounded by a per-key rate\n * limit and a body-size cap (see `ephemeralRateLimit` /\n * `ephemeralMaxBodyBytes`). Terminated at the connector by its own\n * zero-priced route (`deploy/connector.toml`'s `g.toon.relay.ephemeral`).\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 { NostrEvent } from 'nostr-tools/pure';\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 {\n createEphemeralWriteHandler,\n DEFAULT_EPHEMERAL_RATE_LIMIT,\n DEFAULT_EPHEMERAL_MAX_BODY_BYTES,\n} from './handlers/write-ephemeral-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 // --- Retention (NIP-40 / NIP-09 / operator blocklist, relay#137) ---\n\n /**\n * Enforce NIP-40 expiration (default: true; env: TOON_ENFORCE_EXPIRATION).\n *\n * When true, an event past its `expiration` tag is not served from history\n * and not fanned out live. Setting this false is the KILL SWITCH back to\n * the pre-relay#137 behaviour of serving everything forever.\n *\n * ! READ THIS BEFORE ASSUMING THE DEFAULT IS FREE !\n * On the TOON devnet the only events carrying an `expiration` tag are\n * kind:10032 node announces, published with a 600s TTL and refreshed by a\n * shell loop every 240s — 2.5 refresh periods of margin, and the loop's\n * failure backoff (5s doubling, capped at 240s) needs SEVEN consecutive\n * failed publishes before an announce goes past its expiry. That margin is\n * comfortable, but it is a margin, not a guarantee: the store and swap\n * announce loops PAY for each republish out of a payment channel, so a\n * drained channel makes every republish fail indefinitely. Before this\n * flag existed, that failure degraded to \"a stale announce is still\n * served\"; with enforcement on, it becomes \"the node vanishes from\n * discovery\". That is the intended semantics — a node that cannot afford\n * to say it is alive should not be advertised — but it is a real change in\n * blast radius, and this flag is how an operator buys time.\n */\n enforceExpiration?: boolean;\n /**\n * How long an expired event is kept on disk before the reaper deletes it\n * (default: 86400 = 24h; env: TOON_EXPIRATION_REAP_GRACE_SECONDS).\n *\n * Serve-time filtering is reversible; a DELETE is not. The grace window is\n * what makes flipping `enforceExpiration` back off an actual recovery\n * rather than an apology. Set 0 to reap the moment an event expires.\n */\n expirationReapGraceSeconds?: number;\n /**\n * How often the reaper sweeps (default: 3600 = hourly; env:\n * TOON_EXPIRATION_REAP_INTERVAL_SECONDS). 0 disables reaping entirely;\n * serve-time filtering is unaffected.\n */\n expirationReapIntervalSeconds?: number;\n /**\n * Operator-blocked event ids (env: TOON_BLOCKED_EVENT_IDS, comma-separated).\n *\n * The narrow escape hatch for an event that neither NIP-01 replacement nor\n * NIP-09 deletion can reach because its author's key is gone. Ids only,\n * never pubkeys; startup configuration only, never an API. See\n * `nips/blocklist.ts` for the full reasoning and the censorship hazard it\n * is drawn around.\n */\n blockedEventIds?: string[];\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 // --- Free ephemeral write lane (relay#129) ---\n\n /**\n * Per-key sliding-window rate limit for `POST /write-ephemeral` (default:\n * 200 requests / 10s; env: TOON_EPHEMERAL_RATE_LIMIT /\n * TOON_EPHEMERAL_RATE_WINDOW_MS). This lane has no payment gate, so this\n * bound (plus `ephemeralMaxBodyBytes`) IS its admission control -- see\n * `EphemeralWriteHandlerConfig` for the full reasoning.\n */\n ephemeralRateLimit?: { maxRequests: number; windowMs: number };\n /**\n * Request-body size cap in bytes for `POST /write-ephemeral` (default:\n * 8192; env: TOON_EPHEMERAL_MAX_BODY_BYTES).\n */\n ephemeralMaxBodyBytes?: 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 ephemeralRateLimit: { maxRequests: number; windowMs: number };\n ephemeralMaxBodyBytes: number;\n logWrites: boolean;\n enforceExpiration: boolean;\n expirationReapGraceSeconds: number;\n expirationReapIntervalSeconds: number;\n blockedEventIds: string[];\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 * `POST /write-ephemeral` (relay#129) shares this same port/host but needs\n * no exposure check of its own: it always runs full verification (no skip\n * for exposure to weaken) and enforces its own rate limit/size cap\n * regardless of how a request reaches it. This guard's warning is scoped to\n * the paid-write skip above, which stays the one exposure risk on this port.\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 ephemeralRateLimit =\n config.ephemeralRateLimit ?? DEFAULT_EPHEMERAL_RATE_LIMIT;\n const ephemeralMaxBodyBytes =\n config.ephemeralMaxBodyBytes ?? DEFAULT_EPHEMERAL_MAX_BODY_BYTES;\n const logWrites = config.logWrites ?? false;\n const enforceExpiration = config.enforceExpiration ?? true;\n const expirationReapGraceSeconds = config.expirationReapGraceSeconds ?? 86400;\n const expirationReapIntervalSeconds =\n config.expirationReapIntervalSeconds ?? 3600;\n const blockedEventIds = config.blockedEventIds ?? [];\n\n const resolvedConfig: ResolvedRelayConfig = {\n relayPort,\n blsPort,\n host,\n writeHost,\n maxConnections,\n dataDir,\n devMode,\n verifyEphemeral,\n verifyWorkers,\n ephemeralRateLimit,\n ephemeralMaxBodyBytes,\n logWrites,\n enforceExpiration,\n expirationReapGraceSeconds,\n expirationReapIntervalSeconds,\n blockedEventIds,\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 enforceExpiration,\n blockedEventIds,\n });\n }\n\n // Retention posture, printed every boot. NIP-40 enforcement changes what\n // readers see, and a relay withholding events should say so out loud --\n // especially the operator blocklist, which is this node declining to carry\n // specific events rather than anything the protocol decided.\n console.log(\n `[relay] NIP-40 expiration: ${\n enforceExpiration\n ? `enforced (reap grace ${expirationReapGraceSeconds}s, sweep ${\n expirationReapIntervalSeconds > 0\n ? `every ${expirationReapIntervalSeconds}s`\n : 'disabled'\n })`\n : 'NOT enforced -- expired events are still served (TOON_ENFORCE_EXPIRATION=false)'\n }`\n );\n console.log('[relay] NIP-09 deletion: enabled (author-signed kind:5 only)');\n if (blockedEventIds.length > 0) {\n console.warn(\n `[relay] OPERATOR BLOCKLIST ACTIVE -- ${blockedEventIds.length} event id(s) ` +\n 'refused on write and swept from storage:'\n );\n for (const id of blockedEventIds) {\n console.warn(`[relay] blocked ${id}`);\n }\n }\n\n // --- 4. WebSocket read server (created first so /write can broadcast) ---\n const wsRelay = new NostrRelayServer(\n { port: relayPort, host, maxConnections, enforceExpiration },\n eventStore\n );\n\n // NIP-40 reaper. Unref'd so it can never hold the process open, and driven\n // off the same `enforceExpiration` switch as serve-time filtering: a relay\n // that is still serving expired events must not be quietly deleting them.\n let reapTimer: NodeJS.Timeout | undefined;\n if (\n enforceExpiration &&\n expirationReapIntervalSeconds > 0 &&\n eventStore.reapExpired\n ) {\n const reap = (): void => {\n try {\n const removed = eventStore.reapExpired?.(\n Math.floor(Date.now() / 1000),\n expirationReapGraceSeconds\n );\n if (removed) {\n console.log(\n `[relay] NIP-40 reaper removed ${removed} expired event(s)`\n );\n }\n } catch (error) {\n // A failed sweep is a housekeeping miss, never a reason to take the\n // relay down: the events it would have deleted are already unserved.\n console.warn(\n `[relay] NIP-40 reaper failed: ${error instanceof Error ? error.message : String(error)}`\n );\n }\n };\n reap();\n reapTimer = setInterval(reap, expirationReapIntervalSeconds * 1000);\n reapTimer.unref();\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 ephemeralRateLimit,\n ephemeralMaxBodyBytes,\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 // Shared by both write surfaces: the pool-backed verifier and the live-WS\n // broadcast hook behave identically on either lane -- only WHEN each lane\n // calls them differs (the paid lane may skip verify for ephemeral kinds;\n // the free lane never does).\n const verifyViaPool = (event: NostrEvent): Promise<boolean> => {\n // Keep the reported worker count honest if the pool degraded.\n metrics.setVerifyWorkers(verifyPool.size);\n return verifyPool.verify(event);\n };\n const broadcastToReaders = (event: NostrEvent): void => {\n try {\n wsRelay.broadcastEvent(event);\n } catch {\n // Non-broadcastable payloads -- ignore.\n }\n };\n\n const writeHandler = createWriteHandler({\n eventStore,\n devMode,\n verifyEphemeral,\n verifyEvent: verifyViaPool,\n logWrites,\n onStored: broadcastToReaders,\n });\n app.post('/write', (c: Context) => writeHandler.handleWrite(c));\n\n // POST /write-ephemeral (relay#129): the FREE ephemeral write lane. No\n // payment gate, so unlike /write above, verification here is ALWAYS full\n // (never skipped) and the rate limit + size cap below ARE its admission\n // control -- see write-ephemeral-handler.ts's module doc for the full\n // reasoning. Shares the verify pool with the paid handler.\n console.log(\n `[relay] ephemeral free write lane: enabled on POST /write-ephemeral ` +\n `(full schnorr verify always; rate limit ` +\n `${ephemeralRateLimit.maxRequests} req / ${ephemeralRateLimit.windowMs}ms per key; ` +\n `max body ${ephemeralMaxBodyBytes} bytes)`\n );\n const ephemeralWriteHandler = createEphemeralWriteHandler({\n rateLimit: ephemeralRateLimit,\n maxBodyBytes: ephemeralMaxBodyBytes,\n verifyEvent: verifyViaPool,\n logWrites,\n onBroadcast: broadcastToReaders,\n });\n app.post('/write-ephemeral', (c: Context) =>\n ephemeralWriteHandler.handleWrite(c)\n );\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 if (reapTimer) clearInterval(reapTimer);\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":"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","names":["nowSeconds","nowSeconds","HEX_64","verifyEvent","isEphemeralKind","verifyEvent"]}
package/dist/cli.js CHANGED
@@ -1,7 +1,9 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
+ DEFAULT_EPHEMERAL_RATE_LIMIT,
4
+ parseBlockedEventIds,
3
5
  startRelay
4
- } from "./chunk-QZQRHQEQ.js";
6
+ } from "./chunk-IZOSPMWV.js";
5
7
  import "./chunk-SMT6G3XD.js";
6
8
 
7
9
  // src/launcher/cli.ts
@@ -40,9 +42,39 @@ Options:
40
42
  --max-connections <n> Maximum concurrent WebSocket read connections
41
43
  (default: 4096; each costs one file descriptor --
42
44
  mind ulimit -n, relay#90)
45
+ --ephemeral-rate-limit <n>
46
+ Free ephemeral write lane (POST /write-ephemeral,
47
+ relay#129): max requests per key per window
48
+ (default: 200). This lane has no payment gate, so
49
+ this bound IS its admission control
50
+ --ephemeral-rate-window-ms <n>
51
+ Free ephemeral write lane: rate-limit window in
52
+ milliseconds (default: 10000)
53
+ --ephemeral-max-body-bytes <n>
54
+ Free ephemeral write lane: request body size cap
55
+ in bytes (default: 8192)
43
56
  --log-writes Log one line per accepted POST /write (debug; off
44
57
  by default -- per-event logging is write-path
45
58
  tail jitter, relay#85)
59
+ --no-enforce-expiration Serve events past their NIP-40 expiration tag.
60
+ KILL SWITCH back to the pre-relay#137 behaviour;
61
+ enforcement is ON by default. Also disables the
62
+ reaper -- a relay still serving expired events
63
+ must not be silently deleting them
64
+ --expiration-reap-grace-seconds <n>
65
+ How long an expired event stays on disk before the
66
+ reaper deletes it (default: 86400). The window in
67
+ which flipping enforcement back off is a real
68
+ recovery rather than an apology. 0 = reap at once
69
+ --expiration-reap-interval-seconds <n>
70
+ Reaper sweep interval (default: 3600). 0 disables
71
+ reaping; serve-time filtering is unaffected
72
+ --blocked-event-ids <ids>
73
+ Comma-separated 64-hex event ids this relay
74
+ refuses to store or serve. The escape hatch for an
75
+ event whose author key is gone, so neither NIP-01
76
+ replacement nor NIP-09 deletion can reach it. Ids
77
+ only, never pubkeys; every id is logged at startup
46
78
  --help Show this help message
47
79
 
48
80
  Environment Variables:
@@ -58,7 +90,14 @@ Environment Variables:
58
90
  TOON_VERIFY_EPHEMERAL Same as --verify-ephemeral (set to "true")
59
91
  TOON_VERIFY_WORKERS Same as --verify-workers
60
92
  TOON_MAX_CONNECTIONS Same as --max-connections
93
+ TOON_EPHEMERAL_RATE_LIMIT Same as --ephemeral-rate-limit
94
+ TOON_EPHEMERAL_RATE_WINDOW_MS Same as --ephemeral-rate-window-ms
95
+ TOON_EPHEMERAL_MAX_BODY_BYTES Same as --ephemeral-max-body-bytes
61
96
  TOON_LOG_WRITES Same as --log-writes (set to "true")
97
+ TOON_ENFORCE_EXPIRATION Set to "false" for --no-enforce-expiration
98
+ TOON_EXPIRATION_REAP_GRACE_SECONDS Same as --expiration-reap-grace-seconds
99
+ TOON_EXPIRATION_REAP_INTERVAL_SECONDS Same as --expiration-reap-interval-seconds
100
+ TOON_BLOCKED_EVENT_IDS Same as --blocked-event-ids
62
101
 
63
102
  Security:
64
103
  Prefer TOON_MNEMONIC / TOON_SECRET_KEY / NOSTR_SECRET_KEY environment
@@ -67,6 +106,28 @@ Security:
67
106
  `.trim()
68
107
  );
69
108
  }
109
+ function parsePositiveIntOption(flag, raw) {
110
+ if (!raw) {
111
+ return void 0;
112
+ }
113
+ const parsed = parseInt(raw, 10);
114
+ if (Number.isNaN(parsed) || parsed <= 0) {
115
+ console.error(`Error: --${flag} must be a positive integer`);
116
+ process.exit(1);
117
+ }
118
+ return parsed;
119
+ }
120
+ function parseNonNegativeIntOption(flag, raw) {
121
+ if (raw === void 0 || raw === "") {
122
+ return void 0;
123
+ }
124
+ const parsed = parseInt(raw, 10);
125
+ if (Number.isNaN(parsed) || parsed < 0) {
126
+ console.error(`Error: --${flag} must be an integer >= 0`);
127
+ process.exit(1);
128
+ }
129
+ return parsed;
130
+ }
70
131
  function parseCli() {
71
132
  const { values } = parseArgs({
72
133
  options: {
@@ -81,7 +142,14 @@ function parseCli() {
81
142
  "verify-ephemeral": { type: "boolean" },
82
143
  "verify-workers": { type: "string" },
83
144
  "max-connections": { type: "string" },
145
+ "ephemeral-rate-limit": { type: "string" },
146
+ "ephemeral-rate-window-ms": { type: "string" },
147
+ "ephemeral-max-body-bytes": { type: "string" },
84
148
  "log-writes": { type: "boolean" },
149
+ "no-enforce-expiration": { type: "boolean" },
150
+ "expiration-reap-grace-seconds": { type: "string" },
151
+ "expiration-reap-interval-seconds": { type: "string" },
152
+ "blocked-event-ids": { type: "string" },
85
153
  help: { type: "boolean" }
86
154
  },
87
155
  strict: true,
@@ -150,7 +218,42 @@ function parseCli() {
150
218
  console.error("Error: --max-connections must be a positive integer");
151
219
  process.exit(1);
152
220
  }
221
+ const ephemeralRateLimitMax = parsePositiveIntOption(
222
+ "ephemeral-rate-limit",
223
+ values["ephemeral-rate-limit"] ?? process.env["TOON_EPHEMERAL_RATE_LIMIT"]
224
+ );
225
+ const ephemeralRateWindowMs = parsePositiveIntOption(
226
+ "ephemeral-rate-window-ms",
227
+ values["ephemeral-rate-window-ms"] ?? process.env["TOON_EPHEMERAL_RATE_WINDOW_MS"]
228
+ );
229
+ const ephemeralMaxBodyBytes = parsePositiveIntOption(
230
+ "ephemeral-max-body-bytes",
231
+ values["ephemeral-max-body-bytes"] ?? process.env["TOON_EPHEMERAL_MAX_BODY_BYTES"]
232
+ );
233
+ const ephemeralRateLimit = ephemeralRateLimitMax !== void 0 || ephemeralRateWindowMs !== void 0 ? {
234
+ maxRequests: ephemeralRateLimitMax ?? DEFAULT_EPHEMERAL_RATE_LIMIT.maxRequests,
235
+ windowMs: ephemeralRateWindowMs ?? DEFAULT_EPHEMERAL_RATE_LIMIT.windowMs
236
+ } : void 0;
153
237
  const logWrites = values["log-writes"] ?? (process.env["TOON_LOG_WRITES"] === "true" ? true : void 0);
238
+ const enforceExpiration = values["no-enforce-expiration"] === true || process.env["TOON_ENFORCE_EXPIRATION"] === "false" ? false : void 0;
239
+ const expirationReapGraceSeconds = parseNonNegativeIntOption(
240
+ "expiration-reap-grace-seconds",
241
+ values["expiration-reap-grace-seconds"] ?? process.env["TOON_EXPIRATION_REAP_GRACE_SECONDS"]
242
+ );
243
+ const expirationReapIntervalSeconds = parseNonNegativeIntOption(
244
+ "expiration-reap-interval-seconds",
245
+ values["expiration-reap-interval-seconds"] ?? process.env["TOON_EXPIRATION_REAP_INTERVAL_SECONDS"]
246
+ );
247
+ const blocklist = parseBlockedEventIds(
248
+ values["blocked-event-ids"] ?? process.env["TOON_BLOCKED_EVENT_IDS"]
249
+ );
250
+ if (blocklist.invalid.length > 0) {
251
+ console.error(
252
+ `Error: --blocked-event-ids entries must be 64-character hex event ids; rejected: ${blocklist.invalid.join(", ")}`
253
+ );
254
+ process.exit(1);
255
+ }
256
+ const blockedEventIds = blocklist.ids.length > 0 ? blocklist.ids : void 0;
154
257
  const config = {
155
258
  ...mnemonic && { mnemonic },
156
259
  ...secretKey && { secretKey },
@@ -163,7 +266,17 @@ function parseCli() {
163
266
  ...verifyEphemeral !== void 0 && { verifyEphemeral },
164
267
  ...verifyWorkers !== void 0 && { verifyWorkers },
165
268
  ...maxConnections !== void 0 && { maxConnections },
166
- ...logWrites !== void 0 && { logWrites }
269
+ ...ephemeralRateLimit !== void 0 && { ephemeralRateLimit },
270
+ ...ephemeralMaxBodyBytes !== void 0 && { ephemeralMaxBodyBytes },
271
+ ...logWrites !== void 0 && { logWrites },
272
+ ...enforceExpiration !== void 0 && { enforceExpiration },
273
+ ...expirationReapGraceSeconds !== void 0 && {
274
+ expirationReapGraceSeconds
275
+ },
276
+ ...expirationReapIntervalSeconds !== void 0 && {
277
+ expirationReapIntervalSeconds
278
+ },
279
+ ...blockedEventIds !== void 0 && { blockedEventIds }
167
280
  };
168
281
  return config;
169
282
  }
@@ -177,9 +290,16 @@ async function main() {
177
290
  console.log("TOON Relay Ready");
178
291
  console.log("=".repeat(50));
179
292
  console.log(` Pubkey: ${instance.pubkey}`);
180
- console.log(` Reads: ws://localhost:${instance.config.relayPort}`);
181
- console.log(` Writes: http://localhost:${instance.config.blsPort}/write`);
182
- console.log(` Health: http://localhost:${instance.config.blsPort}/health`);
293
+ console.log(` Reads: ws://localhost:${instance.config.relayPort}`);
294
+ console.log(
295
+ ` Writes: http://localhost:${instance.config.blsPort}/write`
296
+ );
297
+ console.log(
298
+ ` Ephemeral: http://localhost:${instance.config.blsPort}/write-ephemeral (free, relay#129)`
299
+ );
300
+ console.log(
301
+ ` Health: http://localhost:${instance.config.blsPort}/health`
302
+ );
183
303
  console.log("=".repeat(50) + "\n");
184
304
  const shutdown = async (signal) => {
185
305
  console.log(`