@toon-protocol/relay 2.0.1 → 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.
package/dist/index.d.ts CHANGED
@@ -4,18 +4,27 @@ import { WebSocket } from 'ws';
4
4
  import { SimplePool } from 'nostr-tools/pool';
5
5
  import { Context } from 'hono';
6
6
 
7
- /** Package version, surfaced on `GET /health`. */
8
- declare const VERSION = "0.1.0";
7
+ declare const VERSION: string;
9
8
 
10
9
  /**
11
10
  * Configuration options for the Nostr relay.
12
11
  */
13
12
  interface RelayServerConfig {
14
- /** Port to listen on (default: 7000) */
13
+ /** Port to listen on (default: 7100) */
15
14
  port: number;
16
15
  /** Host/IP to bind to (default: '0.0.0.0'). Set to '127.0.0.1' for hidden service mode. */
17
16
  host?: string;
18
- /** Maximum concurrent connections (default: 100) */
17
+ /**
18
+ * Maximum concurrent WebSocket connections (default: 4096; relay#90).
19
+ *
20
+ * Each connection costs one file descriptor plus a few KB of handler
21
+ * state, so the practical ceiling is fd-limit-shaped, not memory-shaped.
22
+ * 4096 supports several hundred-listener huddles at once (the stock 100
23
+ * made >100 listeners impossible) while leaving comfortable headroom
24
+ * under docker's default nofile limit (1048576) AND still fitting under a
25
+ * conservative 8192 ulimit; on a classic 1024 soft limit the startup
26
+ * fd-limit check logs a warning (see NostrRelayServer.start).
27
+ */
19
28
  maxConnections?: number;
20
29
  /** Maximum subscriptions per connection (default: 20) */
21
30
  maxSubscriptionsPerConnection?: number;
@@ -23,12 +32,42 @@ interface RelayServerConfig {
23
32
  maxFiltersPerSubscription?: number;
24
33
  /** Path to SQLite database file (default: ':memory:' for in-memory) */
25
34
  databasePath?: string;
35
+ /**
36
+ * Enforce NIP-40 expiration on the live broadcast path (default: true).
37
+ *
38
+ * The stored-history path is enforced by the EventStore; this flag covers
39
+ * the other way an event reaches a subscriber — the fan-out of a freshly
40
+ * written event. Both are driven from the same launcher setting so a relay
41
+ * cannot serve an expired event on one path while hiding it on the other.
42
+ */
43
+ enforceExpiration?: boolean;
26
44
  }
27
45
  /**
28
46
  * Default relay configuration values.
29
47
  */
30
48
  declare const DEFAULT_RELAY_CONFIG: Required<RelayServerConfig>;
31
49
 
50
+ /**
51
+ * Construction options shared by every EventStore implementation.
52
+ */
53
+ interface EventStoreOptions {
54
+ /**
55
+ * Enforce NIP-40 expiration at serve time (default: true).
56
+ *
57
+ * When true, events past their `expiration` tag are not returned by
58
+ * `get()` or `query()`. Set false as a KILL SWITCH: it restores the
59
+ * pre-NIP-40 behaviour of serving every stored event forever, which is the
60
+ * only recourse if enforcement ever starves discovery (see the launcher's
61
+ * `enforceExpiration` docs for the failure mode this guards against).
62
+ */
63
+ enforceExpiration?: boolean;
64
+ /**
65
+ * Operator-blocked event ids (64-char lowercase hex). Blocked events are
66
+ * refused on write and swept from storage. See `nips/blocklist.ts` for why
67
+ * this is scoped to ids and to startup configuration.
68
+ */
69
+ blockedEventIds?: Iterable<string>;
70
+ }
32
71
  /**
33
72
  * Interface for event storage backends.
34
73
  */
@@ -39,6 +78,11 @@ interface EventStore {
39
78
  get(id: string): NostrEvent | undefined;
40
79
  /** Query events matching any of the provided filters */
41
80
  query(filters: Filter[]): NostrEvent[];
81
+ /**
82
+ * Permanently drop events expired for longer than `graceSeconds` (NIP-40).
83
+ * Optional: a backend may serve-filter only. Returns rows removed.
84
+ */
85
+ reapExpired?(nowSeconds: number, graceSeconds?: number): number;
42
86
  /** Close the storage backend (optional) */
43
87
  close?(): void;
44
88
  }
@@ -48,9 +92,30 @@ interface EventStore {
48
92
  */
49
93
  declare class InMemoryEventStore implements EventStore {
50
94
  private events;
95
+ /** NIP-09 id tombstones: event id -> the pubkey that requested deletion. */
96
+ private deletedIds;
97
+ /** NIP-09 address tombstones: `<kind>:<pubkey>:<d>` -> deletion created_at. */
98
+ private deletedAddresses;
99
+ private readonly enforceExpiration;
100
+ private readonly blockedEventIds;
101
+ constructor(options?: EventStoreOptions);
51
102
  store(event: NostrEvent): void;
52
103
  get(id: string): NostrEvent | undefined;
53
104
  query(filters: Filter[]): NostrEvent[];
105
+ /**
106
+ * Drop events expired for longer than `graceSeconds` (NIP-40).
107
+ *
108
+ * @param now - Current unix time in seconds.
109
+ * @param graceSeconds - Extra time an expired event is kept.
110
+ * @returns The number of events removed.
111
+ */
112
+ reapExpired(now: number, graceSeconds?: number): number;
113
+ /** The NIP-01 addressable coordinate of an event, `<kind>:<pubkey>:<d>`. */
114
+ private static coordinateOf;
115
+ /** Whether a NIP-09 request already retracted this event (same author). */
116
+ private isRetracted;
117
+ /** Apply a kind:5 request to the author's OWN events only. */
118
+ private applyDeletion;
54
119
  /**
55
120
  * Close the storage backend (no-op for in-memory store).
56
121
  */
@@ -71,19 +136,31 @@ declare class RelayError extends Error {
71
136
  declare class SqliteEventStore implements EventStore {
72
137
  private db;
73
138
  private insertStmt;
139
+ private insertOrIgnoreStmt;
74
140
  private getStmt;
75
141
  private deleteByPubkeyKindStmt;
76
142
  private deleteByPubkeyKindDTagStmt;
77
143
  private getByPubkeyKindStmt;
78
144
  private getByPubkeyKindDTagStmt;
145
+ private tombstoneIdStmt;
146
+ private getTombstoneStmt;
147
+ private tombstoneAddressStmt;
148
+ private getAddressTombstoneStmt;
149
+ private deleteExpiredStmt;
150
+ private readonly enforceExpiration;
151
+ private readonly blockedEventIds;
79
152
  /**
80
153
  * Create a new SqliteEventStore.
81
154
  * @param dbPath - Path to the database file. Use ':memory:' for in-memory database.
155
+ * @param options - Expiry-enforcement and operator-blocklist settings.
82
156
  */
83
- constructor(dbPath?: string);
157
+ constructor(dbPath?: string, options?: EventStoreOptions);
84
158
  /**
85
159
  * Store an event in the database.
86
- * Handles replaceable and parameterized replaceable events according to NIP-01.
160
+ *
161
+ * Handles replaceable and parameterized replaceable events according to
162
+ * NIP-01, applies NIP-09 deletion requests, and refuses events the operator
163
+ * has blocked or that a previous NIP-09 request already retracted.
87
164
  */
88
165
  store(event: NostrEvent): void;
89
166
  /**
@@ -96,8 +173,64 @@ declare class SqliteEventStore implements EventStore {
96
173
  * Only keeps the latest event per pubkey+kind+d-tag.
97
174
  */
98
175
  private storeParameterizedReplaceableEvent;
176
+ /**
177
+ * Bind an event to one of the prepared INSERT statements.
178
+ *
179
+ * The `expires_at` column is derived here, at the single point every write
180
+ * funnels through, so no insert path can forget it.
181
+ */
182
+ private runInsert;
183
+ /**
184
+ * The NIP-01 addressable coordinate of an event, `<kind>:<pubkey>:<d>`.
185
+ * The `d` value is the empty string for events that carry no `d` tag —
186
+ * which is every kind:10032 announce on the network today.
187
+ */
188
+ private static coordinateOf;
189
+ /**
190
+ * Whether a NIP-09 deletion request already retracted this event, so it
191
+ * must not be re-admitted.
192
+ *
193
+ * The id tombstone only bites when the arriving event's OWN pubkey matches
194
+ * the pubkey that asked for the deletion; otherwise anyone could pre-block
195
+ * an id they merely predicted. Address tombstones already carry the
196
+ * author's pubkey inside the coordinate.
197
+ */
198
+ private isRetracted;
199
+ /**
200
+ * Apply a NIP-09 deletion request: remove the author's own targeted events
201
+ * and record tombstones so a re-publish cannot resurrect them.
202
+ *
203
+ * Every statement here is scoped by `pubkey = <the requester>`, which is
204
+ * what makes a cross-author deletion a no-op rather than a privilege.
205
+ */
206
+ private applyDeletion;
207
+ /**
208
+ * Rows for a (kind, pubkey) pair with their parsed tags, so the caller can
209
+ * compare `d` values in code. SQL cannot distinguish `["d",""]` from a
210
+ * missing `d` tag, and both mean "the empty identifier".
211
+ */
212
+ private findByCoordinate;
213
+ /**
214
+ * NIP-40 reaper: permanently delete events whose expiration is further than
215
+ * `graceSeconds` in the past.
216
+ *
217
+ * The grace window is the safety net for enforcement itself. Serve-time
218
+ * filtering is instantly reversible (flip `enforceExpiration` off and every
219
+ * still-present event is served again); a DELETE is not. Keeping recently
220
+ * expired events on disk for a while means an operator who discovers that
221
+ * enforcement broke discovery can undo it without having lost the data.
222
+ *
223
+ * @param nowSeconds - Current unix time in seconds.
224
+ * @param graceSeconds - Extra time to keep an expired event on disk.
225
+ * @returns The number of rows deleted.
226
+ */
227
+ reapExpired(nowSeconds: number, graceSeconds?: number): number;
99
228
  /**
100
229
  * Retrieve an event by its ID.
230
+ *
231
+ * Returns undefined for an event that is past its NIP-40 expiration while
232
+ * enforcement is on, even though the row may still be on disk inside the
233
+ * reaper's grace window.
101
234
  */
102
235
  get(id: string): NostrEvent | undefined;
103
236
  /**
@@ -129,6 +262,323 @@ declare class SqliteEventStore implements EventStore {
129
262
  */
130
263
  declare function matchFilter(event: NostrEvent, filter: Filter): boolean;
131
264
 
265
+ /**
266
+ * NIP-40 — "Expiration Timestamp".
267
+ *
268
+ * An event MAY carry `["expiration", "<unix seconds>"]`. Past that timestamp
269
+ * the relay SHOULD stop serving it. Until relay#137 this relay parsed no such
270
+ * tag and enforced nothing, so an announce that said "I am valid for ten
271
+ * minutes" was still handed to every discovering client a week later (two
272
+ * such kind:10032 announces — one of them a `g.toon.relay` from an identity
273
+ * that no longer exists — were live on devnet when this was written).
274
+ *
275
+ * This module is deliberately pure: parsing and the expiry predicate only.
276
+ * WHERE enforcement happens (serve-time filtering, the background reaper) is
277
+ * the storage layer's and launcher's business.
278
+ *
279
+ * @module
280
+ */
281
+
282
+ /** The NIP-40 tag name. */
283
+ declare const EXPIRATION_TAG = "expiration";
284
+ /**
285
+ * Read an event's NIP-40 expiration timestamp, in unix seconds.
286
+ *
287
+ * FAIL-OPEN on anything malformed. A tag whose value is not a non-negative
288
+ * integer (empty, `"soon"`, `"1.5"`, `"-1"`, absent) yields `undefined`, i.e.
289
+ * "never expires" — the same treatment an event with no tag at all gets.
290
+ * Dropping an event because its own author wrote a bad timestamp would turn a
291
+ * publisher-side typo into an unrecoverable read outage, and NIP-40 asks
292
+ * relays to honour a valid expiration, not to police an invalid one.
293
+ *
294
+ * The FIRST syntactically valid expiration tag wins when several are present
295
+ * (NIP-40 does not define multi-tag behaviour; the event is signed by the
296
+ * author about the author's own event, so there is no adversary to defend
297
+ * against here — only a need to be deterministic).
298
+ *
299
+ * @param event - Any Nostr event (only `tags` is read).
300
+ * @returns Unix-seconds expiry, or undefined when the event never expires.
301
+ */
302
+ declare function getExpiration(event: {
303
+ tags: string[][];
304
+ }): number | undefined;
305
+ /**
306
+ * Whether an event is past its NIP-40 expiration at `nowSeconds`.
307
+ *
308
+ * Events with no (or a malformed) expiration are never expired.
309
+ *
310
+ * @param event - The event to test.
311
+ * @param nowSeconds - Current unix time in seconds.
312
+ * @returns True when the event must no longer be served.
313
+ */
314
+ declare function isExpired(event: Pick<NostrEvent, 'tags'>, nowSeconds: number): boolean;
315
+
316
+ /**
317
+ * NIP-09 — "Event Deletion Request".
318
+ *
319
+ * A kind:5 event asks the relay to stop serving events the SAME pubkey
320
+ * published, named either by id (`e` tags) or by addressable coordinate
321
+ * (`a` tags, `<kind>:<pubkey>:<d-identifier>`).
322
+ *
323
+ * THE AUTHORIZATION RULE IS THE WHOLE NIP: a deletion request may only ever
324
+ * retract events signed by its own author. A kind:5 that names someone else's
325
+ * event id is not an error and not a partial success — the named target is
326
+ * simply not deleted. Anything looser turns a public relay into a surface
327
+ * where one key can erase another's history.
328
+ *
329
+ * This module is pure: it parses targets and answers "may this deletion
330
+ * request retract this event?". Applying that answer to stored rows (and
331
+ * remembering it, so a re-publish cannot resurrect the event) belongs to the
332
+ * storage layer.
333
+ *
334
+ * @module
335
+ */
336
+
337
+ /** The NIP-09 deletion-request kind. */
338
+ declare const DELETION_KIND = 5;
339
+ /** Whether `kind` is a NIP-09 deletion request. */
340
+ declare function isDeletionKind(kind: number): boolean;
341
+ /**
342
+ * A NIP-01 addressable coordinate, `<kind>:<pubkey>:<d-identifier>`, as
343
+ * carried by a NIP-09 `a` tag.
344
+ */
345
+ interface AddressCoordinate {
346
+ /** Event kind the coordinate addresses. */
347
+ kind: number;
348
+ /** Author pubkey (64-char lowercase hex). */
349
+ pubkey: string;
350
+ /** The `d` tag value; the empty string when the kind carries no `d`. */
351
+ identifier: string;
352
+ }
353
+ /** Targets named by a deletion request. */
354
+ interface DeletionTargets {
355
+ /** Event ids from `e` tags (64-char lowercase hex, de-duplicated). */
356
+ ids: string[];
357
+ /** Coordinates from `a` tags (de-duplicated by their raw tag value). */
358
+ addresses: AddressCoordinate[];
359
+ }
360
+ /**
361
+ * Parse an `a`-tag value into a coordinate.
362
+ *
363
+ * @param value - Raw tag value, `<kind>:<pubkey>:<d-identifier>`.
364
+ * @returns The coordinate, or undefined when the value is malformed.
365
+ */
366
+ declare function parseAddressCoordinate(value: string): AddressCoordinate | undefined;
367
+ /**
368
+ * Collect the targets a deletion request names.
369
+ *
370
+ * Malformed tags are skipped rather than failing the whole request: a client
371
+ * that emits one bad `a` tag alongside three good ones still gets the three.
372
+ *
373
+ * @param event - A kind:5 event (the kind is not re-checked here).
374
+ * @returns De-duplicated ids and coordinates.
375
+ */
376
+ declare function parseDeletionTargets(event: Pick<NostrEvent, 'tags'>): DeletionTargets;
377
+ /**
378
+ * Whether `deletion` is allowed to retract `target`.
379
+ *
380
+ * Two conditions, both required:
381
+ *
382
+ * 1. Same author. This is the trust boundary — see the module comment.
383
+ * 2. `target.created_at <= deletion.created_at`. A deletion request cannot
384
+ * pre-emptively retract a future event; without this, one kind:5 would
385
+ * permanently silence every later event a key publishes.
386
+ *
387
+ * @param target - The stored event being considered for retraction.
388
+ * @param deletion - The kind:5 deletion request.
389
+ * @returns True when the retraction is authorized.
390
+ */
391
+ declare function isDeletableBy(target: Pick<NostrEvent, 'pubkey' | 'created_at'>, deletion: Pick<NostrEvent, 'pubkey' | 'created_at'>): boolean;
392
+
393
+ /**
394
+ * Operator event blocklist — the escape hatch for litter that NEITHER NIP
395
+ * can clear.
396
+ *
397
+ * WHY THIS EXISTS. NIP-01 replacement needs the author's key. NIP-09 deletion
398
+ * needs the author's key. When a node publishes an announce and then the key
399
+ * is lost — a throwaway proof rig wiped off an operator workstation, say —
400
+ * the announce is unretractable BY CONSTRUCTION, and if it also carries no
401
+ * NIP-40 `expiration` tag then nothing in the protocol will ever remove it.
402
+ * That is exactly the state devnet was in: a kind:5094-era swap maker
403
+ * advertising `g.toon.swap.sol` at `ws://127.0.0.1:3401` — a loopback address
404
+ * that resolves to whatever machine READS it — with no expiry and no key.
405
+ *
406
+ * WHY IT IS SHAPED LIKE THIS. Any mechanism that lets an operator remove
407
+ * other people's events is a censorship surface, so the scope is drawn as
408
+ * narrowly as the job allows:
409
+ *
410
+ * - **Event ids only, never pubkeys.** Blocking a pubkey silences an
411
+ * identity's entire past and future output with one line of config.
412
+ * Blocking a 64-hex id removes exactly one event that the operator had to
413
+ * name explicitly, having already seen it. A key that is still alive can
414
+ * simply publish again, so this cannot be used to suppress a live
415
+ * participant — only to sweep a specific dead artifact.
416
+ * - **Config at startup, not an API.** There is no admin endpoint, no
417
+ * authenticated mutation, nothing network-reachable. The list arrives as
418
+ * process configuration (`TOON_BLOCKED_EVENT_IDS`) and changing it means
419
+ * restarting the process with a changed deployment — an act that lands in
420
+ * a git history and a deploy log rather than in an unlogged HTTP call.
421
+ * - **Loud.** The launcher prints every blocked id at startup. A relay that
422
+ * is withholding events should say so on every boot.
423
+ *
424
+ * A blocked id is refused on write, filtered on read, and swept from the
425
+ * database — but the block still lives only in ONE relay's configuration.
426
+ * Other relays serving the same event are unaffected, which is the correct
427
+ * outcome: this is an operator declining to carry a specific artifact, not a
428
+ * protocol-level retraction.
429
+ *
430
+ * @module
431
+ */
432
+ /**
433
+ * Parse an operator blocklist from its configuration string.
434
+ *
435
+ * Accepts a comma- and/or whitespace-separated list of 64-char hex event ids.
436
+ * Case is normalized to lowercase. Entries that are not well-formed event ids
437
+ * are reported separately rather than silently dropped: a typo in a blocklist
438
+ * must be visible, because its failure mode (an event the operator believes
439
+ * is blocked but is still being served) is silent otherwise.
440
+ *
441
+ * @param raw - Raw configuration value, or undefined.
442
+ * @returns The accepted ids and any rejected entries.
443
+ */
444
+ declare function parseBlockedEventIds(raw: string | undefined): {
445
+ ids: string[];
446
+ invalid: string[];
447
+ };
448
+
449
+ /**
450
+ * Event-signature verification for the paid-write hot path (relay#85).
451
+ *
452
+ * nostr-tools' `verifyEvent` runs BIP-340 schnorr verification in pure-JS
453
+ * noble at ~1.3ms per event -- synchronously, on the relay's single Node
454
+ * event loop. Post-#84 that verify IS the write-path ceiling: the devnet
455
+ * relay caps at ~240-260 events/s aggregate, CPU-bound (connector#685
456
+ * Phase G measurements).
457
+ *
458
+ * This module verifies with libsecp256k1 compiled to WASM (`tiny-secp256k1`,
459
+ * ~0.20ms per event on the same workload -- ~7x faster) and keeps noble as a
460
+ * fallback: if the WASM module fails to load or fails a startup self-test on
461
+ * a known-good/known-bad vector pair, verification transparently degrades to
462
+ * `verifyEvent` from nostr-tools. The relay never hard-fails because of the
463
+ * fast path. WASM was chosen over a native addon deliberately: it needs no
464
+ * platform toolchain in the Docker build, and the `secp256k1` native-addon
465
+ * package exposes no schnorr API at all.
466
+ *
467
+ * Semantics match nostr-tools `verifyEvent` -- serialize per NIP-01, SHA-256,
468
+ * compare against `event.id`, then BIP-340-verify `event.sig` -- with one
469
+ * deliberate improvement: a structurally invalid event returns `false` here
470
+ * (nostr-tools' `getEventHash` throws instead). `verifyEventSignature` never
471
+ * throws.
472
+ *
473
+ * @module
474
+ */
475
+
476
+ /**
477
+ * Which verify implementation is active. Surfaced so the launcher can log it
478
+ * once at startup (the noble fallback is a silent ~7x throughput loss
479
+ * otherwise).
480
+ */
481
+ declare const verifyImplementation: 'libsecp256k1-wasm' | 'noble-pure-js';
482
+ /**
483
+ * Verify ONLY that `event.id` is the correct NIP-01 SHA-256 hash of the
484
+ * event's serialized form. Does NOT check the schnorr signature and does NOT
485
+ * stamp nostr-tools' `verifiedSymbol` cache (an id check is not a signature
486
+ * verdict).
487
+ *
488
+ * This is the integrity floor for the paid-ephemeral skip-verify path
489
+ * (relay#85): when the relay skips schnorr for payment-gated ephemeral kinds,
490
+ * it still refuses events whose id does not match their content, so a paid
491
+ * writer cannot make the relay broadcast a frame whose bytes disagree with
492
+ * the id that clients index/verify by.
493
+ *
494
+ * Never throws; structurally invalid events return false.
495
+ *
496
+ * @param event - The event whose id to check.
497
+ * @returns True iff `event.id` equals the NIP-01 SHA-256 of the event.
498
+ */
499
+ declare function verifyEventId(event: NostrEvent): boolean;
500
+ /**
501
+ * Verify a Nostr event's id and BIP-340 signature.
502
+ *
503
+ * Drop-in replacement for nostr-tools' `verifyEvent` on the write hot path:
504
+ * same accept/reject semantics for well-formed events, ~7x faster via WASM
505
+ * libsecp256k1 when available, noble pure-JS otherwise. Never throws;
506
+ * structurally invalid events return false.
507
+ *
508
+ * @param event - The event to verify.
509
+ * @returns True iff the id matches the NIP-01 hash and the signature is valid.
510
+ */
511
+ declare function verifyEventSignature(event: NostrEvent): boolean;
512
+
513
+ /**
514
+ * Worker-thread pool for event-signature verification (relay#85).
515
+ *
516
+ * Post-#87 the WASM verify takes ~0.2ms per event -- but it still runs ON the
517
+ * single Node event loop, which also carries every WebSocket broadcast.
518
+ * Agent writers make persistent-kind write rates potentially bursty and high;
519
+ * a verify burst on the loop is exactly the kind of stall that shows up as
520
+ * tail jitter on ephemeral (huddle-frame) latency. The pool moves persistent
521
+ * -kind schnorr verification onto worker threads so verify bursts cannot
522
+ * stall the loop, at the cost of one thread-hop per verified event.
523
+ *
524
+ * Shape:
525
+ * - `size` workers (default `max(0, os.cpus().length - 1)`); each worker
526
+ * imports `verify-event.ts` and therefore instantiates its OWN WASM
527
+ * libsecp256k1 (same self-test + noble fallback semantics as inline).
528
+ * - `size: 0` -- automatic on 1-core boxes, and the explicit config escape
529
+ * hatch (TOON_VERIFY_WORKERS=0) -- keeps the current inline path: `verify`
530
+ * resolves synchronously-computed results, no threads are created.
531
+ * - Dispatch is least-busy; per-call results resolve independently.
532
+ * ORDERING: results for CONCURRENT calls may settle out of submission
533
+ * order. The write path stays correct because the upstream connector
534
+ * serializes each BTP session's POSTs (next request only after the
535
+ * previous response) -- pinned by tests in write-handler.test.ts.
536
+ * - Worker failure degrades transparently: pending and future verifies fall
537
+ * back to the inline implementation (the pool never hard-fails a write).
538
+ * - Hand-rolled on `node:worker_threads` -- ~100 lines beats a piscina
539
+ * dependency in a package whose runtime deps are deliberately minimal.
540
+ *
541
+ * @module
542
+ */
543
+
544
+ /** A pool that verifies event signatures off the main event loop. */
545
+ interface VerifyPool {
546
+ /**
547
+ * Verify an event's id + BIP-340 signature. Never rejects; invalid or
548
+ * structurally broken events resolve `false`. Semantics match
549
+ * `verifyEventSignature`, including stamping the nostr-tools
550
+ * verified-event cache symbol on the caller's object.
551
+ */
552
+ verify(event: NostrEvent): Promise<boolean>;
553
+ /** Live worker count (0 = inline path). */
554
+ readonly size: number;
555
+ /** Terminate all workers. Idempotent. Pending verifies resolve inline. */
556
+ destroy(): Promise<void>;
557
+ }
558
+ /**
559
+ * Default pool size: one worker per CPU minus one core reserved for the
560
+ * event loop (WS fan-out + HTTP). On a 1-core box this is 0 -- the inline
561
+ * path -- because a worker would only add thread-hop overhead while
562
+ * competing for the same core.
563
+ */
564
+ declare function defaultVerifyWorkers(): number;
565
+ /** Options for {@link createVerifyPool}. */
566
+ interface VerifyPoolOptions {
567
+ /** Worker count (default {@link defaultVerifyWorkers}; 0 = inline). */
568
+ size?: number;
569
+ /**
570
+ * Called with the wall-clock milliseconds of each verify -- including
571
+ * pool queue + thread-hop time, i.e. the latency a write actually paid.
572
+ * The launcher wires this into the /metrics registry.
573
+ */
574
+ onMeasure?: (ms: number) => void;
575
+ }
576
+ /**
577
+ * Create a verify pool. See the module doc for semantics; see
578
+ * {@link VerifyPoolOptions} for knobs.
579
+ */
580
+ declare function createVerifyPool(options?: VerifyPoolOptions): VerifyPool;
581
+
132
582
  /**
133
583
  * Represents an active subscription from a client.
134
584
  */
@@ -138,6 +588,21 @@ interface Subscription {
138
588
  /** Filters applied to this subscription */
139
589
  filters: Filter[];
140
590
  }
591
+ /**
592
+ * Build a NIP-01 EVENT frame from an ALREADY-SERIALIZED event payload,
593
+ * splicing in the (JSON-escaped) subscription id.
594
+ *
595
+ * Byte-identical to `JSON.stringify(['EVENT', subscriptionId, event])` --
596
+ * pinned by tests -- but lets the broadcast fan-out serialize the event
597
+ * ONCE and reuse the string across every matching subscriber (relay#91:
598
+ * 500 subscribers previously meant 500 identical `JSON.stringify(event)`
599
+ * calls per frame, measured pinning a core in the s500 benchmark run).
600
+ *
601
+ * @param subscriptionId - The per-subscriber NIP-01 subscription id.
602
+ * @param eventJson - `JSON.stringify(event)` output to reuse.
603
+ * @returns The full EVENT frame string for the wire.
604
+ */
605
+ declare function serializeEventFrame(subscriptionId: string, eventJson: string): string;
141
606
  /**
142
607
  * Handles NIP-01 messages for a single WebSocket connection.
143
608
  */
@@ -170,8 +635,15 @@ declare class ConnectionHandler {
170
635
  /**
171
636
  * Push a new event to all matching subscriptions on this connection.
172
637
  * Used when events are stored outside the WebSocket flow (e.g., via ILP).
638
+ *
639
+ * @param event - The event to fan out (used for filter matching).
640
+ * @param eventJson - Optional pre-serialized `JSON.stringify(event)`.
641
+ * `NostrRelayServer.broadcastEvent` serializes the event ONCE and passes
642
+ * it here so a 500-subscriber fan-out costs one serialization, not 500
643
+ * (relay#91). When omitted (direct callers), the event is serialized
644
+ * on first matching send.
173
645
  */
174
- notifyNewEvent(event: NostrEvent): void;
646
+ notifyNewEvent(event: NostrEvent, eventJson?: string): void;
175
647
  /**
176
648
  * Clean up all subscriptions for this connection.
177
649
  */
@@ -188,14 +660,24 @@ declare class ConnectionHandler {
188
660
  * with the event as a plain JSON object — so any standard nostr client can
189
661
  * parse it and verify `id`/`sig` from the wire bytes (#46). Never re-encode
190
662
  * the event (TOON text, double-JSON-stringify, etc.) at this boundary.
663
+ * serializeEventFrame is byte-identical to the full JSON.stringify
664
+ * envelope (pinned by tests).
191
665
  */
192
666
  private sendEvent;
193
667
  private sendEose;
194
668
  private sendOk;
195
669
  private sendNotice;
670
+ /** Send a message: pre-serialized frames go out as-is (relay#91). */
196
671
  private send;
197
672
  }
198
673
 
674
+ /**
675
+ * Read this process's soft "Max open files" limit from /proc/self/limits.
676
+ * Returns null off-Linux or on any parse failure (the check is advisory).
677
+ *
678
+ * @internal Exported for unit testing.
679
+ */
680
+ declare function readOpenFilesSoftLimit(read?: (path: string) => string): number | null;
199
681
  /**
200
682
  * A NIP-01 compliant Nostr relay WebSocket server.
201
683
  * Handles client connections and routes messages to ConnectionHandlers.
@@ -227,44 +709,17 @@ declare class NostrRelayServer {
227
709
  * Broadcast an event to all connected clients with matching subscriptions.
228
710
  * Call this after storing an event outside the WebSocket flow (e.g., via ILP)
229
711
  * so that discovery subscribers are notified.
712
+ *
713
+ * Serialize-once fan-out (relay#91): the event payload is stringified ONE
714
+ * time here and reused for every matching subscriber -- only the small
715
+ * per-subscription `["EVENT",<subId>,...]` envelope is spliced per send.
716
+ * Previously each of N subscribers re-serialized the identical event
717
+ * (N=500 pinned a core doing 500 identical stringifies per frame).
230
718
  */
231
719
  broadcastEvent(event: NostrEvent): void;
232
720
  private handleConnection;
233
721
  }
234
722
 
235
- /**
236
- * TOON event codec.
237
- *
238
- * Encodes/decodes Nostr events to and from the TOON text format. Vendored from
239
- * `@toon-protocol/core` so the relay depends only on the lightweight
240
- * `@toon-format/toon` encoder rather than core's full transitive tree (which
241
- * pulls Arweave / web3 wallet stacks the relay does not use). Same MIT
242
- * license / org.
243
- *
244
- * NOTE: this codec is NOT used on the relay's NIP-01 read surface. Outbound
245
- * EVENT frames are canonical NIP-01 JSON (see
246
- * `websocket/ConnectionHandler.sendEvent`, #46) so that standard nostr clients
247
- * can parse events and verify signatures from the wire. The codec remains
248
- * exported for library consumers that exchange TOON-text events elsewhere.
249
- *
250
- * @module
251
- */
252
-
253
- /** Thrown when a Nostr event cannot be encoded to TOON. */
254
- declare class ToonEncodeError extends Error {
255
- readonly code = "TOON_ENCODE_ERROR";
256
- constructor(message: string, cause?: Error);
257
- }
258
- /** Thrown when TOON data cannot be decoded into a valid Nostr event. */
259
- declare class ToonDecodeError extends Error {
260
- readonly code = "TOON_DECODE_ERROR";
261
- constructor(message: string, cause?: Error);
262
- }
263
- /** Encode a Nostr event to TOON bytes (UTF-8). */
264
- declare function encodeEventToToon(event: NostrEvent): Uint8Array;
265
- /** Decode TOON bytes into a validated Nostr event. */
266
- declare function decodeEventFromToon(data: Uint8Array): NostrEvent;
267
-
268
723
  /**
269
724
  * Subscribe to upstream relays and propagate events into the local EventStore.
270
725
  *
@@ -320,12 +775,23 @@ declare class RelaySubscriber {
320
775
  * time a write reaches this process it is already proven paid, so the relay
321
776
  * simply stores the event and serves reads.
322
777
  *
323
- * Two surfaces:
778
+ * Three surfaces:
324
779
  *
325
- * - `POST /write` (TOON_BLS_PORT, default 3100): accepts `{ event }` as JSON,
326
- * trusts the injected `X-TOON-Payer`/`-Amount`/`-Chain` headers WITHOUT
327
- * re-validating payment, verifies only the event's own signature for
328
- * integrity, and stores it. `GET /health` lives on the same port.
780
+ * - `POST /write` (TOON_BLS_PORT, default 3100): accepts `{ event }` as JSON.
781
+ * By the time a request reaches this surface it is already proven paid;
782
+ * the terminating connector asserts nothing about that payment to this
783
+ * relay -- no payer, amount, or chain (`toon-protocol/connector` ADR
784
+ * 0036) -- so the handler verifies only the event's own signature for
785
+ * integrity (paid ephemeral kinds skip schnorr by default and keep the
786
+ * id check -- relay#85, see `verifyEphemeral`), and stores it.
787
+ * `GET /health` and `GET /metrics` live on the same port.
788
+ * - `POST /write-ephemeral` (same port, relay#129): the FREE ephemeral
789
+ * write lane. Accepts only ephemeral kinds (NIP-16, 20000 <= kind <
790
+ * 30000), always runs FULL schnorr verification (no skip -- this lane
791
+ * has no payment gate), and never stores. Bounded by a per-key rate
792
+ * limit and a body-size cap (see `ephemeralRateLimit` /
793
+ * `ephemeralMaxBodyBytes`). Terminated at the connector by its own
794
+ * zero-priced route (`deploy/connector.toml`'s `g.toon.relay.ephemeral`).
329
795
  * - Free NIP-01 WebSocket reads (TOON_RELAY_PORT, default 7100).
330
796
  *
331
797
  * `startRelay()` returns a `RelayInstance` with an explicit `.stop()` for
@@ -354,6 +820,25 @@ interface RelayConfig {
354
820
  * port to localhost only (e.g. when an upstream proxy handles inbound).
355
821
  */
356
822
  host?: string;
823
+ /**
824
+ * Bind host for the HTTP write/health listener (default: 0.0.0.0).
825
+ *
826
+ * The write port MUST only be reachable via the payment-gating connector
827
+ * (see `verifyEphemeral`): in the canonical compose deploy that is enforced
828
+ * by NOT host-publishing the port (docker `expose:`, never `ports:` --
829
+ * note that docker `ports:` publishes bypass ufw). When the relay runs
830
+ * directly on a host, bind this to a loopback/internal address instead.
831
+ * A non-internal bind while the ephemeral verify skip is active logs a
832
+ * prominent startup warning (never a hard failure -- topologies vary).
833
+ */
834
+ writeHost?: string;
835
+ /**
836
+ * Maximum concurrent WebSocket read connections (default: 4096; env:
837
+ * TOON_MAX_CONNECTIONS). Connections beyond the cap are closed with 1013.
838
+ * Fd-limit-shaped, not memory-shaped -- see RelayServerConfig
839
+ * .maxConnections for the sizing reasoning (relay#90).
840
+ */
841
+ maxConnections?: number;
357
842
  /** Data directory for the file-backed SQLite store (default: ./data). */
358
843
  dataDir?: string;
359
844
  /**
@@ -363,8 +848,99 @@ interface RelayConfig {
363
848
  * caller owns its lifecycle when supplied.
364
849
  */
365
850
  eventStore?: EventStore;
851
+ /**
852
+ * Enforce NIP-40 expiration (default: true; env: TOON_ENFORCE_EXPIRATION).
853
+ *
854
+ * When true, an event past its `expiration` tag is not served from history
855
+ * and not fanned out live. Setting this false is the KILL SWITCH back to
856
+ * the pre-relay#137 behaviour of serving everything forever.
857
+ *
858
+ * ! READ THIS BEFORE ASSUMING THE DEFAULT IS FREE !
859
+ * On the TOON devnet the only events carrying an `expiration` tag are
860
+ * kind:10032 node announces, published with a 600s TTL and refreshed by a
861
+ * shell loop every 240s — 2.5 refresh periods of margin, and the loop's
862
+ * failure backoff (5s doubling, capped at 240s) needs SEVEN consecutive
863
+ * failed publishes before an announce goes past its expiry. That margin is
864
+ * comfortable, but it is a margin, not a guarantee: the store and swap
865
+ * announce loops PAY for each republish out of a payment channel, so a
866
+ * drained channel makes every republish fail indefinitely. Before this
867
+ * flag existed, that failure degraded to "a stale announce is still
868
+ * served"; with enforcement on, it becomes "the node vanishes from
869
+ * discovery". That is the intended semantics — a node that cannot afford
870
+ * to say it is alive should not be advertised — but it is a real change in
871
+ * blast radius, and this flag is how an operator buys time.
872
+ */
873
+ enforceExpiration?: boolean;
874
+ /**
875
+ * How long an expired event is kept on disk before the reaper deletes it
876
+ * (default: 86400 = 24h; env: TOON_EXPIRATION_REAP_GRACE_SECONDS).
877
+ *
878
+ * Serve-time filtering is reversible; a DELETE is not. The grace window is
879
+ * what makes flipping `enforceExpiration` back off an actual recovery
880
+ * rather than an apology. Set 0 to reap the moment an event expires.
881
+ */
882
+ expirationReapGraceSeconds?: number;
883
+ /**
884
+ * How often the reaper sweeps (default: 3600 = hourly; env:
885
+ * TOON_EXPIRATION_REAP_INTERVAL_SECONDS). 0 disables reaping entirely;
886
+ * serve-time filtering is unaffected.
887
+ */
888
+ expirationReapIntervalSeconds?: number;
889
+ /**
890
+ * Operator-blocked event ids (env: TOON_BLOCKED_EVENT_IDS, comma-separated).
891
+ *
892
+ * The narrow escape hatch for an event that neither NIP-01 replacement nor
893
+ * NIP-09 deletion can reach because its author's key is gone. Ids only,
894
+ * never pubkeys; startup configuration only, never an API. See
895
+ * `nips/blocklist.ts` for the full reasoning and the censorship hazard it
896
+ * is drawn around.
897
+ */
898
+ blockedEventIds?: string[];
366
899
  /** Skip event-signature verification on `POST /write` (default: false). */
367
900
  devMode?: boolean;
901
+ /**
902
+ * Run FULL schnorr verification on ephemeral kinds (default: false -- the
903
+ * paid-ephemeral verify skip is ON by default, relay#85).
904
+ *
905
+ * The default skip is safe ONLY because `POST /write` is payment-gated by
906
+ * the upstream connector and clients verify every signature themselves;
907
+ * the SHA-256 event-id check always runs. Community operators fronting the
908
+ * write port with anything other than a payment-gating connector should
909
+ * set this to true (env: TOON_VERIFY_EPHEMERAL=true). See
910
+ * `WriteHandlerConfig.verifyEphemeral` for the full invariant.
911
+ */
912
+ verifyEphemeral?: boolean;
913
+ /**
914
+ * Worker-thread verify pool size for persistent-kind signature
915
+ * verification (default: `max(0, os.cpus().length - 1)`; env:
916
+ * TOON_VERIFY_WORKERS). `0` -- automatic on 1-core boxes -- is the inline
917
+ * escape hatch: verification runs synchronously on the event loop as
918
+ * before. Workers keep bursty agent-writer verify load from stalling the
919
+ * loop and jittering ephemeral frame latency (relay#85).
920
+ */
921
+ verifyWorkers?: number;
922
+ /**
923
+ * Per-key sliding-window rate limit for `POST /write-ephemeral` (default:
924
+ * 200 requests / 10s; env: TOON_EPHEMERAL_RATE_LIMIT /
925
+ * TOON_EPHEMERAL_RATE_WINDOW_MS). This lane has no payment gate, so this
926
+ * bound (plus `ephemeralMaxBodyBytes`) IS its admission control -- see
927
+ * `EphemeralWriteHandlerConfig` for the full reasoning.
928
+ */
929
+ ephemeralRateLimit?: {
930
+ maxRequests: number;
931
+ windowMs: number;
932
+ };
933
+ /**
934
+ * Request-body size cap in bytes for `POST /write-ephemeral` (default:
935
+ * 8192; env: TOON_EPHEMERAL_MAX_BODY_BYTES).
936
+ */
937
+ ephemeralMaxBodyBytes?: number;
938
+ /**
939
+ * Log one line per accepted `POST /write` (default: false). Per-event
940
+ * console I/O is measurable tail jitter on the write hot path (relay#85),
941
+ * so this is a debug switch, not an access log.
942
+ */
943
+ logWrites?: boolean;
368
944
  }
369
945
  /**
370
946
  * Resolved configuration with all defaults applied.
@@ -373,8 +949,22 @@ interface ResolvedRelayConfig {
373
949
  relayPort: number;
374
950
  blsPort: number;
375
951
  host: string;
952
+ writeHost: string;
953
+ maxConnections: number;
376
954
  dataDir: string;
377
955
  devMode: boolean;
956
+ verifyEphemeral: boolean;
957
+ verifyWorkers: number;
958
+ ephemeralRateLimit: {
959
+ maxRequests: number;
960
+ windowMs: number;
961
+ };
962
+ ephemeralMaxBodyBytes: number;
963
+ logWrites: boolean;
964
+ enforceExpiration: boolean;
965
+ expirationReapGraceSeconds: number;
966
+ expirationReapIntervalSeconds: number;
967
+ blockedEventIds: string[];
378
968
  }
379
969
  /**
380
970
  * A running TOON relay node instance returned by `startRelay()`.
@@ -411,6 +1001,40 @@ interface RelaySubscription {
411
1001
  /** Whether this subscription is still active. */
412
1002
  isActive(): boolean;
413
1003
  }
1004
+ /**
1005
+ * Whether `host` is a bind address that cannot be reached from the public
1006
+ * internet directly: loopback, RFC1918 private, IPv6 unique-local/link-local.
1007
+ * `0.0.0.0` / `::` (all interfaces) and public addresses return false.
1008
+ *
1009
+ * Used by the startup exposure guard: with the paid-ephemeral verify skip
1010
+ * active, the write port must only be reachable via the payment-gating
1011
+ * connector. Note a "false" here is not proof of exposure -- inside a
1012
+ * container, 0.0.0.0 is required for the connector to dial the compose
1013
+ * network and the port is kept private by not host-publishing it -- which is
1014
+ * why the guard warns instead of failing.
1015
+ *
1016
+ * @internal Exported for unit testing.
1017
+ */
1018
+ declare function isInternalBindHost(host: string): boolean;
1019
+ /**
1020
+ * Log the prominent write-port exposure warning when the paid-ephemeral
1021
+ * verify skip is active and the write listener binds a non-internal
1022
+ * interface. Deliberately a warning, not a hard failure: in the canonical
1023
+ * compose deploy the port binds 0.0.0.0 inside the container and is private
1024
+ * because it is never host-published (docker `expose:`, not `ports:`).
1025
+ *
1026
+ * `POST /write-ephemeral` (relay#129) shares this same port/host but needs
1027
+ * no exposure check of its own: it always runs full verification (no skip
1028
+ * for exposure to weaken) and enforces its own rate limit/size cap
1029
+ * regardless of how a request reaches it. This guard's warning is scoped to
1030
+ * the paid-write skip above, which stays the one exposure risk on this port.
1031
+ *
1032
+ * @internal Exported for unit testing.
1033
+ */
1034
+ declare function warnIfWritePortExposed(writeHost: string, blsPort: number, options: {
1035
+ verifyEphemeral: boolean;
1036
+ devMode: boolean;
1037
+ }): boolean;
414
1038
  /**
415
1039
  * Start a TOON relay node with the given configuration.
416
1040
  *
@@ -464,26 +1088,151 @@ interface HealthResponse {
464
1088
  */
465
1089
  declare function createHealthResponse(config: HealthConfig): HealthResponse;
466
1090
 
1091
+ /**
1092
+ * Metrics for the relay's HTTP telemetry surface (relay#85).
1093
+ *
1094
+ * Served as JSON from `GET /metrics` on the write/health port, next to
1095
+ * `/health`. Two families, chosen because they are the TRIGGER METRICS for
1096
+ * future scaling decisions (2026-08-02 benchmarking, toon-meta
1097
+ * proto/spacetimedb-relay RESULTS.md):
1098
+ *
1099
+ * - **Event-loop delay**: the single Node loop carries every WS broadcast;
1100
+ * loop lag IS ephemeral (huddle-frame) tail latency. Sustained p99 growth
1101
+ * here is the signal to shed load or scale out.
1102
+ * - **Per-event verify time**: wall-clock ms per signature verification as
1103
+ * the write actually paid it (including verify-pool queue + thread-hop
1104
+ * when workers are enabled). Growth here is the signal to resize the
1105
+ * verify pool (TOON_VERIFY_WORKERS) or move boxes.
1106
+ *
1107
+ * The registry is deliberately dependency-free: `monitorEventLoopDelay`
1108
+ * from node:perf_hooks plus a fixed-size ring of recent verify durations
1109
+ * (percentiles over the last {@link VERIFY_WINDOW} samples -- bounded
1110
+ * memory, O(window log window) only when a snapshot is requested).
1111
+ *
1112
+ * @module
1113
+ */
1114
+ /** Aggregates for one duration family, in milliseconds. */
1115
+ interface DurationStats {
1116
+ /** Total samples recorded since startup. */
1117
+ count: number;
1118
+ /** Mean over ALL samples since startup. */
1119
+ meanMs: number;
1120
+ /** Max over ALL samples since startup. */
1121
+ maxMs: number;
1122
+ /** Median over the most recent window (up to {@link VERIFY_WINDOW}). */
1123
+ p50Ms: number;
1124
+ /** 99th percentile over the most recent window. */
1125
+ p99Ms: number;
1126
+ }
1127
+ /** The `GET /metrics` response shape. */
1128
+ interface MetricsSnapshot {
1129
+ timestamp: number;
1130
+ /**
1131
+ * Event-loop delay in ms (node:perf_hooks monitorEventLoopDelay since the
1132
+ * last snapshot reset -- lifetime of the process unless noted). `mean`,
1133
+ * `p50`, `p99`, `max` -- loop lag is ephemeral-frame tail latency.
1134
+ */
1135
+ eventLoopDelayMs: {
1136
+ mean: number;
1137
+ p50: number;
1138
+ p99: number;
1139
+ max: number;
1140
+ };
1141
+ /** Per-event signature-verify timing (trigger metric for pool sizing). */
1142
+ verify: {
1143
+ /** Active implementation: 'libsecp256k1-wasm' or 'noble-pure-js'. */
1144
+ implementation: string;
1145
+ /** Verify-pool worker count (0 = inline on the event loop). */
1146
+ workers: number;
1147
+ } & DurationStats;
1148
+ /**
1149
+ * Free ephemeral write lane (relay#129, `POST /write-ephemeral`). Always
1150
+ * present -- the lane is always mounted -- and static for the process
1151
+ * lifetime: it has no payment gate, so these bounds ARE its admission
1152
+ * control, and the acceptance criteria requires them to be visible here
1153
+ * alongside the startup log line.
1154
+ */
1155
+ ephemeralWriteLane: {
1156
+ enabled: true;
1157
+ rateLimit: {
1158
+ maxRequests: number;
1159
+ windowMs: number;
1160
+ };
1161
+ maxBodyBytes: number;
1162
+ };
1163
+ }
1164
+ /** Live registry behind `GET /metrics`. */
1165
+ interface MetricsRegistry {
1166
+ /** Record one verify duration in milliseconds. */
1167
+ recordVerify(ms: number): void;
1168
+ /** Build the current snapshot (cheap; safe to poll). */
1169
+ snapshot(): MetricsSnapshot;
1170
+ /** Update the reported worker count (pool may degrade at runtime). */
1171
+ setVerifyWorkers(workers: number): void;
1172
+ /** Disable the loop-delay histogram (call on relay stop). */
1173
+ stop(): void;
1174
+ }
1175
+ /**
1176
+ * Create the metrics registry. One per relay instance; the launcher wires
1177
+ * `recordVerify` into the verify pool's `onMeasure` and serves `snapshot()`
1178
+ * from `GET /metrics`.
1179
+ *
1180
+ * @param info - Static verify metadata + ephemeral-lane bounds surfaced in
1181
+ * the snapshot (relay#129).
1182
+ */
1183
+ declare function createMetricsRegistry(info: {
1184
+ verifyImplementation: string;
1185
+ verifyWorkers: number;
1186
+ ephemeralRateLimit: {
1187
+ maxRequests: number;
1188
+ windowMs: number;
1189
+ };
1190
+ ephemeralMaxBodyBytes: number;
1191
+ }): MetricsRegistry;
1192
+
467
1193
  /**
468
1194
  * Write handler for @toon-protocol/relay.
469
1195
  *
470
- * Exposes a plain-HTTP write surface that accepts an event-as-JSON, trusts
471
- * (but does NOT validate) injected payment headers, verifies ONLY the event
472
- * signature for integrity, and stores the event.
1196
+ * Exposes a plain-HTTP write surface that accepts an event-as-JSON, verifies
1197
+ * ONLY the event signature for integrity, and stores the event.
473
1198
  *
474
1199
  * This handler is intentionally decoupled from any payment layer: it contains
475
1200
  * no claim/settlement/ILP logic and imports none of it. Payment validation is
476
- * the upstream terminator's concern; by the time a request reaches this surface
477
- * the trusted `X-TOON-*` headers are assumed already proven. The handler
478
- * captures them purely for the response echo and a log line.
1201
+ * the upstream terminator's concern; by the time a request reaches this
1202
+ * surface it is already proven paid.
1203
+ *
1204
+ * What the terminator DOES state, it records. A terminating connector states
1205
+ * `X-TOON-Payer` / `X-TOON-Amount` / `X-TOON-Chain` on a delivery whose
1206
+ * payment it verified at its own client edge (`toon-protocol/connector`
1207
+ * ADR 0040, relay#133); the handler reads them, echoes a well-formed triple
1208
+ * back on the 200, and treats their absence as "this hop was not the one
1209
+ * paid" -- NEVER as "unpaid". See payment-attribution.ts for the contract and
1210
+ * for why the same header names were right to distrust before ADR 0040.
479
1211
  *
480
1212
  * Flow:
481
1213
  * 1. Parse JSON body `{ event }` -> 400 on malformed/missing event
482
- * 2. Capture trusted X-TOON-Payer / X-TOON-Amount / X-TOON-Chain headers
483
- * 3. Verify the event signature (skipped in devMode) -> 422 on invalid sig
484
- * 4. Store the event in the EventStore
485
- * 5. Fire the optional onStored callback
486
- * 6. Respond 200 with the event id, storedAt timestamp, and echoed headers
1214
+ * 2. Verify the event signature (skipped in devMode) -> 422 on invalid sig.
1215
+ * Verification uses the fast WASM libsecp256k1 path (crypto/verify-event)
1216
+ * rather than noble pure-JS: post-#84 the synchronous ~1.3ms noble verify
1217
+ * on the single event loop WAS the write-path ceiling (~240-260 events/s
1218
+ * aggregate, relay#85 / connector#685 Phase G).
1219
+ * PAID-EPHEMERAL EXCEPTION (relay#85, decision 2026-08-02): for ephemeral
1220
+ * kinds (20000 <= kind < 30000) the schnorr verification is SKIPPED by
1221
+ * default -- only the SHA-256 id check runs -> 422 on id mismatch. See
1222
+ * the loud comment at the verify step for why this is safe, and
1223
+ * `verifyEphemeral` to turn full verification back on.
1224
+ * 3. Store the event in the EventStore -- unless its kind is ephemeral
1225
+ * (NIP-16: 20000 <= kind < 30000), which is delivered live and never
1226
+ * persisted. Skipping the store here is not only NIP-16 semantics: the
1227
+ * synchronous per-event disk write was the serialization point that
1228
+ * capped the whole paid-write pipeline at ~150 events/s globally
1229
+ * (connector#685), and ephemeral traffic -- audio frames -- is exactly
1230
+ * the traffic that hits that path hardest.
1231
+ * 4. Fire the optional onStored callback (ephemeral events included: it is
1232
+ * the live-broadcast hook, and ephemeral events exist only as that
1233
+ * broadcast)
1234
+ * 5. Respond 200 with the event id, storedAt timestamp, and -- when the
1235
+ * connector stated one -- the payment it attributed the write to
487
1236
  *
488
1237
  * @module
489
1238
  */
@@ -496,8 +1245,52 @@ interface WriteHandlerConfig {
496
1245
  eventStore: EventStore;
497
1246
  /** Whether dev mode is enabled (skips Schnorr signature verification). */
498
1247
  devMode: boolean;
1248
+ /**
1249
+ * Run FULL schnorr verification on ephemeral kinds too (default: false --
1250
+ * i.e. the paid-ephemeral verify skip is ON by default).
1251
+ *
1252
+ * ! SECURITY INVARIANT -- READ BEFORE TOUCHING !
1253
+ * The default skip is safe ONLY because this write surface is payment-gated:
1254
+ * every request reaching `POST /write` has already passed the upstream
1255
+ * connector's claim gate (payment IS the admission/spam gate), and the
1256
+ * protocol rule is that clients trust the signature chain and verify every
1257
+ * event themselves -- never the relay. Relay-side schnorr on ephemeral
1258
+ * frames is therefore pure spam defense that payment already provides;
1259
+ * forging a speaker costs real money to emit frames every client discards.
1260
+ * The SHA-256 id check is ALWAYS kept (see handleWrite).
1261
+ *
1262
+ * If you ever add a FREE (non-payment-gated) ephemeral write lane, it MUST
1263
+ * NOT reuse this skip -- free spam with valid-looking ids would be
1264
+ * broadcast to every subscriber. Community operators who front this port
1265
+ * with anything other than a payment-gating connector should set
1266
+ * `verifyEphemeral: true` (TOON_VERIFY_EPHEMERAL=true).
1267
+ */
1268
+ verifyEphemeral?: boolean;
1269
+ /**
1270
+ * Signature verifier for non-skipped (persistent-kind) events. Defaults to
1271
+ * the inline `verifyEventSignature`; the launcher injects the worker-pool
1272
+ * verifier (`crypto/verify-pool.ts`) so verify bursts run off the event
1273
+ * loop (relay#85). May resolve asynchronously -- the handler awaits it.
1274
+ *
1275
+ * ORDERING NOTE: an async verifier means CONCURRENT requests can complete
1276
+ * out of arrival order. Per-session write ordering is enforced UPSTREAM:
1277
+ * the connector serializes each BTP session's POSTs (it does not send the
1278
+ * next request until the previous response arrives), so sequential
1279
+ * same-session writes can never reorder here. Pinned by the ordering test
1280
+ * in write-handler.test.ts -- do not weaken that contract upstream without
1281
+ * revisiting this.
1282
+ */
1283
+ verifyEvent?: (event: NostrEvent) => Promise<boolean> | boolean;
499
1284
  /** Optional callback fired after an event is successfully stored. */
500
1285
  onStored?: (event: NostrEvent) => void;
1286
+ /**
1287
+ * Log one line per accepted write (default: false). Off by default because
1288
+ * per-event console I/O on the single event loop is measurable tail jitter
1289
+ * at huddle frame rates (relay#85, connector#685 Phase G): every write's
1290
+ * log line goes through docker's json-file driver, i.e. residual per-event
1291
+ * disk I/O that #84 did not remove.
1292
+ */
1293
+ logWrites?: boolean;
501
1294
  }
502
1295
  /**
503
1296
  * Write handler instance.
@@ -514,4 +1307,227 @@ interface WriteHandler {
514
1307
  */
515
1308
  declare function createWriteHandler(config: WriteHandlerConfig): WriteHandler;
516
1309
 
517
- 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 };
1310
+ /**
1311
+ * The connector's payment statement, as read off a delivery to `POST /write`.
1312
+ *
1313
+ * A terminating connector states three headers on a delivery whose payment it
1314
+ * verified at its OWN client edge (`toon-protocol/connector` ADR 0040):
1315
+ *
1316
+ * | header | value |
1317
+ * | --------------- | ----------------------------------------------------------- |
1318
+ * | `X-TOON-Payer` | `evm:0x<64 hex>` or `solana:<base58>` -- the client CHANNEL |
1319
+ * | | key whose covering claim that connector verified |
1320
+ * | `X-TOON-Amount` | the route's flat price (ADR 0020), decimal, base units |
1321
+ * | `X-TOON-Chain` | that key's namespace -- `evm` or `solana` |
1322
+ *
1323
+ * ! ABSENCE IS NOT "UNPAID" ! The headers are present ONLY when this
1324
+ * connector was the hop that took the payment and the route's price is
1325
+ * non-zero. They are absent -- not empty -- on a peer-wire arrival, a
1326
+ * forwarded packet, and every `price = 0` route (which is why the free
1327
+ * ephemeral lane never sees them). A handler that read absence as "nobody
1328
+ * paid" would reject exactly the deliveries a longer path produced.
1329
+ *
1330
+ * The relay does not, and cannot, re-validate any of this: it holds no chain
1331
+ * state and speaks no ILP. The connector's statement IS the trust model. What
1332
+ * this module adds is that a malformed statement is treated as no statement
1333
+ * at all -- a garbled value from a stale or hostile caller becomes `undefined`
1334
+ * rather than something the relay records and echoes as fact.
1335
+ *
1336
+ * History worth not repeating: relay#122 removed the earlier reading of these
1337
+ * same header NAMES, correctly, because the TypeScript-era connector set
1338
+ * `X-TOON-Payer` to the PREVIOUS HOP -- which on any path longer than one hop
1339
+ * named the wrong party. ADR 0040's successor is a chain-verified channel key
1340
+ * that is never stated by a hop that did not take the payment, so the value
1341
+ * now means what its name says (relay#133).
1342
+ *
1343
+ * @module
1344
+ */
1345
+
1346
+ /** A payment the terminating connector states it verified. */
1347
+ interface PaymentAttribution {
1348
+ /** The client channel key, namespaced: `evm:0x<64 hex>` / `solana:<base58>`. */
1349
+ payer: string;
1350
+ /** The route's flat price in base units, decimal. */
1351
+ amount: string;
1352
+ /** The payer key's namespace. */
1353
+ chain: 'evm' | 'solana';
1354
+ }
1355
+ /**
1356
+ * Read the connector's payment statement off a request.
1357
+ *
1358
+ * Returns `undefined` unless ALL THREE headers are present, individually
1359
+ * well-formed, and mutually consistent (the payer's namespace must be the
1360
+ * chain it claims). Anything else -- one header, two headers, a payer from a
1361
+ * chain the `X-TOON-Chain` header disagrees with, a non-decimal amount -- is
1362
+ * discarded whole. Partial attribution is worse than none: it would record
1363
+ * half a fact as if it were the whole one.
1364
+ *
1365
+ * This never rejects the request. A caller that states nothing, or states
1366
+ * nonsense, still gets its write handled on the merits of the event itself;
1367
+ * the payment gate is upstream and has already run by the time anything
1368
+ * reaches this process.
1369
+ */
1370
+ declare function readPaymentAttribution(c: Context): PaymentAttribution | undefined;
1371
+
1372
+ /**
1373
+ * A minimal in-memory sliding-window rate limiter (relay#129).
1374
+ *
1375
+ * Built for the free ephemeral write lane (`POST /write-ephemeral`,
1376
+ * handlers/write-ephemeral-handler.ts): with no payment gate, request volume
1377
+ * is the only admission control that lane has, so every request must be
1378
+ * checked against a per-key budget before any other work happens.
1379
+ * Dependency-free and small enough to own outright rather than pull in a
1380
+ * library, matching the shape the rest of the launcher already uses for
1381
+ * self-contained stateful helpers (crypto/verify-pool.ts, launcher/metrics.ts).
1382
+ *
1383
+ * Sliding-window LOG, not a fixed-window counter: a fixed window lets a
1384
+ * caller burst up to 2x the limit across a window boundary (all of window
1385
+ * N's budget at :59, all of window N+1's budget at :00). The log costs one
1386
+ * array per active key and O(requests-in-window) work per call, which is
1387
+ * fine at the request volumes a bound like this is meant to police.
1388
+ *
1389
+ * @module
1390
+ */
1391
+ /** Options for {@link createRateLimiter}. */
1392
+ interface RateLimiterOptions {
1393
+ /** Max requests allowed per key within the trailing window. */
1394
+ maxRequests: number;
1395
+ /** Trailing window length in milliseconds. */
1396
+ windowMs: number;
1397
+ /** Clock override for deterministic tests (default: `Date.now`). */
1398
+ now?: () => number;
1399
+ }
1400
+ /** A live rate limiter. One instance per bound; keys are caller-defined. */
1401
+ interface RateLimiter {
1402
+ /**
1403
+ * Check `key`'s budget. Returns `true` and records this call towards the
1404
+ * budget when the key is under its limit; returns `false` (and records
1405
+ * nothing) when the key is over budget. Never throws.
1406
+ */
1407
+ allow(key: string): boolean;
1408
+ }
1409
+ /**
1410
+ * Create a sliding-window rate limiter.
1411
+ *
1412
+ * @param options - Bound + optional clock override.
1413
+ */
1414
+ declare function createRateLimiter(options: RateLimiterOptions): RateLimiter;
1415
+
1416
+ /**
1417
+ * Free ephemeral write handler for @toon-protocol/relay (relay#129, ephemeral
1418
+ * epic toon-meta#393 E2).
1419
+ *
1420
+ * A SECOND write surface, `POST /write-ephemeral`, distinct from the paid
1421
+ * `POST /write` (write-handler.ts). It exists because the connector cannot
1422
+ * carry two prices on one `handler_url` (`ConflictingHandlerPrice`,
1423
+ * connector `route.rs:378-399`) -- a free lane needs its own endpoint,
1424
+ * terminated by its own zero-priced route in the deploy config (see
1425
+ * `deploy/connector.toml`'s `g.toon.relay.ephemeral` route).
1426
+ *
1427
+ * Differences from the paid handler, all deliberate:
1428
+ *
1429
+ * - Accepts ONLY ephemeral kinds (NIP-16, 20000 <= kind < 30000) -- anything
1430
+ * else is a 400. Persistent kinds have no business on a free lane; letting
1431
+ * them through would be a free ride around pay-to-write.
1432
+ * - NEVER stores. Ephemeral kinds are never persisted on the paid path
1433
+ * either (NIP-16; write-handler.ts), so there is nothing this lane would
1434
+ * ever write to an EventStore -- it does not take one as a dependency.
1435
+ * - Schnorr verification is ALWAYS FULL, with no skip and no config knob to
1436
+ * add one. The paid path's ephemeral verify-skip (write-handler.ts) is
1437
+ * safe ONLY because payment is the admission gate; this lane has no
1438
+ * payment gate, so signature verification IS its only defense against
1439
+ * forged-signature spam before the bounds below even apply. Reusing that
1440
+ * skip here would let anyone broadcast garbage to every subscriber for
1441
+ * free -- exactly the case write-handler.ts's own invariant comment warns
1442
+ * against.
1443
+ * - Bounds, because free + broadcast = spam surface: a per-key sliding-
1444
+ * window rate limit (rate-limiter.ts) and a request-body size cap, both
1445
+ * config-gated with conservative defaults (see
1446
+ * {@link EphemeralWriteHandlerConfig}).
1447
+ *
1448
+ * Flow:
1449
+ * 1. Rate-limit check, keyed by remote address (falling back to a shared
1450
+ * bucket when connection info is unavailable -- see `defaultClientKey`)
1451
+ * -- BEFORE any body is read, so a rate-limited caller costs as little
1452
+ * work as possible -> 429 over budget.
1453
+ * 2. Body-size check against `maxBodyBytes` -- BEFORE JSON parsing, so an
1454
+ * oversized payload is never deserialized -> 413 too large.
1455
+ * 3. Parse JSON body `{ event }` -> 400 on malformed/missing event.
1456
+ * 4. Reject non-ephemeral kinds -> 400.
1457
+ * 5. Full schnorr verification, never skipped -> 422 on invalid signature.
1458
+ * 6. Fire the optional onBroadcast callback (the live-broadcast hook).
1459
+ * Nothing is ever stored.
1460
+ * 7. Respond 200 with the event id.
1461
+ *
1462
+ * @module
1463
+ */
1464
+
1465
+ /**
1466
+ * Conservative default rate-limit bound: 200 requests per 10-second window
1467
+ * per key. Sized for presence/typing traffic (the epic's motivating
1468
+ * workload, toon-meta#393), not huddle-frame rates -- that traffic stays on
1469
+ * the paid path. Deliberately generous rather than tight: this is a spam
1470
+ * ceiling, not a fairness scheduler, and a false-positive reject on
1471
+ * legitimate ephemeral traffic (a dropped typing indicator) is silent and
1472
+ * has no client-side retry signal.
1473
+ */
1474
+ declare const DEFAULT_EPHEMERAL_RATE_LIMIT: {
1475
+ maxRequests: number;
1476
+ windowMs: number;
1477
+ };
1478
+ /**
1479
+ * Conservative default body-size cap in bytes. Ephemeral events on this lane
1480
+ * (presence heartbeats, typing indicators) are small JSON; 8 KiB comfortably
1481
+ * covers a signed Nostr event with generous tag/content headroom while
1482
+ * bounding worst-case memory per request on an unpaid surface.
1483
+ */
1484
+ declare const DEFAULT_EPHEMERAL_MAX_BODY_BYTES: number;
1485
+ /** Configuration for the ephemeral write handler. */
1486
+ interface EphemeralWriteHandlerConfig {
1487
+ /**
1488
+ * Signature verifier. Defaults to the inline `verifyEventSignature`; the
1489
+ * launcher injects the worker-pool verifier (crypto/verify-pool.ts),
1490
+ * shared with the paid handler, so verify bursts run off the event loop
1491
+ * (relay#85). May resolve asynchronously -- the handler awaits it.
1492
+ *
1493
+ * There is NO devMode/skip option here, unlike the paid handler --
1494
+ * verification on this lane is always full (see the module doc).
1495
+ */
1496
+ verifyEvent?: (event: NostrEvent) => Promise<boolean> | boolean;
1497
+ /** Optional callback fired after an event passes all checks (broadcast hook). */
1498
+ onBroadcast?: (event: NostrEvent) => void;
1499
+ /** Log one line per accepted write (default: false), matching write-handler.ts. */
1500
+ logWrites?: boolean;
1501
+ /** Rate-limit bound (default {@link DEFAULT_EPHEMERAL_RATE_LIMIT}). */
1502
+ rateLimit?: {
1503
+ maxRequests: number;
1504
+ windowMs: number;
1505
+ };
1506
+ /** Request body size cap in bytes (default {@link DEFAULT_EPHEMERAL_MAX_BODY_BYTES}). */
1507
+ maxBodyBytes?: number;
1508
+ /**
1509
+ * Test-only: inject a rate limiter directly (e.g. with a fake clock)
1510
+ * instead of letting the handler build one from `rateLimit`.
1511
+ */
1512
+ rateLimiter?: RateLimiter;
1513
+ /**
1514
+ * Test-only: override how a request is keyed for rate limiting. Defaults
1515
+ * to `defaultClientKey` (remote address via `getConnInfo`, falling back to
1516
+ * a shared bucket).
1517
+ */
1518
+ getClientKey?: (c: Context) => string;
1519
+ }
1520
+ /** Ephemeral write handler instance. */
1521
+ interface EphemeralWriteHandler {
1522
+ /** Handle a plain-HTTP ephemeral write request. */
1523
+ handleWrite(c: Context): Promise<Response>;
1524
+ }
1525
+ /**
1526
+ * Create the ephemeral write handler.
1527
+ *
1528
+ * @param config - Handler configuration.
1529
+ * @returns An EphemeralWriteHandler with a handleWrite method.
1530
+ */
1531
+ declare function createEphemeralWriteHandler(config?: EphemeralWriteHandlerConfig): EphemeralWriteHandler;
1532
+
1533
+ export { type AddressCoordinate, ConnectionHandler, DEFAULT_EPHEMERAL_MAX_BODY_BYTES, DEFAULT_EPHEMERAL_RATE_LIMIT, DEFAULT_RELAY_CONFIG, DELETION_KIND, type DeletionTargets, type DurationStats, EXPIRATION_TAG, type EphemeralWriteHandler, type EphemeralWriteHandlerConfig, type EventStore, type EventStoreOptions, type HealthConfig, type HealthResponse, InMemoryEventStore, type MetricsRegistry, type MetricsSnapshot, NostrRelayServer, type PaymentAttribution, type RateLimiter, type RateLimiterOptions, type RelayConfig, RelayError, type RelayInstance, type RelayServerConfig, RelaySubscriber, type RelaySubscriberConfig, type RelaySubscription, type ResolvedRelayConfig, SqliteEventStore, type Subscription, VERSION, type VerifyPool, type VerifyPoolOptions, type WriteHandler, type WriteHandlerConfig, createEphemeralWriteHandler, createHealthResponse, createMetricsRegistry, createRateLimiter, createVerifyPool, createWriteHandler, defaultVerifyWorkers, getExpiration, isDeletableBy, isDeletionKind, isExpired, isInternalBindHost, matchFilter, parseAddressCoordinate, parseBlockedEventIds, parseDeletionTargets, readOpenFilesSoftLimit, readPaymentAttribution, serializeEventFrame, startRelay, verifyEventId, verifyEventSignature, verifyImplementation, warnIfWritePortExposed };