@toon-protocol/relay 2.0.0 → 2.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -15,7 +15,17 @@ interface RelayServerConfig {
15
15
  port: number;
16
16
  /** Host/IP to bind to (default: '0.0.0.0'). Set to '127.0.0.1' for hidden service mode. */
17
17
  host?: string;
18
- /** Maximum concurrent connections (default: 100) */
18
+ /**
19
+ * Maximum concurrent WebSocket connections (default: 4096; relay#90).
20
+ *
21
+ * Each connection costs one file descriptor plus a few KB of handler
22
+ * state, so the practical ceiling is fd-limit-shaped, not memory-shaped.
23
+ * 4096 supports several hundred-listener huddles at once (the stock 100
24
+ * made >100 listeners impossible) while leaving comfortable headroom
25
+ * under docker's default nofile limit (1048576) AND still fitting under a
26
+ * conservative 8192 ulimit; on a classic 1024 soft limit the startup
27
+ * fd-limit check logs a warning (see NostrRelayServer.start).
28
+ */
19
29
  maxConnections?: number;
20
30
  /** Maximum subscriptions per connection (default: 20) */
21
31
  maxSubscriptionsPerConnection?: number;
@@ -71,6 +81,7 @@ declare class RelayError extends Error {
71
81
  declare class SqliteEventStore implements EventStore {
72
82
  private db;
73
83
  private insertStmt;
84
+ private insertOrIgnoreStmt;
74
85
  private getStmt;
75
86
  private deleteByPubkeyKindStmt;
76
87
  private deleteByPubkeyKindDTagStmt;
@@ -129,6 +140,139 @@ declare class SqliteEventStore implements EventStore {
129
140
  */
130
141
  declare function matchFilter(event: NostrEvent, filter: Filter): boolean;
131
142
 
143
+ /**
144
+ * Event-signature verification for the paid-write hot path (relay#85).
145
+ *
146
+ * nostr-tools' `verifyEvent` runs BIP-340 schnorr verification in pure-JS
147
+ * noble at ~1.3ms per event -- synchronously, on the relay's single Node
148
+ * event loop. Post-#84 that verify IS the write-path ceiling: the devnet
149
+ * relay caps at ~240-260 events/s aggregate, CPU-bound (connector#685
150
+ * Phase G measurements).
151
+ *
152
+ * This module verifies with libsecp256k1 compiled to WASM (`tiny-secp256k1`,
153
+ * ~0.20ms per event on the same workload -- ~7x faster) and keeps noble as a
154
+ * fallback: if the WASM module fails to load or fails a startup self-test on
155
+ * a known-good/known-bad vector pair, verification transparently degrades to
156
+ * `verifyEvent` from nostr-tools. The relay never hard-fails because of the
157
+ * fast path. WASM was chosen over a native addon deliberately: it needs no
158
+ * platform toolchain in the Docker build, and the `secp256k1` native-addon
159
+ * package exposes no schnorr API at all.
160
+ *
161
+ * Semantics match nostr-tools `verifyEvent` -- serialize per NIP-01, SHA-256,
162
+ * compare against `event.id`, then BIP-340-verify `event.sig` -- with one
163
+ * deliberate improvement: a structurally invalid event returns `false` here
164
+ * (nostr-tools' `getEventHash` throws instead). `verifyEventSignature` never
165
+ * throws.
166
+ *
167
+ * @module
168
+ */
169
+
170
+ /**
171
+ * Which verify implementation is active. Surfaced so the launcher can log it
172
+ * once at startup (the noble fallback is a silent ~7x throughput loss
173
+ * otherwise).
174
+ */
175
+ declare const verifyImplementation: 'libsecp256k1-wasm' | 'noble-pure-js';
176
+ /**
177
+ * Verify ONLY that `event.id` is the correct NIP-01 SHA-256 hash of the
178
+ * event's serialized form. Does NOT check the schnorr signature and does NOT
179
+ * stamp nostr-tools' `verifiedSymbol` cache (an id check is not a signature
180
+ * verdict).
181
+ *
182
+ * This is the integrity floor for the paid-ephemeral skip-verify path
183
+ * (relay#85): when the relay skips schnorr for payment-gated ephemeral kinds,
184
+ * it still refuses events whose id does not match their content, so a paid
185
+ * writer cannot make the relay broadcast a frame whose bytes disagree with
186
+ * the id that clients index/verify by.
187
+ *
188
+ * Never throws; structurally invalid events return false.
189
+ *
190
+ * @param event - The event whose id to check.
191
+ * @returns True iff `event.id` equals the NIP-01 SHA-256 of the event.
192
+ */
193
+ declare function verifyEventId(event: NostrEvent): boolean;
194
+ /**
195
+ * Verify a Nostr event's id and BIP-340 signature.
196
+ *
197
+ * Drop-in replacement for nostr-tools' `verifyEvent` on the write hot path:
198
+ * same accept/reject semantics for well-formed events, ~7x faster via WASM
199
+ * libsecp256k1 when available, noble pure-JS otherwise. Never throws;
200
+ * structurally invalid events return false.
201
+ *
202
+ * @param event - The event to verify.
203
+ * @returns True iff the id matches the NIP-01 hash and the signature is valid.
204
+ */
205
+ declare function verifyEventSignature(event: NostrEvent): boolean;
206
+
207
+ /**
208
+ * Worker-thread pool for event-signature verification (relay#85).
209
+ *
210
+ * Post-#87 the WASM verify takes ~0.2ms per event -- but it still runs ON the
211
+ * single Node event loop, which also carries every WebSocket broadcast.
212
+ * Agent writers make persistent-kind write rates potentially bursty and high;
213
+ * a verify burst on the loop is exactly the kind of stall that shows up as
214
+ * tail jitter on ephemeral (huddle-frame) latency. The pool moves persistent
215
+ * -kind schnorr verification onto worker threads so verify bursts cannot
216
+ * stall the loop, at the cost of one thread-hop per verified event.
217
+ *
218
+ * Shape:
219
+ * - `size` workers (default `max(0, os.cpus().length - 1)`); each worker
220
+ * imports `verify-event.ts` and therefore instantiates its OWN WASM
221
+ * libsecp256k1 (same self-test + noble fallback semantics as inline).
222
+ * - `size: 0` -- automatic on 1-core boxes, and the explicit config escape
223
+ * hatch (TOON_VERIFY_WORKERS=0) -- keeps the current inline path: `verify`
224
+ * resolves synchronously-computed results, no threads are created.
225
+ * - Dispatch is least-busy; per-call results resolve independently.
226
+ * ORDERING: results for CONCURRENT calls may settle out of submission
227
+ * order. The write path stays correct because the upstream connector
228
+ * serializes each BTP session's POSTs (next request only after the
229
+ * previous response) -- pinned by tests in write-handler.test.ts.
230
+ * - Worker failure degrades transparently: pending and future verifies fall
231
+ * back to the inline implementation (the pool never hard-fails a write).
232
+ * - Hand-rolled on `node:worker_threads` -- ~100 lines beats a piscina
233
+ * dependency in a package whose runtime deps are deliberately minimal.
234
+ *
235
+ * @module
236
+ */
237
+
238
+ /** A pool that verifies event signatures off the main event loop. */
239
+ interface VerifyPool {
240
+ /**
241
+ * Verify an event's id + BIP-340 signature. Never rejects; invalid or
242
+ * structurally broken events resolve `false`. Semantics match
243
+ * `verifyEventSignature`, including stamping the nostr-tools
244
+ * verified-event cache symbol on the caller's object.
245
+ */
246
+ verify(event: NostrEvent): Promise<boolean>;
247
+ /** Live worker count (0 = inline path). */
248
+ readonly size: number;
249
+ /** Terminate all workers. Idempotent. Pending verifies resolve inline. */
250
+ destroy(): Promise<void>;
251
+ }
252
+ /**
253
+ * Default pool size: one worker per CPU minus one core reserved for the
254
+ * event loop (WS fan-out + HTTP). On a 1-core box this is 0 -- the inline
255
+ * path -- because a worker would only add thread-hop overhead while
256
+ * competing for the same core.
257
+ */
258
+ declare function defaultVerifyWorkers(): number;
259
+ /** Options for {@link createVerifyPool}. */
260
+ interface VerifyPoolOptions {
261
+ /** Worker count (default {@link defaultVerifyWorkers}; 0 = inline). */
262
+ size?: number;
263
+ /**
264
+ * Called with the wall-clock milliseconds of each verify -- including
265
+ * pool queue + thread-hop time, i.e. the latency a write actually paid.
266
+ * The launcher wires this into the /metrics registry.
267
+ */
268
+ onMeasure?: (ms: number) => void;
269
+ }
270
+ /**
271
+ * Create a verify pool. See the module doc for semantics; see
272
+ * {@link VerifyPoolOptions} for knobs.
273
+ */
274
+ declare function createVerifyPool(options?: VerifyPoolOptions): VerifyPool;
275
+
132
276
  /**
133
277
  * Represents an active subscription from a client.
134
278
  */
@@ -138,6 +282,21 @@ interface Subscription {
138
282
  /** Filters applied to this subscription */
139
283
  filters: Filter[];
140
284
  }
285
+ /**
286
+ * Build a NIP-01 EVENT frame from an ALREADY-SERIALIZED event payload,
287
+ * splicing in the (JSON-escaped) subscription id.
288
+ *
289
+ * Byte-identical to `JSON.stringify(['EVENT', subscriptionId, event])` --
290
+ * pinned by tests -- but lets the broadcast fan-out serialize the event
291
+ * ONCE and reuse the string across every matching subscriber (relay#91:
292
+ * 500 subscribers previously meant 500 identical `JSON.stringify(event)`
293
+ * calls per frame, measured pinning a core in the s500 benchmark run).
294
+ *
295
+ * @param subscriptionId - The per-subscriber NIP-01 subscription id.
296
+ * @param eventJson - `JSON.stringify(event)` output to reuse.
297
+ * @returns The full EVENT frame string for the wire.
298
+ */
299
+ declare function serializeEventFrame(subscriptionId: string, eventJson: string): string;
141
300
  /**
142
301
  * Handles NIP-01 messages for a single WebSocket connection.
143
302
  */
@@ -170,8 +329,15 @@ declare class ConnectionHandler {
170
329
  /**
171
330
  * Push a new event to all matching subscriptions on this connection.
172
331
  * Used when events are stored outside the WebSocket flow (e.g., via ILP).
332
+ *
333
+ * @param event - The event to fan out (used for filter matching).
334
+ * @param eventJson - Optional pre-serialized `JSON.stringify(event)`.
335
+ * `NostrRelayServer.broadcastEvent` serializes the event ONCE and passes
336
+ * it here so a 500-subscriber fan-out costs one serialization, not 500
337
+ * (relay#91). When omitted (direct callers), the event is serialized
338
+ * on first matching send.
173
339
  */
174
- notifyNewEvent(event: NostrEvent): void;
340
+ notifyNewEvent(event: NostrEvent, eventJson?: string): void;
175
341
  /**
176
342
  * Clean up all subscriptions for this connection.
177
343
  */
@@ -180,13 +346,32 @@ declare class ConnectionHandler {
180
346
  * Get the number of active subscriptions.
181
347
  */
182
348
  getSubscriptionCount(): number;
349
+ /**
350
+ * Emit an outbound NIP-01 EVENT frame.
351
+ *
352
+ * The event MUST go on the wire as canonical NIP-01 JSON —
353
+ * `["EVENT", <subId>, {id, pubkey, created_at, kind, tags, content, sig}]`
354
+ * with the event as a plain JSON object — so any standard nostr client can
355
+ * parse it and verify `id`/`sig` from the wire bytes (#46). Never re-encode
356
+ * the event (TOON text, double-JSON-stringify, etc.) at this boundary.
357
+ * serializeEventFrame is byte-identical to the full JSON.stringify
358
+ * envelope (pinned by tests).
359
+ */
183
360
  private sendEvent;
184
361
  private sendEose;
185
362
  private sendOk;
186
363
  private sendNotice;
364
+ /** Send a message: pre-serialized frames go out as-is (relay#91). */
187
365
  private send;
188
366
  }
189
367
 
368
+ /**
369
+ * Read this process's soft "Max open files" limit from /proc/self/limits.
370
+ * Returns null off-Linux or on any parse failure (the check is advisory).
371
+ *
372
+ * @internal Exported for unit testing.
373
+ */
374
+ declare function readOpenFilesSoftLimit(read?: (path: string) => string): number | null;
190
375
  /**
191
376
  * A NIP-01 compliant Nostr relay WebSocket server.
192
377
  * Handles client connections and routes messages to ConnectionHandlers.
@@ -218,6 +403,12 @@ declare class NostrRelayServer {
218
403
  * Broadcast an event to all connected clients with matching subscriptions.
219
404
  * Call this after storing an event outside the WebSocket flow (e.g., via ILP)
220
405
  * so that discovery subscribers are notified.
406
+ *
407
+ * Serialize-once fan-out (relay#91): the event payload is stringified ONE
408
+ * time here and reused for every matching subscriber -- only the small
409
+ * per-subscription `["EVENT",<subId>,...]` envelope is spliced per send.
410
+ * Previously each of N subscribers re-serialized the identical event
411
+ * (N=500 pinned a core doing 500 identical stringifies per frame).
221
412
  */
222
413
  broadcastEvent(event: NostrEvent): void;
223
414
  private handleConnection;
@@ -226,11 +417,17 @@ declare class NostrRelayServer {
226
417
  /**
227
418
  * TOON event codec.
228
419
  *
229
- * Encodes/decodes Nostr events to and from the TOON wire format used on this
230
- * relay's NIP-01 read surface. Vendored from `@toon-protocol/core` so the relay
231
- * depends only on the lightweight `@toon-format/toon` encoder rather than core's
232
- * full transitive tree (which pulls Arweave / web3 wallet stacks the relay does
233
- * not use). Same MIT license / org.
420
+ * Encodes/decodes Nostr events to and from the TOON text format. Vendored from
421
+ * `@toon-protocol/core` so the relay depends only on the lightweight
422
+ * `@toon-format/toon` encoder rather than core's full transitive tree (which
423
+ * pulls Arweave / web3 wallet stacks the relay does not use). Same MIT
424
+ * license / org.
425
+ *
426
+ * NOTE: this codec is NOT used on the relay's NIP-01 read surface. Outbound
427
+ * EVENT frames are canonical NIP-01 JSON (see
428
+ * `websocket/ConnectionHandler.sendEvent`, #46) so that standard nostr clients
429
+ * can parse events and verify signatures from the wire. The codec remains
430
+ * exported for library consumers that exchange TOON-text events elsewhere.
234
431
  *
235
432
  * @module
236
433
  */
@@ -310,7 +507,9 @@ declare class RelaySubscriber {
310
507
  * - `POST /write` (TOON_BLS_PORT, default 3100): accepts `{ event }` as JSON,
311
508
  * trusts the injected `X-TOON-Payer`/`-Amount`/`-Chain` headers WITHOUT
312
509
  * re-validating payment, verifies only the event's own signature for
313
- * integrity, and stores it. `GET /health` lives on the same port.
510
+ * integrity (paid ephemeral kinds skip schnorr by default and keep the
511
+ * id check -- relay#85, see `verifyEphemeral`), and stores it.
512
+ * `GET /health` and `GET /metrics` live on the same port.
314
513
  * - Free NIP-01 WebSocket reads (TOON_RELAY_PORT, default 7100).
315
514
  *
316
515
  * `startRelay()` returns a `RelayInstance` with an explicit `.stop()` for
@@ -339,6 +538,25 @@ interface RelayConfig {
339
538
  * port to localhost only (e.g. when an upstream proxy handles inbound).
340
539
  */
341
540
  host?: string;
541
+ /**
542
+ * Bind host for the HTTP write/health listener (default: 0.0.0.0).
543
+ *
544
+ * The write port MUST only be reachable via the payment-gating connector
545
+ * (see `verifyEphemeral`): in the canonical compose deploy that is enforced
546
+ * by NOT host-publishing the port (docker `expose:`, never `ports:` --
547
+ * note that docker `ports:` publishes bypass ufw). When the relay runs
548
+ * directly on a host, bind this to a loopback/internal address instead.
549
+ * A non-internal bind while the ephemeral verify skip is active logs a
550
+ * prominent startup warning (never a hard failure -- topologies vary).
551
+ */
552
+ writeHost?: string;
553
+ /**
554
+ * Maximum concurrent WebSocket read connections (default: 4096; env:
555
+ * TOON_MAX_CONNECTIONS). Connections beyond the cap are closed with 1013.
556
+ * Fd-limit-shaped, not memory-shaped -- see RelayServerConfig
557
+ * .maxConnections for the sizing reasoning (relay#90).
558
+ */
559
+ maxConnections?: number;
342
560
  /** Data directory for the file-backed SQLite store (default: ./data). */
343
561
  dataDir?: string;
344
562
  /**
@@ -350,6 +568,33 @@ interface RelayConfig {
350
568
  eventStore?: EventStore;
351
569
  /** Skip event-signature verification on `POST /write` (default: false). */
352
570
  devMode?: boolean;
571
+ /**
572
+ * Run FULL schnorr verification on ephemeral kinds (default: false -- the
573
+ * paid-ephemeral verify skip is ON by default, relay#85).
574
+ *
575
+ * The default skip is safe ONLY because `POST /write` is payment-gated by
576
+ * the upstream connector and clients verify every signature themselves;
577
+ * the SHA-256 event-id check always runs. Community operators fronting the
578
+ * write port with anything other than a payment-gating connector should
579
+ * set this to true (env: TOON_VERIFY_EPHEMERAL=true). See
580
+ * `WriteHandlerConfig.verifyEphemeral` for the full invariant.
581
+ */
582
+ verifyEphemeral?: boolean;
583
+ /**
584
+ * Worker-thread verify pool size for persistent-kind signature
585
+ * verification (default: `max(0, os.cpus().length - 1)`; env:
586
+ * TOON_VERIFY_WORKERS). `0` -- automatic on 1-core boxes -- is the inline
587
+ * escape hatch: verification runs synchronously on the event loop as
588
+ * before. Workers keep bursty agent-writer verify load from stalling the
589
+ * loop and jittering ephemeral frame latency (relay#85).
590
+ */
591
+ verifyWorkers?: number;
592
+ /**
593
+ * Log one line per accepted `POST /write` (default: false). Per-event
594
+ * console I/O is measurable tail jitter on the write hot path (relay#85),
595
+ * so this is a debug switch, not an access log.
596
+ */
597
+ logWrites?: boolean;
353
598
  }
354
599
  /**
355
600
  * Resolved configuration with all defaults applied.
@@ -358,8 +603,13 @@ interface ResolvedRelayConfig {
358
603
  relayPort: number;
359
604
  blsPort: number;
360
605
  host: string;
606
+ writeHost: string;
607
+ maxConnections: number;
361
608
  dataDir: string;
362
609
  devMode: boolean;
610
+ verifyEphemeral: boolean;
611
+ verifyWorkers: number;
612
+ logWrites: boolean;
363
613
  }
364
614
  /**
365
615
  * A running TOON relay node instance returned by `startRelay()`.
@@ -396,6 +646,34 @@ interface RelaySubscription {
396
646
  /** Whether this subscription is still active. */
397
647
  isActive(): boolean;
398
648
  }
649
+ /**
650
+ * Whether `host` is a bind address that cannot be reached from the public
651
+ * internet directly: loopback, RFC1918 private, IPv6 unique-local/link-local.
652
+ * `0.0.0.0` / `::` (all interfaces) and public addresses return false.
653
+ *
654
+ * Used by the startup exposure guard: with the paid-ephemeral verify skip
655
+ * active, the write port must only be reachable via the payment-gating
656
+ * connector. Note a "false" here is not proof of exposure -- inside a
657
+ * container, 0.0.0.0 is required for the connector to dial the compose
658
+ * network and the port is kept private by not host-publishing it -- which is
659
+ * why the guard warns instead of failing.
660
+ *
661
+ * @internal Exported for unit testing.
662
+ */
663
+ declare function isInternalBindHost(host: string): boolean;
664
+ /**
665
+ * Log the prominent write-port exposure warning when the paid-ephemeral
666
+ * verify skip is active and the write listener binds a non-internal
667
+ * interface. Deliberately a warning, not a hard failure: in the canonical
668
+ * compose deploy the port binds 0.0.0.0 inside the container and is private
669
+ * because it is never host-published (docker `expose:`, not `ports:`).
670
+ *
671
+ * @internal Exported for unit testing.
672
+ */
673
+ declare function warnIfWritePortExposed(writeHost: string, blsPort: number, options: {
674
+ verifyEphemeral: boolean;
675
+ devMode: boolean;
676
+ }): boolean;
399
677
  /**
400
678
  * Start a TOON relay node with the given configuration.
401
679
  *
@@ -449,6 +727,87 @@ interface HealthResponse {
449
727
  */
450
728
  declare function createHealthResponse(config: HealthConfig): HealthResponse;
451
729
 
730
+ /**
731
+ * Metrics for the relay's HTTP telemetry surface (relay#85).
732
+ *
733
+ * Served as JSON from `GET /metrics` on the write/health port, next to
734
+ * `/health`. Two families, chosen because they are the TRIGGER METRICS for
735
+ * future scaling decisions (2026-08-02 benchmarking, toon-meta
736
+ * proto/spacetimedb-relay RESULTS.md):
737
+ *
738
+ * - **Event-loop delay**: the single Node loop carries every WS broadcast;
739
+ * loop lag IS ephemeral (huddle-frame) tail latency. Sustained p99 growth
740
+ * here is the signal to shed load or scale out.
741
+ * - **Per-event verify time**: wall-clock ms per signature verification as
742
+ * the write actually paid it (including verify-pool queue + thread-hop
743
+ * when workers are enabled). Growth here is the signal to resize the
744
+ * verify pool (TOON_VERIFY_WORKERS) or move boxes.
745
+ *
746
+ * The registry is deliberately dependency-free: `monitorEventLoopDelay`
747
+ * from node:perf_hooks plus a fixed-size ring of recent verify durations
748
+ * (percentiles over the last {@link VERIFY_WINDOW} samples -- bounded
749
+ * memory, O(window log window) only when a snapshot is requested).
750
+ *
751
+ * @module
752
+ */
753
+ /** Aggregates for one duration family, in milliseconds. */
754
+ interface DurationStats {
755
+ /** Total samples recorded since startup. */
756
+ count: number;
757
+ /** Mean over ALL samples since startup. */
758
+ meanMs: number;
759
+ /** Max over ALL samples since startup. */
760
+ maxMs: number;
761
+ /** Median over the most recent window (up to {@link VERIFY_WINDOW}). */
762
+ p50Ms: number;
763
+ /** 99th percentile over the most recent window. */
764
+ p99Ms: number;
765
+ }
766
+ /** The `GET /metrics` response shape. */
767
+ interface MetricsSnapshot {
768
+ timestamp: number;
769
+ /**
770
+ * Event-loop delay in ms (node:perf_hooks monitorEventLoopDelay since the
771
+ * last snapshot reset -- lifetime of the process unless noted). `mean`,
772
+ * `p50`, `p99`, `max` -- loop lag is ephemeral-frame tail latency.
773
+ */
774
+ eventLoopDelayMs: {
775
+ mean: number;
776
+ p50: number;
777
+ p99: number;
778
+ max: number;
779
+ };
780
+ /** Per-event signature-verify timing (trigger metric for pool sizing). */
781
+ verify: {
782
+ /** Active implementation: 'libsecp256k1-wasm' or 'noble-pure-js'. */
783
+ implementation: string;
784
+ /** Verify-pool worker count (0 = inline on the event loop). */
785
+ workers: number;
786
+ } & DurationStats;
787
+ }
788
+ /** Live registry behind `GET /metrics`. */
789
+ interface MetricsRegistry {
790
+ /** Record one verify duration in milliseconds. */
791
+ recordVerify(ms: number): void;
792
+ /** Build the current snapshot (cheap; safe to poll). */
793
+ snapshot(): MetricsSnapshot;
794
+ /** Update the reported worker count (pool may degrade at runtime). */
795
+ setVerifyWorkers(workers: number): void;
796
+ /** Disable the loop-delay histogram (call on relay stop). */
797
+ stop(): void;
798
+ }
799
+ /**
800
+ * Create the metrics registry. One per relay instance; the launcher wires
801
+ * `recordVerify` into the verify pool's `onMeasure` and serves `snapshot()`
802
+ * from `GET /metrics`.
803
+ *
804
+ * @param info - Static verify metadata surfaced in the snapshot.
805
+ */
806
+ declare function createMetricsRegistry(info: {
807
+ verifyImplementation: string;
808
+ verifyWorkers: number;
809
+ }): MetricsRegistry;
810
+
452
811
  /**
453
812
  * Write handler for @toon-protocol/relay.
454
813
  *
@@ -465,9 +824,26 @@ declare function createHealthResponse(config: HealthConfig): HealthResponse;
465
824
  * Flow:
466
825
  * 1. Parse JSON body `{ event }` -> 400 on malformed/missing event
467
826
  * 2. Capture trusted X-TOON-Payer / X-TOON-Amount / X-TOON-Chain headers
468
- * 3. Verify the event signature (skipped in devMode) -> 422 on invalid sig
469
- * 4. Store the event in the EventStore
470
- * 5. Fire the optional onStored callback
827
+ * 3. Verify the event signature (skipped in devMode) -> 422 on invalid sig.
828
+ * Verification uses the fast WASM libsecp256k1 path (crypto/verify-event)
829
+ * rather than noble pure-JS: post-#84 the synchronous ~1.3ms noble verify
830
+ * on the single event loop WAS the write-path ceiling (~240-260 events/s
831
+ * aggregate, relay#85 / connector#685 Phase G).
832
+ * PAID-EPHEMERAL EXCEPTION (relay#85, decision 2026-08-02): for ephemeral
833
+ * kinds (20000 <= kind < 30000) the schnorr verification is SKIPPED by
834
+ * default -- only the SHA-256 id check runs -> 422 on id mismatch. See
835
+ * the loud comment at the verify step for why this is safe, and
836
+ * `verifyEphemeral` to turn full verification back on.
837
+ * 4. Store the event in the EventStore -- unless its kind is ephemeral
838
+ * (NIP-16: 20000 <= kind < 30000), which is delivered live and never
839
+ * persisted. Skipping the store here is not only NIP-16 semantics: the
840
+ * synchronous per-event disk write was the serialization point that
841
+ * capped the whole paid-write pipeline at ~150 events/s globally
842
+ * (connector#685), and ephemeral traffic -- audio frames -- is exactly
843
+ * the traffic that hits that path hardest.
844
+ * 5. Fire the optional onStored callback (ephemeral events included: it is
845
+ * the live-broadcast hook, and ephemeral events exist only as that
846
+ * broadcast)
471
847
  * 6. Respond 200 with the event id, storedAt timestamp, and echoed headers
472
848
  *
473
849
  * @module
@@ -481,8 +857,52 @@ interface WriteHandlerConfig {
481
857
  eventStore: EventStore;
482
858
  /** Whether dev mode is enabled (skips Schnorr signature verification). */
483
859
  devMode: boolean;
860
+ /**
861
+ * Run FULL schnorr verification on ephemeral kinds too (default: false --
862
+ * i.e. the paid-ephemeral verify skip is ON by default).
863
+ *
864
+ * ! SECURITY INVARIANT -- READ BEFORE TOUCHING !
865
+ * The default skip is safe ONLY because this write surface is payment-gated:
866
+ * every request reaching `POST /write` has already passed the upstream
867
+ * connector's claim gate (payment IS the admission/spam gate), and the
868
+ * protocol rule is that clients trust the signature chain and verify every
869
+ * event themselves -- never the relay. Relay-side schnorr on ephemeral
870
+ * frames is therefore pure spam defense that payment already provides;
871
+ * forging a speaker costs real money to emit frames every client discards.
872
+ * The SHA-256 id check is ALWAYS kept (see handleWrite).
873
+ *
874
+ * If you ever add a FREE (non-payment-gated) ephemeral write lane, it MUST
875
+ * NOT reuse this skip -- free spam with valid-looking ids would be
876
+ * broadcast to every subscriber. Community operators who front this port
877
+ * with anything other than a payment-gating connector should set
878
+ * `verifyEphemeral: true` (TOON_VERIFY_EPHEMERAL=true).
879
+ */
880
+ verifyEphemeral?: boolean;
881
+ /**
882
+ * Signature verifier for non-skipped (persistent-kind) events. Defaults to
883
+ * the inline `verifyEventSignature`; the launcher injects the worker-pool
884
+ * verifier (`crypto/verify-pool.ts`) so verify bursts run off the event
885
+ * loop (relay#85). May resolve asynchronously -- the handler awaits it.
886
+ *
887
+ * ORDERING NOTE: an async verifier means CONCURRENT requests can complete
888
+ * out of arrival order. Per-session write ordering is enforced UPSTREAM:
889
+ * the connector serializes each BTP session's POSTs (it does not send the
890
+ * next request until the previous response arrives), so sequential
891
+ * same-session writes can never reorder here. Pinned by the ordering test
892
+ * in write-handler.test.ts -- do not weaken that contract upstream without
893
+ * revisiting this.
894
+ */
895
+ verifyEvent?: (event: NostrEvent) => Promise<boolean> | boolean;
484
896
  /** Optional callback fired after an event is successfully stored. */
485
897
  onStored?: (event: NostrEvent) => void;
898
+ /**
899
+ * Log one line per accepted write (default: false). Off by default because
900
+ * per-event console I/O on the single event loop is measurable tail jitter
901
+ * at huddle frame rates (relay#85, connector#685 Phase G): every write's
902
+ * log line goes through docker's json-file driver, i.e. residual per-event
903
+ * disk I/O that #84 did not remove.
904
+ */
905
+ logWrites?: boolean;
486
906
  }
487
907
  /**
488
908
  * Write handler instance.
@@ -499,4 +919,4 @@ interface WriteHandler {
499
919
  */
500
920
  declare function createWriteHandler(config: WriteHandlerConfig): WriteHandler;
501
921
 
502
- export { ConnectionHandler, DEFAULT_RELAY_CONFIG, type EventStore, type HealthConfig, type HealthResponse, InMemoryEventStore, NostrRelayServer, type RelayConfig, RelayError, type RelayInstance, type RelayServerConfig, RelaySubscriber, type RelaySubscriberConfig, type RelaySubscription, type ResolvedRelayConfig, SqliteEventStore, type Subscription, ToonDecodeError, ToonEncodeError, VERSION, type WriteHandler, type WriteHandlerConfig, createHealthResponse, createWriteHandler, decodeEventFromToon, encodeEventToToon, matchFilter, startRelay };
922
+ export { ConnectionHandler, DEFAULT_RELAY_CONFIG, type DurationStats, type EventStore, type HealthConfig, type HealthResponse, InMemoryEventStore, type MetricsRegistry, type MetricsSnapshot, NostrRelayServer, type RelayConfig, RelayError, type RelayInstance, type RelayServerConfig, RelaySubscriber, type RelaySubscriberConfig, type RelaySubscription, type ResolvedRelayConfig, SqliteEventStore, type Subscription, ToonDecodeError, ToonEncodeError, VERSION, type VerifyPool, type VerifyPoolOptions, type WriteHandler, type WriteHandlerConfig, createHealthResponse, createMetricsRegistry, createVerifyPool, createWriteHandler, decodeEventFromToon, defaultVerifyWorkers, encodeEventToToon, isInternalBindHost, matchFilter, readOpenFilesSoftLimit, serializeEventFrame, startRelay, verifyEventId, verifyEventSignature, verifyImplementation, warnIfWritePortExposed };