@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/README.md +118 -64
- package/dist/chunk-IZOSPMWV.js +1799 -0
- package/dist/chunk-IZOSPMWV.js.map +1 -0
- package/dist/chunk-SMT6G3XD.js +92 -0
- package/dist/chunk-SMT6G3XD.js.map +1 -0
- package/dist/cli.js +181 -8
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +1073 -57
- package/dist/index.js +53 -100
- package/dist/index.js.map +1 -1
- package/dist/verify-worker.d.ts +2 -0
- package/dist/verify-worker.js +17 -0
- package/dist/verify-worker.js.map +1 -0
- package/package.json +9 -5
- package/dist/chunk-FXQSNOCG.js +0 -939
- package/dist/chunk-FXQSNOCG.js.map +0 -1
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
|
-
|
|
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:
|
|
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
|
-
/**
|
|
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
|
-
*
|
|
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
|
-
*
|
|
778
|
+
* Three surfaces:
|
|
324
779
|
*
|
|
325
|
-
* - `POST /write` (TOON_BLS_PORT, default 3100): accepts `{ event }` as JSON
|
|
326
|
-
*
|
|
327
|
-
*
|
|
328
|
-
*
|
|
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,
|
|
471
|
-
*
|
|
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
|
|
477
|
-
*
|
|
478
|
-
*
|
|
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.
|
|
483
|
-
*
|
|
484
|
-
*
|
|
485
|
-
*
|
|
486
|
-
*
|
|
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
|
-
|
|
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 };
|