@toon-protocol/relay 2.0.2 → 2.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +119 -97
- package/dist/{chunk-QZQRHQEQ.js → chunk-IZOSPMWV.js} +619 -93
- package/dist/chunk-IZOSPMWV.js.map +1 -0
- package/dist/cli.js +125 -5
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +666 -55
- package/dist/index.js +29 -98
- package/dist/index.js.map +1 -1
- package/package.json +8 -5
- package/dist/chunk-QZQRHQEQ.js.map +0 -1
package/dist/index.d.ts
CHANGED
|
@@ -4,14 +4,13 @@ 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;
|
|
@@ -33,12 +32,42 @@ interface RelayServerConfig {
|
|
|
33
32
|
maxFiltersPerSubscription?: number;
|
|
34
33
|
/** Path to SQLite database file (default: ':memory:' for in-memory) */
|
|
35
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;
|
|
36
44
|
}
|
|
37
45
|
/**
|
|
38
46
|
* Default relay configuration values.
|
|
39
47
|
*/
|
|
40
48
|
declare const DEFAULT_RELAY_CONFIG: Required<RelayServerConfig>;
|
|
41
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
|
+
}
|
|
42
71
|
/**
|
|
43
72
|
* Interface for event storage backends.
|
|
44
73
|
*/
|
|
@@ -49,6 +78,11 @@ interface EventStore {
|
|
|
49
78
|
get(id: string): NostrEvent | undefined;
|
|
50
79
|
/** Query events matching any of the provided filters */
|
|
51
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;
|
|
52
86
|
/** Close the storage backend (optional) */
|
|
53
87
|
close?(): void;
|
|
54
88
|
}
|
|
@@ -58,9 +92,30 @@ interface EventStore {
|
|
|
58
92
|
*/
|
|
59
93
|
declare class InMemoryEventStore implements EventStore {
|
|
60
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);
|
|
61
102
|
store(event: NostrEvent): void;
|
|
62
103
|
get(id: string): NostrEvent | undefined;
|
|
63
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;
|
|
64
119
|
/**
|
|
65
120
|
* Close the storage backend (no-op for in-memory store).
|
|
66
121
|
*/
|
|
@@ -87,14 +142,25 @@ declare class SqliteEventStore implements EventStore {
|
|
|
87
142
|
private deleteByPubkeyKindDTagStmt;
|
|
88
143
|
private getByPubkeyKindStmt;
|
|
89
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;
|
|
90
152
|
/**
|
|
91
153
|
* Create a new SqliteEventStore.
|
|
92
154
|
* @param dbPath - Path to the database file. Use ':memory:' for in-memory database.
|
|
155
|
+
* @param options - Expiry-enforcement and operator-blocklist settings.
|
|
93
156
|
*/
|
|
94
|
-
constructor(dbPath?: string);
|
|
157
|
+
constructor(dbPath?: string, options?: EventStoreOptions);
|
|
95
158
|
/**
|
|
96
159
|
* Store an event in the database.
|
|
97
|
-
*
|
|
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.
|
|
98
164
|
*/
|
|
99
165
|
store(event: NostrEvent): void;
|
|
100
166
|
/**
|
|
@@ -107,8 +173,64 @@ declare class SqliteEventStore implements EventStore {
|
|
|
107
173
|
* Only keeps the latest event per pubkey+kind+d-tag.
|
|
108
174
|
*/
|
|
109
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;
|
|
110
228
|
/**
|
|
111
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.
|
|
112
234
|
*/
|
|
113
235
|
get(id: string): NostrEvent | undefined;
|
|
114
236
|
/**
|
|
@@ -140,6 +262,190 @@ declare class SqliteEventStore implements EventStore {
|
|
|
140
262
|
*/
|
|
141
263
|
declare function matchFilter(event: NostrEvent, filter: Filter): boolean;
|
|
142
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
|
+
|
|
143
449
|
/**
|
|
144
450
|
* Event-signature verification for the paid-write hot path (relay#85).
|
|
145
451
|
*
|
|
@@ -414,39 +720,6 @@ declare class NostrRelayServer {
|
|
|
414
720
|
private handleConnection;
|
|
415
721
|
}
|
|
416
722
|
|
|
417
|
-
/**
|
|
418
|
-
* TOON event codec.
|
|
419
|
-
*
|
|
420
|
-
* Encodes/decodes Nostr events to and from the TOON text format. Vendored from
|
|
421
|
-
* `@toon-protocol/core` so the relay depends only on the lightweight
|
|
422
|
-
* `@toon-format/toon` encoder rather than core's full transitive tree (which
|
|
423
|
-
* pulls Arweave / web3 wallet stacks the relay does not use). Same MIT
|
|
424
|
-
* license / org.
|
|
425
|
-
*
|
|
426
|
-
* NOTE: this codec is NOT used on the relay's NIP-01 read surface. Outbound
|
|
427
|
-
* EVENT frames are canonical NIP-01 JSON (see
|
|
428
|
-
* `websocket/ConnectionHandler.sendEvent`, #46) so that standard nostr clients
|
|
429
|
-
* can parse events and verify signatures from the wire. The codec remains
|
|
430
|
-
* exported for library consumers that exchange TOON-text events elsewhere.
|
|
431
|
-
*
|
|
432
|
-
* @module
|
|
433
|
-
*/
|
|
434
|
-
|
|
435
|
-
/** Thrown when a Nostr event cannot be encoded to TOON. */
|
|
436
|
-
declare class ToonEncodeError extends Error {
|
|
437
|
-
readonly code = "TOON_ENCODE_ERROR";
|
|
438
|
-
constructor(message: string, cause?: Error);
|
|
439
|
-
}
|
|
440
|
-
/** Thrown when TOON data cannot be decoded into a valid Nostr event. */
|
|
441
|
-
declare class ToonDecodeError extends Error {
|
|
442
|
-
readonly code = "TOON_DECODE_ERROR";
|
|
443
|
-
constructor(message: string, cause?: Error);
|
|
444
|
-
}
|
|
445
|
-
/** Encode a Nostr event to TOON bytes (UTF-8). */
|
|
446
|
-
declare function encodeEventToToon(event: NostrEvent): Uint8Array;
|
|
447
|
-
/** Decode TOON bytes into a validated Nostr event. */
|
|
448
|
-
declare function decodeEventFromToon(data: Uint8Array): NostrEvent;
|
|
449
|
-
|
|
450
723
|
/**
|
|
451
724
|
* Subscribe to upstream relays and propagate events into the local EventStore.
|
|
452
725
|
*
|
|
@@ -502,14 +775,23 @@ declare class RelaySubscriber {
|
|
|
502
775
|
* time a write reaches this process it is already proven paid, so the relay
|
|
503
776
|
* simply stores the event and serves reads.
|
|
504
777
|
*
|
|
505
|
-
*
|
|
778
|
+
* Three surfaces:
|
|
506
779
|
*
|
|
507
|
-
* - `POST /write` (TOON_BLS_PORT, default 3100): accepts `{ event }` as JSON
|
|
508
|
-
*
|
|
509
|
-
*
|
|
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
|
|
510
785
|
* integrity (paid ephemeral kinds skip schnorr by default and keep the
|
|
511
786
|
* id check -- relay#85, see `verifyEphemeral`), and stores it.
|
|
512
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`).
|
|
513
795
|
* - Free NIP-01 WebSocket reads (TOON_RELAY_PORT, default 7100).
|
|
514
796
|
*
|
|
515
797
|
* `startRelay()` returns a `RelayInstance` with an explicit `.stop()` for
|
|
@@ -566,6 +848,54 @@ interface RelayConfig {
|
|
|
566
848
|
* caller owns its lifecycle when supplied.
|
|
567
849
|
*/
|
|
568
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[];
|
|
569
899
|
/** Skip event-signature verification on `POST /write` (default: false). */
|
|
570
900
|
devMode?: boolean;
|
|
571
901
|
/**
|
|
@@ -589,6 +919,22 @@ interface RelayConfig {
|
|
|
589
919
|
* loop and jittering ephemeral frame latency (relay#85).
|
|
590
920
|
*/
|
|
591
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;
|
|
592
938
|
/**
|
|
593
939
|
* Log one line per accepted `POST /write` (default: false). Per-event
|
|
594
940
|
* console I/O is measurable tail jitter on the write hot path (relay#85),
|
|
@@ -609,7 +955,16 @@ interface ResolvedRelayConfig {
|
|
|
609
955
|
devMode: boolean;
|
|
610
956
|
verifyEphemeral: boolean;
|
|
611
957
|
verifyWorkers: number;
|
|
958
|
+
ephemeralRateLimit: {
|
|
959
|
+
maxRequests: number;
|
|
960
|
+
windowMs: number;
|
|
961
|
+
};
|
|
962
|
+
ephemeralMaxBodyBytes: number;
|
|
612
963
|
logWrites: boolean;
|
|
964
|
+
enforceExpiration: boolean;
|
|
965
|
+
expirationReapGraceSeconds: number;
|
|
966
|
+
expirationReapIntervalSeconds: number;
|
|
967
|
+
blockedEventIds: string[];
|
|
613
968
|
}
|
|
614
969
|
/**
|
|
615
970
|
* A running TOON relay node instance returned by `startRelay()`.
|
|
@@ -668,6 +1023,12 @@ declare function isInternalBindHost(host: string): boolean;
|
|
|
668
1023
|
* compose deploy the port binds 0.0.0.0 inside the container and is private
|
|
669
1024
|
* because it is never host-published (docker `expose:`, not `ports:`).
|
|
670
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
|
+
*
|
|
671
1032
|
* @internal Exported for unit testing.
|
|
672
1033
|
*/
|
|
673
1034
|
declare function warnIfWritePortExposed(writeHost: string, blsPort: number, options: {
|
|
@@ -784,6 +1145,21 @@ interface MetricsSnapshot {
|
|
|
784
1145
|
/** Verify-pool worker count (0 = inline on the event loop). */
|
|
785
1146
|
workers: number;
|
|
786
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
|
+
};
|
|
787
1163
|
}
|
|
788
1164
|
/** Live registry behind `GET /metrics`. */
|
|
789
1165
|
interface MetricsRegistry {
|
|
@@ -801,30 +1177,41 @@ interface MetricsRegistry {
|
|
|
801
1177
|
* `recordVerify` into the verify pool's `onMeasure` and serves `snapshot()`
|
|
802
1178
|
* from `GET /metrics`.
|
|
803
1179
|
*
|
|
804
|
-
* @param info - Static verify metadata surfaced in
|
|
1180
|
+
* @param info - Static verify metadata + ephemeral-lane bounds surfaced in
|
|
1181
|
+
* the snapshot (relay#129).
|
|
805
1182
|
*/
|
|
806
1183
|
declare function createMetricsRegistry(info: {
|
|
807
1184
|
verifyImplementation: string;
|
|
808
1185
|
verifyWorkers: number;
|
|
1186
|
+
ephemeralRateLimit: {
|
|
1187
|
+
maxRequests: number;
|
|
1188
|
+
windowMs: number;
|
|
1189
|
+
};
|
|
1190
|
+
ephemeralMaxBodyBytes: number;
|
|
809
1191
|
}): MetricsRegistry;
|
|
810
1192
|
|
|
811
1193
|
/**
|
|
812
1194
|
* Write handler for @toon-protocol/relay.
|
|
813
1195
|
*
|
|
814
|
-
* Exposes a plain-HTTP write surface that accepts an event-as-JSON,
|
|
815
|
-
*
|
|
816
|
-
* 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.
|
|
817
1198
|
*
|
|
818
1199
|
* This handler is intentionally decoupled from any payment layer: it contains
|
|
819
1200
|
* no claim/settlement/ILP logic and imports none of it. Payment validation is
|
|
820
|
-
* the upstream terminator's concern; by the time a request reaches this
|
|
821
|
-
*
|
|
822
|
-
*
|
|
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.
|
|
823
1211
|
*
|
|
824
1212
|
* Flow:
|
|
825
1213
|
* 1. Parse JSON body `{ event }` -> 400 on malformed/missing event
|
|
826
|
-
* 2.
|
|
827
|
-
* 3. Verify the event signature (skipped in devMode) -> 422 on invalid sig.
|
|
1214
|
+
* 2. Verify the event signature (skipped in devMode) -> 422 on invalid sig.
|
|
828
1215
|
* Verification uses the fast WASM libsecp256k1 path (crypto/verify-event)
|
|
829
1216
|
* rather than noble pure-JS: post-#84 the synchronous ~1.3ms noble verify
|
|
830
1217
|
* on the single event loop WAS the write-path ceiling (~240-260 events/s
|
|
@@ -834,17 +1221,18 @@ declare function createMetricsRegistry(info: {
|
|
|
834
1221
|
* default -- only the SHA-256 id check runs -> 422 on id mismatch. See
|
|
835
1222
|
* the loud comment at the verify step for why this is safe, and
|
|
836
1223
|
* `verifyEphemeral` to turn full verification back on.
|
|
837
|
-
*
|
|
1224
|
+
* 3. Store the event in the EventStore -- unless its kind is ephemeral
|
|
838
1225
|
* (NIP-16: 20000 <= kind < 30000), which is delivered live and never
|
|
839
1226
|
* persisted. Skipping the store here is not only NIP-16 semantics: the
|
|
840
1227
|
* synchronous per-event disk write was the serialization point that
|
|
841
1228
|
* capped the whole paid-write pipeline at ~150 events/s globally
|
|
842
1229
|
* (connector#685), and ephemeral traffic -- audio frames -- is exactly
|
|
843
1230
|
* the traffic that hits that path hardest.
|
|
844
|
-
*
|
|
1231
|
+
* 4. Fire the optional onStored callback (ephemeral events included: it is
|
|
845
1232
|
* the live-broadcast hook, and ephemeral events exist only as that
|
|
846
1233
|
* broadcast)
|
|
847
|
-
*
|
|
1234
|
+
* 5. Respond 200 with the event id, storedAt timestamp, and -- when the
|
|
1235
|
+
* connector stated one -- the payment it attributed the write to
|
|
848
1236
|
*
|
|
849
1237
|
* @module
|
|
850
1238
|
*/
|
|
@@ -919,4 +1307,227 @@ interface WriteHandler {
|
|
|
919
1307
|
*/
|
|
920
1308
|
declare function createWriteHandler(config: WriteHandlerConfig): WriteHandler;
|
|
921
1309
|
|
|
922
|
-
|
|
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 };
|