@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/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
- /** Package version, surfaced on `GET /health`. */
8
- declare const VERSION = "0.1.0";
7
+ declare const VERSION: string;
9
8
 
10
9
  /**
11
10
  * Configuration options for the Nostr relay.
12
11
  */
13
12
  interface RelayServerConfig {
14
- /** Port to listen on (default: 7000) */
13
+ /** Port to listen on (default: 7100) */
15
14
  port: number;
16
15
  /** Host/IP to bind to (default: '0.0.0.0'). Set to '127.0.0.1' for hidden service mode. */
17
16
  host?: string;
@@ -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
- * Handles replaceable and parameterized replaceable events according to NIP-01.
160
+ *
161
+ * Handles replaceable and parameterized replaceable events according to
162
+ * NIP-01, applies NIP-09 deletion requests, and refuses events the operator
163
+ * has blocked or that a previous NIP-09 request already retracted.
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
- * Two surfaces:
778
+ * Three surfaces:
506
779
  *
507
- * - `POST /write` (TOON_BLS_PORT, default 3100): accepts `{ event }` as JSON,
508
- * trusts the injected `X-TOON-Payer`/`-Amount`/`-Chain` headers WITHOUT
509
- * re-validating payment, verifies only the event's own signature for
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 the snapshot.
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, trusts
815
- * (but does NOT validate) injected payment headers, verifies ONLY the event
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 surface
821
- * the trusted `X-TOON-*` headers are assumed already proven. The handler
822
- * captures them purely for the response echo and a log line.
1201
+ * the upstream terminator's concern; by the time a request reaches this
1202
+ * surface it is already proven paid.
1203
+ *
1204
+ * What the terminator DOES state, it records. A terminating connector states
1205
+ * `X-TOON-Payer` / `X-TOON-Amount` / `X-TOON-Chain` on a delivery whose
1206
+ * payment it verified at its own client edge (`toon-protocol/connector`
1207
+ * ADR 0040, relay#133); the handler reads them, echoes a well-formed triple
1208
+ * back on the 200, and treats their absence as "this hop was not the one
1209
+ * paid" -- NEVER as "unpaid". See payment-attribution.ts for the contract and
1210
+ * for why the same header names were right to distrust before ADR 0040.
823
1211
  *
824
1212
  * Flow:
825
1213
  * 1. Parse JSON body `{ event }` -> 400 on malformed/missing event
826
- * 2. Capture trusted X-TOON-Payer / X-TOON-Amount / X-TOON-Chain headers
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
- * 4. Store the event in the EventStore -- unless its kind is ephemeral
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
- * 5. Fire the optional onStored callback (ephemeral events included: it is
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
- * 6. Respond 200 with the event id, storedAt timestamp, and echoed headers
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
- export { ConnectionHandler, DEFAULT_RELAY_CONFIG, type DurationStats, type EventStore, type HealthConfig, type HealthResponse, InMemoryEventStore, type MetricsRegistry, type MetricsSnapshot, NostrRelayServer, type RelayConfig, RelayError, type RelayInstance, type RelayServerConfig, RelaySubscriber, type RelaySubscriberConfig, type RelaySubscription, type ResolvedRelayConfig, SqliteEventStore, type Subscription, ToonDecodeError, ToonEncodeError, VERSION, type VerifyPool, type VerifyPoolOptions, type WriteHandler, type WriteHandlerConfig, createHealthResponse, createMetricsRegistry, createVerifyPool, createWriteHandler, decodeEventFromToon, defaultVerifyWorkers, encodeEventToToon, isInternalBindHost, matchFilter, readOpenFilesSoftLimit, serializeEventFrame, startRelay, verifyEventId, verifyEventSignature, verifyImplementation, warnIfWritePortExposed };
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 };