@toon-protocol/relay 2.0.1 → 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/README.md CHANGED
@@ -29,8 +29,39 @@ NOSTR_SECRET_KEY=<64-char-hex> npx @toon-protocol/relay
29
29
  | `TOON_MNEMONIC` | — | BIP-39 mnemonic (NIP-06 derivation) |
30
30
  | `TOON_RELAY_PORT` | `7100` | WebSocket read port |
31
31
  | `TOON_BLS_PORT` | `3100` | HTTP write/health port |
32
+ | `TOON_HOST` | `0.0.0.0` | WebSocket bind host |
33
+ | `TOON_WRITE_HOST` | `0.0.0.0` | HTTP write/health bind host (see [write-port exposure](#paid-ephemeral-verify-skip-relay85)) |
32
34
  | `TOON_DATA_DIR` | `./data` | SQLite data directory |
33
35
  | `TOON_DEV_MODE` | `false` | Skip event-signature verification on `POST /write` |
36
+ | `TOON_VERIFY_EPHEMERAL` | `false` | Run FULL schnorr verification on ephemeral kinds too (see below) |
37
+ | `TOON_VERIFY_WORKERS` | CPUs − 1 | Worker threads for persistent-kind signature verification; `0` = inline on the event loop (automatic on 1-core boxes) |
38
+ | `TOON_MAX_CONNECTIONS` | `4096` | Maximum concurrent WebSocket read connections (each costs one file descriptor — mind `ulimit -n`) |
39
+
40
+ ## Paid-ephemeral verify skip (relay#85)
41
+
42
+ By default the relay **skips schnorr verification for ephemeral kinds**
43
+ (NIP-16, `20000 <= kind < 30000`) on `POST /write` and keeps only the SHA-256
44
+ event-id check. This is a deliberate, payment-gated bypass:
45
+
46
+ - **Why it is safe here:** every request reaching `POST /write` has already
47
+ passed the upstream connector's payment claim gate — payment is the
48
+ admission/spam gate. Protocol rule: clients trust the signature chain and
49
+ verify every event themselves, never the relay. Relay-side schnorr on paid
50
+ ephemeral frames buys no additional trust; forging a speaker costs real
51
+ money to emit frames every client discards.
52
+ - **What is still checked:** the SHA-256 id check always runs, so the relay
53
+ never broadcasts bytes that disagree with the event id clients verify by.
54
+ - **When you MUST turn it off:** if your write port is fronted by anything
55
+ other than a payment-gating connector — or you ever add a FREE ephemeral
56
+ write lane — set `TOON_VERIFY_EPHEMERAL=true` (`--verify-ephemeral`,
57
+ `verifyEphemeral: true`). A free lane must NOT reuse this skip.
58
+ - **Exposure guard:** the write port must be reachable only via the
59
+ connector. In docker, never host-publish it (`expose:`, not `ports:` —
60
+ docker-published ports bypass ufw). Outside docker, bind it internally via
61
+ `TOON_WRITE_HOST=127.0.0.1`. At startup the relay logs a prominent warning
62
+ if the write listener binds a non-internal interface while the skip is
63
+ active (warning only — container topologies legitimately bind `0.0.0.0`
64
+ and stay private by not publishing the port).
34
65
 
35
66
  ## Run (programmatic)
36
67
 
@@ -46,8 +77,9 @@ await relay.stop();
46
77
 
47
78
  | Method | Path | Description |
48
79
  |--------|------|-------------|
49
- | `POST` | `/write` | Store an event. Body `{ "event": <NostrEvent> }`. Trusts injected `X-TOON-Payer`/`-Amount`/`-Chain` headers (echoed, not validated); verifies only the event signature. |
80
+ | `POST` | `/write` | Store an event. Body `{ "event": <NostrEvent> }`. Trusts injected `X-TOON-Payer`/`-Amount`/`-Chain` headers (echoed, not validated); verifies only the event signature (ephemeral kinds: id check only by default, see above). |
50
81
  | `GET` | `/health` | Liveness, identity (`pubkey`), `capabilities`, and `version`. |
82
+ | `GET` | `/metrics` | JSON telemetry: `eventLoopDelayMs` (mean/p50/p99/max — loop lag is ephemeral-frame tail latency) and `verify` (per-event verify wall time incl. pool queueing, active implementation, worker count). The trigger metrics for scaling decisions (relay#85). |
51
83
 
52
84
  ## WebSocket Relay Server
53
85
 
@@ -79,7 +111,7 @@ const results = memStore.query([{ kinds: [1], limit: 10 }]);
79
111
 
80
112
  ## TOON Codec
81
113
 
82
- Re-exported from [`@toon-protocol/core`](https://github.com/toon-protocol/core) for convenience.
114
+ Vendored in-repo (`src/toon/codec.ts`) so the relay depends only on the lightweight `@toon-format/toon` encoder rather than `@toon-protocol/core`'s full transitive tree. The relay has no runtime dependency on `@toon-protocol/core`.
83
115
 
84
116
  ```ts
85
117
  import { encodeEventToToon, decodeEventFromToon } from '@toon-protocol/relay';
@@ -1,3 +1,9 @@
1
+ import {
2
+ verifyEventId,
3
+ verifyEventSignature,
4
+ verifyImplementation
5
+ } from "./chunk-SMT6G3XD.js";
6
+
1
7
  // src/version.ts
2
8
  var VERSION = "0.1.0";
3
9
 
@@ -5,7 +11,7 @@ var VERSION = "0.1.0";
5
11
  var DEFAULT_RELAY_CONFIG = {
6
12
  port: 7e3,
7
13
  host: "0.0.0.0",
8
- maxConnections: 100,
14
+ maxConnections: 4096,
9
15
  maxSubscriptionsPerConnection: 20,
10
16
  maxFiltersPerSubscription: 10,
11
17
  databasePath: ":memory:"
@@ -132,6 +138,7 @@ function getDTagValue(tags) {
132
138
  var SqliteEventStore = class {
133
139
  db;
134
140
  insertStmt;
141
+ insertOrIgnoreStmt;
135
142
  getStmt;
136
143
  deleteByPubkeyKindStmt;
137
144
  deleteByPubkeyKindDTagStmt;
@@ -144,11 +151,17 @@ var SqliteEventStore = class {
144
151
  constructor(dbPath = ":memory:") {
145
152
  try {
146
153
  this.db = new Database(dbPath);
154
+ this.db.pragma("journal_mode = WAL");
155
+ this.db.pragma("synchronous = NORMAL");
147
156
  initializeSchema(this.db);
148
157
  this.insertStmt = this.db.prepare(`
149
158
  INSERT OR REPLACE INTO events (id, pubkey, kind, content, tags, created_at, sig, received_at)
150
159
  VALUES (?, ?, ?, ?, ?, ?, ?, ?)
151
160
  `);
161
+ this.insertOrIgnoreStmt = this.db.prepare(`
162
+ INSERT OR IGNORE INTO events (id, pubkey, kind, content, tags, created_at, sig, received_at)
163
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
164
+ `);
152
165
  this.getStmt = this.db.prepare("SELECT * FROM events WHERE id = ?");
153
166
  this.deleteByPubkeyKindStmt = this.db.prepare(
154
167
  "DELETE FROM events WHERE pubkey = ? AND kind = ?"
@@ -182,11 +195,7 @@ var SqliteEventStore = class {
182
195
  } else if (isParameterizedReplaceableKind(event.kind)) {
183
196
  this.storeParameterizedReplaceableEvent(event, tagsJson, receivedAt);
184
197
  } else {
185
- const insertOrIgnore = this.db.prepare(`
186
- INSERT OR IGNORE INTO events (id, pubkey, kind, content, tags, created_at, sig, received_at)
187
- VALUES (?, ?, ?, ?, ?, ?, ?, ?)
188
- `);
189
- insertOrIgnore.run(
198
+ this.insertOrIgnoreStmt.run(
190
199
  event.id,
191
200
  event.pubkey,
192
201
  event.kind,
@@ -419,7 +428,124 @@ var SqliteEventStore = class {
419
428
  }
420
429
  };
421
430
 
431
+ // src/crypto/verify-pool.ts
432
+ import { existsSync } from "fs";
433
+ import { cpus } from "os";
434
+ import { fileURLToPath } from "url";
435
+ import { Worker } from "worker_threads";
436
+ import { performance } from "perf_hooks";
437
+ import { verifiedSymbol } from "nostr-tools/pure";
438
+ function defaultVerifyWorkers() {
439
+ return Math.max(0, cpus().length - 1);
440
+ }
441
+ function resolveWorkerUrl() {
442
+ for (const candidate of [
443
+ new URL("./verify-worker.js", import.meta.url),
444
+ new URL("../../dist/verify-worker.js", import.meta.url)
445
+ ]) {
446
+ try {
447
+ if (existsSync(fileURLToPath(candidate))) return candidate;
448
+ } catch {
449
+ }
450
+ }
451
+ return null;
452
+ }
453
+ function createVerifyPool(options = {}) {
454
+ const requestedSize = options.size ?? defaultVerifyWorkers();
455
+ const onMeasure = options.onMeasure;
456
+ const measured = (fn) => {
457
+ if (!onMeasure) return fn();
458
+ const start = performance.now();
459
+ const result = fn();
460
+ onMeasure(performance.now() - start);
461
+ return result;
462
+ };
463
+ const inlineVerify = (event) => Promise.resolve(measured(() => verifyEventSignature(event)));
464
+ const workerUrl = requestedSize > 0 ? resolveWorkerUrl() : null;
465
+ if (requestedSize > 0 && !workerUrl) {
466
+ console.warn(
467
+ "[relay] verify pool: compiled worker (dist/verify-worker.js) not found -- falling back to inline verification (build the package to enable workers)"
468
+ );
469
+ }
470
+ const workers = [];
471
+ let seq = 0;
472
+ let destroyed = false;
473
+ const retireWorker = (pw, reason) => {
474
+ const index = workers.indexOf(pw);
475
+ if (index === -1) return;
476
+ workers.splice(index, 1);
477
+ if (!destroyed) {
478
+ console.warn(
479
+ `[relay] verify pool: worker retired (${reason}); ` + (workers.length > 0 ? `${workers.length} worker(s) remain` : "falling back to inline verification")
480
+ );
481
+ }
482
+ for (const { resolve, event } of pw.pending.values()) {
483
+ resolve(verifyEventSignature(event));
484
+ }
485
+ pw.pending.clear();
486
+ };
487
+ if (workerUrl) {
488
+ for (let i = 0; i < requestedSize; i++) {
489
+ const worker = new Worker(workerUrl);
490
+ const pw = { worker, pending: /* @__PURE__ */ new Map() };
491
+ worker.on("message", (reply) => {
492
+ const entry = pw.pending.get(reply.seq);
493
+ if (!entry) return;
494
+ pw.pending.delete(reply.seq);
495
+ entry.event[verifiedSymbol] = reply.ok;
496
+ entry.resolve(reply.ok);
497
+ });
498
+ worker.on(
499
+ "error",
500
+ (error) => retireWorker(pw, `error: ${error.message}`)
501
+ );
502
+ worker.on("exit", () => retireWorker(pw, "exit"));
503
+ workers.push(pw);
504
+ }
505
+ }
506
+ const poolVerify = (event) => {
507
+ const cached = event[verifiedSymbol];
508
+ if (typeof cached === "boolean") return Promise.resolve(cached);
509
+ let target = workers[0];
510
+ if (!target) return inlineVerify(event);
511
+ for (const pw of workers) {
512
+ if (pw.pending.size < target.pending.size) target = pw;
513
+ }
514
+ const start = performance.now();
515
+ return new Promise((resolve) => {
516
+ const id = ++seq;
517
+ target.pending.set(id, {
518
+ event,
519
+ resolve: (ok) => {
520
+ onMeasure?.(performance.now() - start);
521
+ resolve(ok);
522
+ }
523
+ });
524
+ target.worker.postMessage({ seq: id, event });
525
+ });
526
+ };
527
+ return {
528
+ verify(event) {
529
+ return workers.length > 0 ? poolVerify(event) : inlineVerify(event);
530
+ },
531
+ get size() {
532
+ return workers.length;
533
+ },
534
+ async destroy() {
535
+ destroyed = true;
536
+ const toTerminate = [...workers];
537
+ for (const pw of toTerminate) {
538
+ retireWorker(pw, "destroy");
539
+ }
540
+ await Promise.all(toTerminate.map((pw) => pw.worker.terminate()));
541
+ }
542
+ };
543
+ }
544
+
422
545
  // src/websocket/ConnectionHandler.ts
546
+ function serializeEventFrame(subscriptionId, eventJson) {
547
+ return `["EVENT",${JSON.stringify(subscriptionId)},${eventJson}]`;
548
+ }
423
549
  var ConnectionHandler = class {
424
550
  constructor(ws, eventStore, config = {}) {
425
551
  this.ws = ws;
@@ -519,12 +645,21 @@ var ConnectionHandler = class {
519
645
  /**
520
646
  * Push a new event to all matching subscriptions on this connection.
521
647
  * Used when events are stored outside the WebSocket flow (e.g., via ILP).
648
+ *
649
+ * @param event - The event to fan out (used for filter matching).
650
+ * @param eventJson - Optional pre-serialized `JSON.stringify(event)`.
651
+ * `NostrRelayServer.broadcastEvent` serializes the event ONCE and passes
652
+ * it here so a 500-subscriber fan-out costs one serialization, not 500
653
+ * (relay#91). When omitted (direct callers), the event is serialized
654
+ * on first matching send.
522
655
  */
523
- notifyNewEvent(event) {
656
+ notifyNewEvent(event, eventJson) {
657
+ let json = eventJson;
524
658
  for (const sub of this.subscriptions.values()) {
525
659
  const matches = sub.filters.some((f) => matchFilter(event, f));
526
660
  if (matches) {
527
- this.sendEvent(sub.id, event);
661
+ json ??= JSON.stringify(event);
662
+ this.send(serializeEventFrame(sub.id, json));
528
663
  }
529
664
  }
530
665
  }
@@ -548,9 +683,11 @@ var ConnectionHandler = class {
548
683
  * with the event as a plain JSON object — so any standard nostr client can
549
684
  * parse it and verify `id`/`sig` from the wire bytes (#46). Never re-encode
550
685
  * the event (TOON text, double-JSON-stringify, etc.) at this boundary.
686
+ * serializeEventFrame is byte-identical to the full JSON.stringify
687
+ * envelope (pinned by tests).
551
688
  */
552
689
  sendEvent(subscriptionId, event) {
553
- this.send(["EVENT", subscriptionId, event]);
690
+ this.send(serializeEventFrame(subscriptionId, JSON.stringify(event)));
554
691
  }
555
692
  sendEose(subscriptionId) {
556
693
  this.send(["EOSE", subscriptionId]);
@@ -561,15 +698,32 @@ var ConnectionHandler = class {
561
698
  sendNotice(message) {
562
699
  this.send(["NOTICE", message]);
563
700
  }
701
+ /** Send a message: pre-serialized frames go out as-is (relay#91). */
564
702
  send(message) {
565
703
  if (this.ws.readyState === 1) {
566
- this.ws.send(JSON.stringify(message));
704
+ this.ws.send(
705
+ typeof message === "string" ? message : JSON.stringify(message)
706
+ );
567
707
  }
568
708
  }
569
709
  };
570
710
 
571
711
  // src/websocket/NostrRelayServer.ts
712
+ import { readFileSync } from "fs";
572
713
  import { WebSocketServer } from "ws";
714
+ var FD_HEADROOM = 128;
715
+ function readOpenFilesSoftLimit(read = (p) => readFileSync(p, "utf8")) {
716
+ try {
717
+ const line = read("/proc/self/limits").split("\n").find((l) => l.startsWith("Max open files"));
718
+ const match = line?.match(/Max open files\s+(\S+)/);
719
+ if (!match?.[1]) return null;
720
+ if (match[1] === "unlimited") return Infinity;
721
+ const limit = parseInt(match[1], 10);
722
+ return Number.isNaN(limit) ? null : limit;
723
+ } catch {
724
+ return null;
725
+ }
726
+ }
573
727
  var NostrRelayServer = class {
574
728
  constructor(config = {}, eventStore) {
575
729
  this.eventStore = eventStore;
@@ -599,6 +753,12 @@ var NostrRelayServer = class {
599
753
  if (address && typeof address === "object") {
600
754
  console.log(`[NostrRelayServer] Listening on port ${address.port}`);
601
755
  }
756
+ const fdLimit = readOpenFilesSoftLimit();
757
+ if (fdLimit !== null && Number.isFinite(fdLimit) && this.config.maxConnections > fdLimit - FD_HEADROOM) {
758
+ console.warn(
759
+ `[NostrRelayServer] maxConnections (${this.config.maxConnections}) exceeds the process fd soft limit (${fdLimit}) minus ${FD_HEADROOM} headroom -- connections will fail with EMFILE before the cap. Raise \`ulimit -n\` or lower maxConnections.`
760
+ );
761
+ }
602
762
  resolve();
603
763
  });
604
764
  } catch (error) {
@@ -648,14 +808,24 @@ var NostrRelayServer = class {
648
808
  * Broadcast an event to all connected clients with matching subscriptions.
649
809
  * Call this after storing an event outside the WebSocket flow (e.g., via ILP)
650
810
  * so that discovery subscribers are notified.
811
+ *
812
+ * Serialize-once fan-out (relay#91): the event payload is stringified ONE
813
+ * time here and reused for every matching subscriber -- only the small
814
+ * per-subscription `["EVENT",<subId>,...]` envelope is spliced per send.
815
+ * Previously each of N subscribers re-serialized the identical event
816
+ * (N=500 pinned a core doing 500 identical stringifies per frame).
651
817
  */
652
818
  broadcastEvent(event) {
819
+ const eventJson = JSON.stringify(event);
653
820
  for (const handler of this.handlers.values()) {
654
- handler.notifyNewEvent(event);
821
+ handler.notifyNewEvent(event, eventJson);
655
822
  }
656
823
  }
657
824
  handleConnection(ws) {
658
825
  if (this.handlers.size >= this.config.maxConnections) {
826
+ console.warn(
827
+ `[NostrRelayServer] connection rejected: maxConnections (${this.config.maxConnections}) reached -- raise TOON_MAX_CONNECTIONS if this box has headroom (relay#90)`
828
+ );
659
829
  ws.close(1013, "max connections reached");
660
830
  return;
661
831
  }
@@ -742,9 +912,81 @@ var RelaySubscriber = class {
742
912
  }
743
913
  };
744
914
 
915
+ // src/launcher/metrics.ts
916
+ import { monitorEventLoopDelay } from "perf_hooks";
917
+ var VERIFY_WINDOW = 2048;
918
+ var NS_PER_MS = 1e6;
919
+ function percentileOf(sorted, fraction) {
920
+ if (sorted.length === 0) return 0;
921
+ const index = Math.min(
922
+ sorted.length - 1,
923
+ Math.ceil(fraction * sorted.length) - 1
924
+ );
925
+ return sorted[Math.max(0, index)] ?? 0;
926
+ }
927
+ function round(value) {
928
+ if (!Number.isFinite(value)) return 0;
929
+ return Math.round(value * 1e3) / 1e3;
930
+ }
931
+ function createMetricsRegistry(info) {
932
+ const loopDelay = monitorEventLoopDelay({
933
+ resolution: 20
934
+ });
935
+ loopDelay.enable();
936
+ let verifyWorkers = info.verifyWorkers;
937
+ let count = 0;
938
+ let totalMs = 0;
939
+ let maxMs = 0;
940
+ const window = new Array(VERIFY_WINDOW);
941
+ let windowFill = 0;
942
+ let windowCursor = 0;
943
+ return {
944
+ recordVerify(ms) {
945
+ count += 1;
946
+ totalMs += ms;
947
+ if (ms > maxMs) maxMs = ms;
948
+ window[windowCursor] = ms;
949
+ windowCursor = (windowCursor + 1) % VERIFY_WINDOW;
950
+ if (windowFill < VERIFY_WINDOW) windowFill += 1;
951
+ },
952
+ snapshot() {
953
+ const recent = window.slice(0, windowFill).sort((a, b) => a - b);
954
+ return {
955
+ timestamp: Date.now(),
956
+ eventLoopDelayMs: {
957
+ mean: round(loopDelay.mean / NS_PER_MS),
958
+ p50: round(loopDelay.percentile(50) / NS_PER_MS),
959
+ p99: round(loopDelay.percentile(99) / NS_PER_MS),
960
+ max: round(loopDelay.max / NS_PER_MS)
961
+ },
962
+ verify: {
963
+ implementation: info.verifyImplementation,
964
+ workers: verifyWorkers,
965
+ count,
966
+ meanMs: round(count > 0 ? totalMs / count : 0),
967
+ maxMs: round(maxMs),
968
+ p50Ms: round(percentileOf(recent, 0.5)),
969
+ p99Ms: round(percentileOf(recent, 0.99))
970
+ }
971
+ };
972
+ },
973
+ setVerifyWorkers(workers) {
974
+ verifyWorkers = workers;
975
+ },
976
+ stop() {
977
+ loopDelay.disable();
978
+ }
979
+ };
980
+ }
981
+
745
982
  // src/launcher/handlers/write-handler.ts
746
- import { verifyEvent as verifyEvent2 } from "nostr-tools/pure";
983
+ function isEphemeralKind(kind) {
984
+ return kind >= 2e4 && kind < 3e4;
985
+ }
747
986
  function createWriteHandler(config) {
987
+ const logWrites = config.logWrites ?? false;
988
+ const verifyEphemeral = config.verifyEphemeral ?? false;
989
+ const verifyEvent2 = config.verifyEvent ?? verifyEventSignature;
748
990
  return {
749
991
  async handleWrite(c) {
750
992
  let body;
@@ -760,13 +1002,23 @@ function createWriteHandler(config) {
760
1002
  const payer = c.req.header("X-TOON-Payer");
761
1003
  const amount = c.req.header("X-TOON-Amount");
762
1004
  const chain = c.req.header("X-TOON-Chain");
763
- console.log(
764
- `[write] event=${event.id} payer=${payer ?? "-"} amount=${amount ?? "-"} chain=${chain ?? "-"}`
765
- );
766
- if (!config.devMode && !verifyEvent2(event)) {
767
- return c.json({ error: "Invalid event signature" }, 422);
1005
+ if (logWrites) {
1006
+ console.log(
1007
+ `[write] event=${event.id} payer=${payer ?? "-"} amount=${amount ?? "-"} chain=${chain ?? "-"}`
1008
+ );
1009
+ }
1010
+ if (!config.devMode) {
1011
+ if (isEphemeralKind(event.kind) && !verifyEphemeral) {
1012
+ if (!verifyEventId(event)) {
1013
+ return c.json({ error: "Invalid event id" }, 422);
1014
+ }
1015
+ } else if (!await verifyEvent2(event)) {
1016
+ return c.json({ error: "Invalid event signature" }, 422);
1017
+ }
1018
+ }
1019
+ if (!isEphemeralKind(event.kind)) {
1020
+ config.eventStore.store(event);
768
1021
  }
769
- config.eventStore.store(event);
770
1022
  config.onStored?.(event);
771
1023
  return c.json(
772
1024
  {
@@ -814,6 +1066,41 @@ function deriveIdentity(config) {
814
1066
  const secretKey = hasMnemonic ? privateKeyFromSeedWords(config.mnemonic) : config.secretKey;
815
1067
  return { secretKey, pubkey: getPublicKey(secretKey) };
816
1068
  }
1069
+ function isInternalBindHost(host) {
1070
+ const h = host.trim().toLowerCase();
1071
+ if (h === "localhost" || h === "::1" || h === "[::1]") return true;
1072
+ if (h.startsWith("127.")) return true;
1073
+ if (h.startsWith("10.")) return true;
1074
+ if (h.startsWith("192.168.")) return true;
1075
+ if (/^172\.(1[6-9]|2\d|3[01])\./.test(h)) return true;
1076
+ if (/^f[cd][0-9a-f]{2}:/.test(h)) return true;
1077
+ if (h.startsWith("fe80:")) return true;
1078
+ return false;
1079
+ }
1080
+ function warnIfWritePortExposed(writeHost, blsPort, options) {
1081
+ const skipActive = options.devMode || !options.verifyEphemeral;
1082
+ if (!skipActive || isInternalBindHost(writeHost)) {
1083
+ return false;
1084
+ }
1085
+ console.warn(
1086
+ [
1087
+ "",
1088
+ "!".repeat(72),
1089
+ `[relay] WARNING: POST /write is binding ${writeHost}:${blsPort} (a`,
1090
+ "[relay] non-loopback/non-internal interface) while event verification",
1091
+ options.devMode ? "[relay] is fully DISABLED (devMode)." : "[relay] is SKIPPED for paid ephemeral kinds (relay#85 default).",
1092
+ "[relay] This is safe ONLY if the write port is reachable exclusively",
1093
+ "[relay] through the payment-gating connector. In docker, do NOT",
1094
+ "[relay] host-publish this port (`expose:`, never `ports:` -- published",
1095
+ "[relay] ports bypass ufw). If the port is directly reachable, either",
1096
+ "[relay] bind it internally (TOON_WRITE_HOST=127.0.0.1) or restore full",
1097
+ "[relay] verification (TOON_VERIFY_EPHEMERAL=true).",
1098
+ "!".repeat(72),
1099
+ ""
1100
+ ].join("\n")
1101
+ );
1102
+ return true;
1103
+ }
817
1104
  function createSubscription(relayUrl, filter, eventStore, activeSubscriptions) {
818
1105
  if (!relayUrl.startsWith("ws://") && !relayUrl.startsWith("wss://")) {
819
1106
  throw new Error(
@@ -846,14 +1133,24 @@ async function startRelay(config) {
846
1133
  const relayPort = config.relayPort ?? 7100;
847
1134
  const blsPort = config.blsPort ?? 3100;
848
1135
  const host = config.host ?? "0.0.0.0";
1136
+ const writeHost = config.writeHost ?? "0.0.0.0";
1137
+ const maxConnections = config.maxConnections ?? DEFAULT_RELAY_CONFIG.maxConnections;
849
1138
  const dataDir = config.dataDir ?? "./data";
850
1139
  const devMode = config.devMode ?? false;
1140
+ const verifyEphemeral = config.verifyEphemeral ?? false;
1141
+ const verifyWorkers = config.verifyWorkers ?? defaultVerifyWorkers();
1142
+ const logWrites = config.logWrites ?? false;
851
1143
  const resolvedConfig = {
852
1144
  relayPort,
853
1145
  blsPort,
854
1146
  host,
1147
+ writeHost,
1148
+ maxConnections,
855
1149
  dataDir,
856
- devMode
1150
+ devMode,
1151
+ verifyEphemeral,
1152
+ verifyWorkers,
1153
+ logWrites
857
1154
  };
858
1155
  let eventStore;
859
1156
  if (config.eventStore) {
@@ -862,15 +1159,43 @@ async function startRelay(config) {
862
1159
  mkdirSync(dataDir, { recursive: true });
863
1160
  eventStore = new SqliteEventStore(join(dataDir, "events.db"));
864
1161
  }
865
- const wsRelay = new NostrRelayServer({ port: relayPort, host }, eventStore);
1162
+ const wsRelay = new NostrRelayServer(
1163
+ { port: relayPort, host, maxConnections },
1164
+ eventStore
1165
+ );
866
1166
  const app = new Hono();
867
1167
  app.get(
868
1168
  "/health",
869
1169
  (c) => c.json(createHealthResponse({ pubkey: identity.pubkey }))
870
1170
  );
1171
+ const metrics = createMetricsRegistry({
1172
+ verifyImplementation,
1173
+ verifyWorkers: 0
1174
+ // updated once the pool reports its live size below
1175
+ });
1176
+ const verifyPool = createVerifyPool({
1177
+ size: verifyWorkers,
1178
+ onMeasure: (ms) => metrics.recordVerify(ms)
1179
+ });
1180
+ metrics.setVerifyWorkers(verifyPool.size);
1181
+ app.get("/metrics", (c) => c.json(metrics.snapshot()));
1182
+ console.log(`[relay] event signature verify: ${verifyImplementation}`);
1183
+ console.log(
1184
+ `[relay] ephemeral-kind schnorr verify: ${devMode ? "skipped (devMode)" : verifyEphemeral ? "full (TOON_VERIFY_EPHEMERAL)" : "skipped -- payment-gated write path, id check kept (relay#85)"}`
1185
+ );
1186
+ warnIfWritePortExposed(writeHost, blsPort, { verifyEphemeral, devMode });
1187
+ console.log(
1188
+ `[relay] verify pool: ${verifyPool.size > 0 ? `${verifyPool.size} worker thread(s)` : "inline (0 workers -- verification on the event loop)"}`
1189
+ );
871
1190
  const writeHandler = createWriteHandler({
872
1191
  eventStore,
873
1192
  devMode,
1193
+ verifyEphemeral,
1194
+ verifyEvent: (event) => {
1195
+ metrics.setVerifyWorkers(verifyPool.size);
1196
+ return verifyPool.verify(event);
1197
+ },
1198
+ logWrites,
874
1199
  onStored: (event) => {
875
1200
  try {
876
1201
  wsRelay.broadcastEvent(event);
@@ -881,7 +1206,7 @@ async function startRelay(config) {
881
1206
  app.post("/write", (c) => writeHandler.handleWrite(c));
882
1207
  const blsServer = await new Promise((resolve) => {
883
1208
  const server = serve(
884
- { fetch: app.fetch, port: blsPort },
1209
+ { fetch: app.fetch, port: blsPort, hostname: writeHost },
885
1210
  () => resolve(server)
886
1211
  );
887
1212
  });
@@ -912,6 +1237,8 @@ async function startRelay(config) {
912
1237
  activeSubscriptions.clear();
913
1238
  await wsRelay.stop();
914
1239
  blsServer.close();
1240
+ metrics.stop();
1241
+ await verifyPool.destroy();
915
1242
  if (!config.eventStore) {
916
1243
  eventStore.close?.();
917
1244
  }
@@ -929,11 +1256,18 @@ export {
929
1256
  InMemoryEventStore,
930
1257
  RelayError,
931
1258
  SqliteEventStore,
1259
+ defaultVerifyWorkers,
1260
+ createVerifyPool,
1261
+ serializeEventFrame,
932
1262
  ConnectionHandler,
1263
+ readOpenFilesSoftLimit,
933
1264
  NostrRelayServer,
934
1265
  RelaySubscriber,
1266
+ createMetricsRegistry,
935
1267
  createWriteHandler,
936
1268
  createHealthResponse,
1269
+ isInternalBindHost,
1270
+ warnIfWritePortExposed,
937
1271
  startRelay
938
1272
  };
939
- //# sourceMappingURL=chunk-FXQSNOCG.js.map
1273
+ //# sourceMappingURL=chunk-QZQRHQEQ.js.map