@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.
@@ -5,16 +5,17 @@ import {
5
5
  } from "./chunk-SMT6G3XD.js";
6
6
 
7
7
  // src/version.ts
8
- var VERSION = "0.1.0";
8
+ var VERSION = "2.1.0";
9
9
 
10
10
  // src/types.ts
11
11
  var DEFAULT_RELAY_CONFIG = {
12
- port: 7e3,
12
+ port: 7100,
13
13
  host: "0.0.0.0",
14
14
  maxConnections: 4096,
15
15
  maxSubscriptionsPerConnection: 20,
16
16
  maxFiltersPerSubscription: 10,
17
- databasePath: ":memory:"
17
+ databasePath: ":memory:",
18
+ enforceExpiration: true
18
19
  };
19
20
 
20
21
  // src/filters/matchFilter.ts
@@ -55,17 +56,98 @@ function matchFilter(event, filter) {
55
56
  return true;
56
57
  }
57
58
 
59
+ // src/nips/expiration.ts
60
+ var EXPIRATION_TAG = "expiration";
61
+ function getExpiration(event) {
62
+ for (const tag of event.tags) {
63
+ if (tag[0] !== EXPIRATION_TAG) continue;
64
+ const raw = tag[1];
65
+ if (raw === void 0) continue;
66
+ if (!/^\d+$/.test(raw)) continue;
67
+ const seconds = Number(raw);
68
+ if (!Number.isSafeInteger(seconds)) continue;
69
+ return seconds;
70
+ }
71
+ return void 0;
72
+ }
73
+ function isExpired(event, nowSeconds2) {
74
+ const expiration = getExpiration(event);
75
+ return expiration !== void 0 && expiration <= nowSeconds2;
76
+ }
77
+
78
+ // src/nips/deletion.ts
79
+ var DELETION_KIND = 5;
80
+ function isDeletionKind(kind) {
81
+ return kind === DELETION_KIND;
82
+ }
83
+ var HEX_64 = /^[0-9a-f]{64}$/;
84
+ function parseAddressCoordinate(value) {
85
+ const firstSep = value.indexOf(":");
86
+ if (firstSep < 0) return void 0;
87
+ const secondSep = value.indexOf(":", firstSep + 1);
88
+ if (secondSep < 0) return void 0;
89
+ const kindPart = value.slice(0, firstSep);
90
+ const pubkey = value.slice(firstSep + 1, secondSep);
91
+ const identifier = value.slice(secondSep + 1);
92
+ if (!/^\d+$/.test(kindPart)) return void 0;
93
+ const kind = Number(kindPart);
94
+ if (!Number.isSafeInteger(kind)) return void 0;
95
+ if (!HEX_64.test(pubkey)) return void 0;
96
+ return { kind, pubkey, identifier };
97
+ }
98
+ function parseDeletionTargets(event) {
99
+ const ids = /* @__PURE__ */ new Set();
100
+ const addresses = /* @__PURE__ */ new Map();
101
+ for (const tag of event.tags) {
102
+ const value = tag[1];
103
+ if (value === void 0) continue;
104
+ if (tag[0] === "e") {
105
+ if (HEX_64.test(value)) ids.add(value);
106
+ } else if (tag[0] === "a") {
107
+ const coordinate = parseAddressCoordinate(value);
108
+ if (coordinate) addresses.set(value, coordinate);
109
+ }
110
+ }
111
+ return { ids: [...ids], addresses: [...addresses.values()] };
112
+ }
113
+ function isDeletableBy(target, deletion) {
114
+ return target.pubkey === deletion.pubkey && target.created_at <= deletion.created_at;
115
+ }
116
+
58
117
  // src/storage/InMemoryEventStore.ts
59
- var InMemoryEventStore = class {
118
+ var InMemoryEventStore = class _InMemoryEventStore {
60
119
  events = /* @__PURE__ */ new Map();
120
+ /** NIP-09 id tombstones: event id -> the pubkey that requested deletion. */
121
+ deletedIds = /* @__PURE__ */ new Map();
122
+ /** NIP-09 address tombstones: `<kind>:<pubkey>:<d>` -> deletion created_at. */
123
+ deletedAddresses = /* @__PURE__ */ new Map();
124
+ enforceExpiration;
125
+ blockedEventIds;
126
+ constructor(options = {}) {
127
+ this.enforceExpiration = options.enforceExpiration ?? true;
128
+ this.blockedEventIds = new Set(options.blockedEventIds ?? []);
129
+ }
61
130
  store(event) {
131
+ if (this.blockedEventIds.has(event.id)) return;
132
+ if (this.isRetracted(event)) return;
133
+ if (isDeletionKind(event.kind)) {
134
+ this.applyDeletion(event);
135
+ }
62
136
  this.events.set(event.id, event);
63
137
  }
64
138
  get(id) {
65
- return this.events.get(id);
139
+ const event = this.events.get(id);
140
+ if (!event) return void 0;
141
+ if (this.enforceExpiration && isExpired(event, nowSeconds())) {
142
+ return void 0;
143
+ }
144
+ return event;
66
145
  }
67
146
  query(filters) {
68
- const allEvents = Array.from(this.events.values());
147
+ const now = nowSeconds();
148
+ const allEvents = Array.from(this.events.values()).filter(
149
+ (event) => !this.enforceExpiration || !isExpired(event, now)
150
+ );
69
151
  if (filters.length === 0) {
70
152
  return allEvents.sort((a, b) => b.created_at - a.created_at);
71
153
  }
@@ -85,12 +167,72 @@ var InMemoryEventStore = class {
85
167
  }
86
168
  return matchingEvents;
87
169
  }
170
+ /**
171
+ * Drop events expired for longer than `graceSeconds` (NIP-40).
172
+ *
173
+ * @param now - Current unix time in seconds.
174
+ * @param graceSeconds - Extra time an expired event is kept.
175
+ * @returns The number of events removed.
176
+ */
177
+ reapExpired(now, graceSeconds = 0) {
178
+ let removed = 0;
179
+ for (const [id, event] of this.events) {
180
+ if (isExpired(event, now - graceSeconds)) {
181
+ this.events.delete(id);
182
+ removed++;
183
+ }
184
+ }
185
+ return removed;
186
+ }
187
+ /** The NIP-01 addressable coordinate of an event, `<kind>:<pubkey>:<d>`. */
188
+ static coordinateOf(event) {
189
+ const identifier = event.tags.find((tag) => tag[0] === "d")?.[1] ?? "";
190
+ return `${event.kind}:${event.pubkey}:${identifier}`;
191
+ }
192
+ /** Whether a NIP-09 request already retracted this event (same author). */
193
+ isRetracted(event) {
194
+ if (this.deletedIds.get(event.id) === event.pubkey) return true;
195
+ const deletedAt = this.deletedAddresses.get(
196
+ _InMemoryEventStore.coordinateOf(event)
197
+ );
198
+ return deletedAt !== void 0 && event.created_at <= deletedAt;
199
+ }
200
+ /** Apply a kind:5 request to the author's OWN events only. */
201
+ applyDeletion(deletion) {
202
+ const targets = parseDeletionTargets(deletion);
203
+ for (const id of targets.ids) {
204
+ this.deletedIds.set(id, deletion.pubkey);
205
+ const target = this.events.get(id);
206
+ if (target && isDeletableBy(target, deletion)) {
207
+ this.events.delete(id);
208
+ }
209
+ }
210
+ for (const address of targets.addresses) {
211
+ if (address.pubkey !== deletion.pubkey) continue;
212
+ const coordinate = `${address.kind}:${address.pubkey}:${address.identifier}`;
213
+ this.deletedAddresses.set(
214
+ coordinate,
215
+ Math.max(
216
+ this.deletedAddresses.get(coordinate) ?? deletion.created_at,
217
+ deletion.created_at
218
+ )
219
+ );
220
+ for (const [id, target] of this.events) {
221
+ if (_InMemoryEventStore.coordinateOf(target) === coordinate && isDeletableBy(target, deletion)) {
222
+ this.events.delete(id);
223
+ }
224
+ }
225
+ }
226
+ }
88
227
  /**
89
228
  * Close the storage backend (no-op for in-memory store).
90
229
  */
91
230
  close() {
92
231
  }
93
232
  };
233
+ function nowSeconds() {
234
+ return Math.floor(Date.now() / 1e3);
235
+ }
94
236
 
95
237
  // src/storage/SqliteEventStore.ts
96
238
  import Database from "better-sqlite3";
@@ -103,17 +245,56 @@ CREATE TABLE IF NOT EXISTS events (
103
245
  tags TEXT NOT NULL,
104
246
  created_at INTEGER NOT NULL,
105
247
  sig TEXT NOT NULL,
106
- received_at INTEGER NOT NULL
248
+ received_at INTEGER NOT NULL,
249
+ expires_at INTEGER
250
+ )
251
+ `;
252
+ var DELETED_EVENTS_SCHEMA_SQL = `
253
+ CREATE TABLE IF NOT EXISTS deleted_events (
254
+ event_id TEXT PRIMARY KEY,
255
+ pubkey TEXT NOT NULL,
256
+ deleted_at INTEGER NOT NULL
257
+ )
258
+ `;
259
+ var DELETED_ADDRESSES_SCHEMA_SQL = `
260
+ CREATE TABLE IF NOT EXISTS deleted_addresses (
261
+ coordinate TEXT PRIMARY KEY,
262
+ deleted_at INTEGER NOT NULL
107
263
  )
108
264
  `;
109
265
  var INDEX_SQL = [
110
266
  "CREATE INDEX IF NOT EXISTS idx_events_pubkey ON events(pubkey)",
111
267
  "CREATE INDEX IF NOT EXISTS idx_events_kind ON events(kind)",
112
268
  "CREATE INDEX IF NOT EXISTS idx_events_created_at ON events(created_at)",
113
- "CREATE INDEX IF NOT EXISTS idx_events_pubkey_kind ON events(pubkey, kind)"
269
+ "CREATE INDEX IF NOT EXISTS idx_events_pubkey_kind ON events(pubkey, kind)",
270
+ // Partial index: only the small minority of events that expire at all.
271
+ "CREATE INDEX IF NOT EXISTS idx_events_expires_at ON events(expires_at) WHERE expires_at IS NOT NULL"
114
272
  ];
273
+ function migrateExpiresAtColumn(db) {
274
+ const columns = db.prepare("PRAGMA table_info(events)").all();
275
+ if (columns.some((column) => column.name === "expires_at")) return;
276
+ db.exec("ALTER TABLE events ADD COLUMN expires_at INTEGER");
277
+ const candidates = db.prepare(`SELECT id, tags FROM events WHERE tags LIKE '%"expiration"%'`).all();
278
+ const update = db.prepare("UPDATE events SET expires_at = ? WHERE id = ?");
279
+ const backfill = db.transaction(() => {
280
+ for (const row of candidates) {
281
+ let tags;
282
+ try {
283
+ tags = JSON.parse(row.tags);
284
+ } catch {
285
+ continue;
286
+ }
287
+ const expiresAt = getExpiration({ tags });
288
+ if (expiresAt !== void 0) update.run(expiresAt, row.id);
289
+ }
290
+ });
291
+ backfill();
292
+ }
115
293
  function initializeSchema(db) {
116
294
  db.exec(SCHEMA_SQL);
295
+ db.exec(DELETED_EVENTS_SCHEMA_SQL);
296
+ db.exec(DELETED_ADDRESSES_SCHEMA_SQL);
297
+ migrateExpiresAtColumn(db);
117
298
  for (const indexSql of INDEX_SQL) {
118
299
  db.exec(indexSql);
119
300
  }
@@ -135,7 +316,7 @@ function getDTagValue(tags) {
135
316
  const dTag = tags.find((tag) => tag[0] === "d");
136
317
  return dTag?.[1] ?? "";
137
318
  }
138
- var SqliteEventStore = class {
319
+ var SqliteEventStore = class _SqliteEventStore {
139
320
  db;
140
321
  insertStmt;
141
322
  insertOrIgnoreStmt;
@@ -144,25 +325,58 @@ var SqliteEventStore = class {
144
325
  deleteByPubkeyKindDTagStmt;
145
326
  getByPubkeyKindStmt;
146
327
  getByPubkeyKindDTagStmt;
328
+ tombstoneIdStmt;
329
+ getTombstoneStmt;
330
+ tombstoneAddressStmt;
331
+ getAddressTombstoneStmt;
332
+ deleteExpiredStmt;
333
+ enforceExpiration;
334
+ blockedEventIds;
147
335
  /**
148
336
  * Create a new SqliteEventStore.
149
337
  * @param dbPath - Path to the database file. Use ':memory:' for in-memory database.
338
+ * @param options - Expiry-enforcement and operator-blocklist settings.
150
339
  */
151
- constructor(dbPath = ":memory:") {
340
+ constructor(dbPath = ":memory:", options = {}) {
341
+ this.enforceExpiration = options.enforceExpiration ?? true;
342
+ this.blockedEventIds = new Set(options.blockedEventIds ?? []);
152
343
  try {
153
344
  this.db = new Database(dbPath);
154
345
  this.db.pragma("journal_mode = WAL");
155
346
  this.db.pragma("synchronous = NORMAL");
156
347
  initializeSchema(this.db);
157
348
  this.insertStmt = this.db.prepare(`
158
- INSERT OR REPLACE INTO events (id, pubkey, kind, content, tags, created_at, sig, received_at)
159
- VALUES (?, ?, ?, ?, ?, ?, ?, ?)
349
+ INSERT OR REPLACE INTO events (id, pubkey, kind, content, tags, created_at, sig, received_at, expires_at)
350
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
160
351
  `);
161
352
  this.insertOrIgnoreStmt = this.db.prepare(`
162
- INSERT OR IGNORE INTO events (id, pubkey, kind, content, tags, created_at, sig, received_at)
163
- VALUES (?, ?, ?, ?, ?, ?, ?, ?)
353
+ INSERT OR IGNORE INTO events (id, pubkey, kind, content, tags, created_at, sig, received_at, expires_at)
354
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
164
355
  `);
165
356
  this.getStmt = this.db.prepare("SELECT * FROM events WHERE id = ?");
357
+ this.tombstoneIdStmt = this.db.prepare(
358
+ "INSERT OR REPLACE INTO deleted_events (event_id, pubkey, deleted_at) VALUES (?, ?, ?)"
359
+ );
360
+ this.getTombstoneStmt = this.db.prepare(
361
+ "SELECT pubkey FROM deleted_events WHERE event_id = ?"
362
+ );
363
+ this.tombstoneAddressStmt = this.db.prepare(
364
+ `INSERT INTO deleted_addresses (coordinate, deleted_at) VALUES (?, ?)
365
+ ON CONFLICT(coordinate) DO UPDATE SET deleted_at = MAX(deleted_at, excluded.deleted_at)`
366
+ );
367
+ this.getAddressTombstoneStmt = this.db.prepare(
368
+ "SELECT deleted_at FROM deleted_addresses WHERE coordinate = ?"
369
+ );
370
+ this.deleteExpiredStmt = this.db.prepare(
371
+ "DELETE FROM events WHERE expires_at IS NOT NULL AND expires_at <= ?"
372
+ );
373
+ if (this.blockedEventIds.size > 0) {
374
+ const purge = this.db.prepare("DELETE FROM events WHERE id = ?");
375
+ const purgeAll = this.db.transaction(() => {
376
+ for (const id of this.blockedEventIds) purge.run(id);
377
+ });
378
+ purgeAll();
379
+ }
166
380
  this.deleteByPubkeyKindStmt = this.db.prepare(
167
381
  "DELETE FROM events WHERE pubkey = ? AND kind = ?"
168
382
  );
@@ -184,27 +398,26 @@ var SqliteEventStore = class {
184
398
  }
185
399
  /**
186
400
  * Store an event in the database.
187
- * Handles replaceable and parameterized replaceable events according to NIP-01.
401
+ *
402
+ * Handles replaceable and parameterized replaceable events according to
403
+ * NIP-01, applies NIP-09 deletion requests, and refuses events the operator
404
+ * has blocked or that a previous NIP-09 request already retracted.
188
405
  */
189
406
  store(event) {
190
407
  try {
408
+ if (this.blockedEventIds.has(event.id)) return;
409
+ if (this.isRetracted(event)) return;
191
410
  const tagsJson = JSON.stringify(event.tags);
192
411
  const receivedAt = Math.floor(Date.now() / 1e3);
412
+ if (isDeletionKind(event.kind)) {
413
+ this.applyDeletion(event);
414
+ }
193
415
  if (isReplaceableKind(event.kind)) {
194
416
  this.storeReplaceableEvent(event, tagsJson, receivedAt);
195
417
  } else if (isParameterizedReplaceableKind(event.kind)) {
196
418
  this.storeParameterizedReplaceableEvent(event, tagsJson, receivedAt);
197
419
  } else {
198
- this.insertOrIgnoreStmt.run(
199
- event.id,
200
- event.pubkey,
201
- event.kind,
202
- event.content,
203
- tagsJson,
204
- event.created_at,
205
- event.sig,
206
- receivedAt
207
- );
420
+ this.runInsert(this.insertOrIgnoreStmt, event, tagsJson, receivedAt);
208
421
  }
209
422
  } catch (error) {
210
423
  if (error instanceof RelayError) {
@@ -226,30 +439,12 @@ var SqliteEventStore = class {
226
439
  if (event.created_at > existing.created_at || event.created_at === existing.created_at && event.id < existing.id) {
227
440
  const transaction = this.db.transaction(() => {
228
441
  this.deleteByPubkeyKindStmt.run(event.pubkey, event.kind);
229
- this.insertStmt.run(
230
- event.id,
231
- event.pubkey,
232
- event.kind,
233
- event.content,
234
- tagsJson,
235
- event.created_at,
236
- event.sig,
237
- receivedAt
238
- );
442
+ this.runInsert(this.insertStmt, event, tagsJson, receivedAt);
239
443
  });
240
444
  transaction();
241
445
  }
242
446
  } else {
243
- this.insertStmt.run(
244
- event.id,
245
- event.pubkey,
246
- event.kind,
247
- event.content,
248
- tagsJson,
249
- event.created_at,
250
- event.sig,
251
- receivedAt
252
- );
447
+ this.runInsert(this.insertStmt, event, tagsJson, receivedAt);
253
448
  }
254
449
  }
255
450
  /**
@@ -283,34 +478,136 @@ var SqliteEventStore = class {
283
478
  if (event.created_at > existing.created_at || event.created_at === existing.created_at && event.id < existing.id) {
284
479
  const transaction = this.db.transaction(() => {
285
480
  this.db.prepare("DELETE FROM events WHERE id = ?").run(existing.id);
286
- this.insertStmt.run(
287
- event.id,
288
- event.pubkey,
289
- event.kind,
290
- event.content,
291
- tagsJson,
292
- event.created_at,
293
- event.sig,
294
- receivedAt
295
- );
481
+ this.runInsert(this.insertStmt, event, tagsJson, receivedAt);
296
482
  });
297
483
  transaction();
298
484
  }
299
485
  } else {
300
- this.insertStmt.run(
301
- event.id,
302
- event.pubkey,
303
- event.kind,
304
- event.content,
305
- tagsJson,
306
- event.created_at,
307
- event.sig,
308
- receivedAt
486
+ this.runInsert(this.insertStmt, event, tagsJson, receivedAt);
487
+ }
488
+ }
489
+ /**
490
+ * Bind an event to one of the prepared INSERT statements.
491
+ *
492
+ * The `expires_at` column is derived here, at the single point every write
493
+ * funnels through, so no insert path can forget it.
494
+ */
495
+ runInsert(stmt, event, tagsJson, receivedAt) {
496
+ stmt.run(
497
+ event.id,
498
+ event.pubkey,
499
+ event.kind,
500
+ event.content,
501
+ tagsJson,
502
+ event.created_at,
503
+ event.sig,
504
+ receivedAt,
505
+ getExpiration(event) ?? null
506
+ );
507
+ }
508
+ /**
509
+ * The NIP-01 addressable coordinate of an event, `<kind>:<pubkey>:<d>`.
510
+ * The `d` value is the empty string for events that carry no `d` tag —
511
+ * which is every kind:10032 announce on the network today.
512
+ */
513
+ static coordinateOf(event) {
514
+ return `${event.kind}:${event.pubkey}:${getDTagValue(event.tags)}`;
515
+ }
516
+ /**
517
+ * Whether a NIP-09 deletion request already retracted this event, so it
518
+ * must not be re-admitted.
519
+ *
520
+ * The id tombstone only bites when the arriving event's OWN pubkey matches
521
+ * the pubkey that asked for the deletion; otherwise anyone could pre-block
522
+ * an id they merely predicted. Address tombstones already carry the
523
+ * author's pubkey inside the coordinate.
524
+ */
525
+ isRetracted(event) {
526
+ const tombstone = this.getTombstoneStmt.get(event.id);
527
+ if (tombstone && tombstone.pubkey === event.pubkey) return true;
528
+ const address = this.getAddressTombstoneStmt.get(
529
+ _SqliteEventStore.coordinateOf(event)
530
+ );
531
+ return address !== void 0 && event.created_at <= address.deleted_at;
532
+ }
533
+ /**
534
+ * Apply a NIP-09 deletion request: remove the author's own targeted events
535
+ * and record tombstones so a re-publish cannot resurrect them.
536
+ *
537
+ * Every statement here is scoped by `pubkey = <the requester>`, which is
538
+ * what makes a cross-author deletion a no-op rather than a privilege.
539
+ */
540
+ applyDeletion(deletion) {
541
+ const targets = parseDeletionTargets(deletion);
542
+ if (targets.ids.length === 0 && targets.addresses.length === 0) return;
543
+ const deleteById = this.db.prepare(
544
+ "DELETE FROM events WHERE id = ? AND pubkey = ? AND created_at <= ?"
545
+ );
546
+ const apply = this.db.transaction(() => {
547
+ for (const id of targets.ids) {
548
+ this.tombstoneIdStmt.run(id, deletion.pubkey, deletion.created_at);
549
+ deleteById.run(id, deletion.pubkey, deletion.created_at);
550
+ }
551
+ for (const address of targets.addresses) {
552
+ if (address.pubkey !== deletion.pubkey) continue;
553
+ const coordinate = `${address.kind}:${address.pubkey}:${address.identifier}`;
554
+ this.tombstoneAddressStmt.run(coordinate, deletion.created_at);
555
+ for (const row of this.findByCoordinate(address.kind, address.pubkey)) {
556
+ if (getDTagValue(row.tags) === address.identifier && isDeletableBy(row, deletion)) {
557
+ this.db.prepare("DELETE FROM events WHERE id = ?").run(row.id);
558
+ }
559
+ }
560
+ }
561
+ });
562
+ apply();
563
+ }
564
+ /**
565
+ * Rows for a (kind, pubkey) pair with their parsed tags, so the caller can
566
+ * compare `d` values in code. SQL cannot distinguish `["d",""]` from a
567
+ * missing `d` tag, and both mean "the empty identifier".
568
+ */
569
+ findByCoordinate(kind, pubkey) {
570
+ const rows = this.db.prepare(
571
+ "SELECT id, pubkey, created_at, tags FROM events WHERE pubkey = ? AND kind = ?"
572
+ ).all(pubkey, kind);
573
+ return rows.map((row) => ({
574
+ id: row.id,
575
+ pubkey: row.pubkey,
576
+ created_at: row.created_at,
577
+ tags: JSON.parse(row.tags)
578
+ }));
579
+ }
580
+ /**
581
+ * NIP-40 reaper: permanently delete events whose expiration is further than
582
+ * `graceSeconds` in the past.
583
+ *
584
+ * The grace window is the safety net for enforcement itself. Serve-time
585
+ * filtering is instantly reversible (flip `enforceExpiration` off and every
586
+ * still-present event is served again); a DELETE is not. Keeping recently
587
+ * expired events on disk for a while means an operator who discovers that
588
+ * enforcement broke discovery can undo it without having lost the data.
589
+ *
590
+ * @param nowSeconds - Current unix time in seconds.
591
+ * @param graceSeconds - Extra time to keep an expired event on disk.
592
+ * @returns The number of rows deleted.
593
+ */
594
+ reapExpired(nowSeconds2, graceSeconds = 0) {
595
+ try {
596
+ const result = this.deleteExpiredStmt.run(nowSeconds2 - graceSeconds);
597
+ return result.changes;
598
+ } catch (error) {
599
+ throw new RelayError(
600
+ `Failed to reap expired events: ${error instanceof Error ? error.message : String(error)}`,
601
+ "STORAGE_ERROR"
309
602
  );
310
603
  }
311
604
  }
312
605
  /**
313
606
  * Retrieve an event by its ID.
607
+ *
608
+ * Returns undefined for an event that is past its NIP-40 expiration while
609
+ * enforcement is on, even though the row may still be on disk inside the
610
+ * reaper's grace window.
314
611
  */
315
612
  get(id) {
316
613
  try {
@@ -318,6 +615,9 @@ var SqliteEventStore = class {
318
615
  if (!row) {
319
616
  return void 0;
320
617
  }
618
+ if (this.enforceExpiration && row.expires_at !== null && row.expires_at <= Math.floor(Date.now() / 1e3)) {
619
+ return void 0;
620
+ }
321
621
  return {
322
622
  id: row.id,
323
623
  pubkey: row.pubkey,
@@ -362,14 +662,19 @@ var SqliteEventStore = class {
362
662
  * Build SQL query from filters.
363
663
  */
364
664
  buildQuerySql(filters) {
665
+ const params = [];
666
+ let liveClause = "";
667
+ if (this.enforceExpiration) {
668
+ liveClause = "(expires_at IS NULL OR expires_at > ?)";
669
+ params.push(Math.floor(Date.now() / 1e3));
670
+ }
365
671
  if (filters.length === 0) {
366
672
  return {
367
- sql: "SELECT * FROM events ORDER BY created_at DESC",
368
- params: []
673
+ sql: `SELECT * FROM events${liveClause ? ` WHERE ${liveClause}` : ""} ORDER BY created_at DESC`,
674
+ params
369
675
  };
370
676
  }
371
677
  const conditions = [];
372
- const params = [];
373
678
  for (const filter of filters) {
374
679
  const filterConditions = [];
375
680
  if (filter.ids?.length) {
@@ -408,9 +713,12 @@ var SqliteEventStore = class {
408
713
  conditions.push(`(${filterConditions.join(" AND ")})`);
409
714
  }
410
715
  }
716
+ const whereParts = [];
717
+ if (liveClause) whereParts.push(liveClause);
718
+ if (conditions.length > 0) whereParts.push(`(${conditions.join(" OR ")})`);
411
719
  let sql = "SELECT * FROM events";
412
- if (conditions.length > 0) {
413
- sql += ` WHERE ${conditions.join(" OR ")}`;
720
+ if (whereParts.length > 0) {
721
+ sql += ` WHERE ${whereParts.join(" AND ")}`;
414
722
  }
415
723
  sql += " ORDER BY created_at DESC";
416
724
  const limitFilter = filters.find((f) => f.limit !== void 0);
@@ -428,6 +736,23 @@ var SqliteEventStore = class {
428
736
  }
429
737
  };
430
738
 
739
+ // src/nips/blocklist.ts
740
+ var HEX_642 = /^[0-9a-f]{64}$/;
741
+ function parseBlockedEventIds(raw) {
742
+ const ids = /* @__PURE__ */ new Set();
743
+ const invalid = [];
744
+ for (const entry of (raw ?? "").split(/[\s,]+/)) {
745
+ if (entry === "") continue;
746
+ const normalized = entry.toLowerCase();
747
+ if (HEX_642.test(normalized)) {
748
+ ids.add(normalized);
749
+ } else {
750
+ invalid.push(entry);
751
+ }
752
+ }
753
+ return { ids: [...ids], invalid };
754
+ }
755
+
431
756
  // src/crypto/verify-pool.ts
432
757
  import { existsSync } from "fs";
433
758
  import { cpus } from "os";
@@ -654,6 +979,9 @@ var ConnectionHandler = class {
654
979
  * on first matching send.
655
980
  */
656
981
  notifyNewEvent(event, eventJson) {
982
+ if (this.config.enforceExpiration && isExpired(event, Math.floor(Date.now() / 1e3))) {
983
+ return;
984
+ }
657
985
  let json = eventJson;
658
986
  for (const sub of this.subscriptions.values()) {
659
987
  const matches = sub.filters.some((f) => matchFilter(event, f));
@@ -967,6 +1295,11 @@ function createMetricsRegistry(info) {
967
1295
  maxMs: round(maxMs),
968
1296
  p50Ms: round(percentileOf(recent, 0.5)),
969
1297
  p99Ms: round(percentileOf(recent, 0.99))
1298
+ },
1299
+ ephemeralWriteLane: {
1300
+ enabled: true,
1301
+ rateLimit: info.ephemeralRateLimit,
1302
+ maxBodyBytes: info.ephemeralMaxBodyBytes
970
1303
  }
971
1304
  };
972
1305
  },
@@ -979,6 +1312,22 @@ function createMetricsRegistry(info) {
979
1312
  };
980
1313
  }
981
1314
 
1315
+ // src/launcher/handlers/payment-attribution.ts
1316
+ var EVM_PAYER = /^evm:0x[0-9a-f]{64}$/;
1317
+ var SOLANA_PAYER = /^solana:[1-9A-HJ-NP-Za-km-z]{32,44}$/;
1318
+ var AMOUNT = /^[0-9]+$/;
1319
+ function readPaymentAttribution(c) {
1320
+ const payer = c.req.header("X-TOON-Payer");
1321
+ const amount = c.req.header("X-TOON-Amount");
1322
+ const chain = c.req.header("X-TOON-Chain");
1323
+ if (!payer || !amount || !chain) return void 0;
1324
+ if (chain !== "evm" && chain !== "solana") return void 0;
1325
+ if (!AMOUNT.test(amount)) return void 0;
1326
+ const payerMatchesChain = chain === "evm" ? EVM_PAYER.test(payer) : SOLANA_PAYER.test(payer);
1327
+ if (!payerMatchesChain) return void 0;
1328
+ return { payer, amount, chain };
1329
+ }
1330
+
982
1331
  // src/launcher/handlers/write-handler.ts
983
1332
  function isEphemeralKind(kind) {
984
1333
  return kind >= 2e4 && kind < 3e4;
@@ -999,13 +1348,10 @@ function createWriteHandler(config) {
999
1348
  return c.json({ error: "Missing required field: event" }, 400);
1000
1349
  }
1001
1350
  const event = body.event;
1002
- const payer = c.req.header("X-TOON-Payer");
1003
- const amount = c.req.header("X-TOON-Amount");
1004
- const chain = c.req.header("X-TOON-Chain");
1351
+ const payment = readPaymentAttribution(c);
1005
1352
  if (logWrites) {
1006
- console.log(
1007
- `[write] event=${event.id} payer=${payer ?? "-"} amount=${amount ?? "-"} chain=${chain ?? "-"}`
1008
- );
1353
+ const attribution = payment ? ` payer=${payment.payer} amount=${payment.amount} chain=${payment.chain}` : "";
1354
+ console.log(`[write] event=${event.id} handler=write${attribution}`);
1009
1355
  }
1010
1356
  if (!config.devMode) {
1011
1357
  if (isEphemeralKind(event.kind) && !verifyEphemeral) {
@@ -1024,9 +1370,106 @@ function createWriteHandler(config) {
1024
1370
  {
1025
1371
  eventId: event.id,
1026
1372
  storedAt: Math.floor(Date.now() / 1e3),
1027
- payer,
1028
- amount,
1029
- chain
1373
+ ...payment ? { payment } : {}
1374
+ },
1375
+ 200
1376
+ );
1377
+ }
1378
+ };
1379
+ }
1380
+
1381
+ // src/launcher/rate-limiter.ts
1382
+ function createRateLimiter(options) {
1383
+ const { maxRequests, windowMs } = options;
1384
+ const now = options.now ?? Date.now;
1385
+ const hits = /* @__PURE__ */ new Map();
1386
+ return {
1387
+ allow(key) {
1388
+ const t = now();
1389
+ const windowStart = t - windowMs;
1390
+ const timestamps = (hits.get(key) ?? []).filter(
1391
+ (ts) => ts >= windowStart
1392
+ );
1393
+ hits.set(key, timestamps);
1394
+ if (timestamps.length >= maxRequests) {
1395
+ return false;
1396
+ }
1397
+ timestamps.push(t);
1398
+ return true;
1399
+ }
1400
+ };
1401
+ }
1402
+
1403
+ // src/launcher/handlers/write-ephemeral-handler.ts
1404
+ import { getConnInfo } from "@hono/node-server/conninfo";
1405
+ function isEphemeralKind2(kind) {
1406
+ return kind >= 2e4 && kind < 3e4;
1407
+ }
1408
+ var DEFAULT_EPHEMERAL_RATE_LIMIT = {
1409
+ maxRequests: 200,
1410
+ windowMs: 1e4
1411
+ };
1412
+ var DEFAULT_EPHEMERAL_MAX_BODY_BYTES = 8 * 1024;
1413
+ function defaultClientKey(c) {
1414
+ try {
1415
+ return getConnInfo(c).remote.address ?? "unknown";
1416
+ } catch {
1417
+ return "unknown";
1418
+ }
1419
+ }
1420
+ function createEphemeralWriteHandler(config = {}) {
1421
+ const logWrites = config.logWrites ?? false;
1422
+ const verifyEvent2 = config.verifyEvent ?? verifyEventSignature;
1423
+ const maxBodyBytes = config.maxBodyBytes ?? DEFAULT_EPHEMERAL_MAX_BODY_BYTES;
1424
+ const rateLimiter = config.rateLimiter ?? createRateLimiter(config.rateLimit ?? DEFAULT_EPHEMERAL_RATE_LIMIT);
1425
+ const getClientKey = config.getClientKey ?? defaultClientKey;
1426
+ return {
1427
+ async handleWrite(c) {
1428
+ if (!rateLimiter.allow(getClientKey(c))) {
1429
+ return c.json({ error: "Rate limit exceeded" }, 429);
1430
+ }
1431
+ const contentLengthHeader = c.req.header("content-length");
1432
+ if (contentLengthHeader !== void 0 && Number(contentLengthHeader) > maxBodyBytes) {
1433
+ return c.json({ error: "Request body too large" }, 413);
1434
+ }
1435
+ let rawBody;
1436
+ try {
1437
+ rawBody = await c.req.text();
1438
+ } catch {
1439
+ return c.json({ error: "Invalid request body" }, 400);
1440
+ }
1441
+ if (Buffer.byteLength(rawBody, "utf8") > maxBodyBytes) {
1442
+ return c.json({ error: "Request body too large" }, 413);
1443
+ }
1444
+ let body;
1445
+ try {
1446
+ body = JSON.parse(rawBody);
1447
+ } catch {
1448
+ return c.json({ error: "Invalid request body" }, 400);
1449
+ }
1450
+ if (!body.event) {
1451
+ return c.json({ error: "Missing required field: event" }, 400);
1452
+ }
1453
+ const event = body.event;
1454
+ if (!isEphemeralKind2(event.kind)) {
1455
+ return c.json(
1456
+ {
1457
+ error: "Only ephemeral kinds (20000-29999) are accepted on this lane"
1458
+ },
1459
+ 400
1460
+ );
1461
+ }
1462
+ if (logWrites) {
1463
+ console.log(`[write] event=${event.id} handler=write-ephemeral`);
1464
+ }
1465
+ if (!await verifyEvent2(event)) {
1466
+ return c.json({ error: "Invalid event signature" }, 422);
1467
+ }
1468
+ config.onBroadcast?.(event);
1469
+ return c.json(
1470
+ {
1471
+ eventId: event.id,
1472
+ broadcastAt: Math.floor(Date.now() / 1e3)
1030
1473
  },
1031
1474
  200
1032
1475
  );
@@ -1139,7 +1582,13 @@ async function startRelay(config) {
1139
1582
  const devMode = config.devMode ?? false;
1140
1583
  const verifyEphemeral = config.verifyEphemeral ?? false;
1141
1584
  const verifyWorkers = config.verifyWorkers ?? defaultVerifyWorkers();
1585
+ const ephemeralRateLimit = config.ephemeralRateLimit ?? DEFAULT_EPHEMERAL_RATE_LIMIT;
1586
+ const ephemeralMaxBodyBytes = config.ephemeralMaxBodyBytes ?? DEFAULT_EPHEMERAL_MAX_BODY_BYTES;
1142
1587
  const logWrites = config.logWrites ?? false;
1588
+ const enforceExpiration = config.enforceExpiration ?? true;
1589
+ const expirationReapGraceSeconds = config.expirationReapGraceSeconds ?? 86400;
1590
+ const expirationReapIntervalSeconds = config.expirationReapIntervalSeconds ?? 3600;
1591
+ const blockedEventIds = config.blockedEventIds ?? [];
1143
1592
  const resolvedConfig = {
1144
1593
  relayPort,
1145
1594
  blsPort,
@@ -1150,19 +1599,63 @@ async function startRelay(config) {
1150
1599
  devMode,
1151
1600
  verifyEphemeral,
1152
1601
  verifyWorkers,
1153
- logWrites
1602
+ ephemeralRateLimit,
1603
+ ephemeralMaxBodyBytes,
1604
+ logWrites,
1605
+ enforceExpiration,
1606
+ expirationReapGraceSeconds,
1607
+ expirationReapIntervalSeconds,
1608
+ blockedEventIds
1154
1609
  };
1155
1610
  let eventStore;
1156
1611
  if (config.eventStore) {
1157
1612
  eventStore = config.eventStore;
1158
1613
  } else {
1159
1614
  mkdirSync(dataDir, { recursive: true });
1160
- eventStore = new SqliteEventStore(join(dataDir, "events.db"));
1615
+ eventStore = new SqliteEventStore(join(dataDir, "events.db"), {
1616
+ enforceExpiration,
1617
+ blockedEventIds
1618
+ });
1619
+ }
1620
+ console.log(
1621
+ `[relay] NIP-40 expiration: ${enforceExpiration ? `enforced (reap grace ${expirationReapGraceSeconds}s, sweep ${expirationReapIntervalSeconds > 0 ? `every ${expirationReapIntervalSeconds}s` : "disabled"})` : "NOT enforced -- expired events are still served (TOON_ENFORCE_EXPIRATION=false)"}`
1622
+ );
1623
+ console.log("[relay] NIP-09 deletion: enabled (author-signed kind:5 only)");
1624
+ if (blockedEventIds.length > 0) {
1625
+ console.warn(
1626
+ `[relay] OPERATOR BLOCKLIST ACTIVE -- ${blockedEventIds.length} event id(s) refused on write and swept from storage:`
1627
+ );
1628
+ for (const id of blockedEventIds) {
1629
+ console.warn(`[relay] blocked ${id}`);
1630
+ }
1161
1631
  }
1162
1632
  const wsRelay = new NostrRelayServer(
1163
- { port: relayPort, host, maxConnections },
1633
+ { port: relayPort, host, maxConnections, enforceExpiration },
1164
1634
  eventStore
1165
1635
  );
1636
+ let reapTimer;
1637
+ if (enforceExpiration && expirationReapIntervalSeconds > 0 && eventStore.reapExpired) {
1638
+ const reap = () => {
1639
+ try {
1640
+ const removed = eventStore.reapExpired?.(
1641
+ Math.floor(Date.now() / 1e3),
1642
+ expirationReapGraceSeconds
1643
+ );
1644
+ if (removed) {
1645
+ console.log(
1646
+ `[relay] NIP-40 reaper removed ${removed} expired event(s)`
1647
+ );
1648
+ }
1649
+ } catch (error) {
1650
+ console.warn(
1651
+ `[relay] NIP-40 reaper failed: ${error instanceof Error ? error.message : String(error)}`
1652
+ );
1653
+ }
1654
+ };
1655
+ reap();
1656
+ reapTimer = setInterval(reap, expirationReapIntervalSeconds * 1e3);
1657
+ reapTimer.unref();
1658
+ }
1166
1659
  const app = new Hono();
1167
1660
  app.get(
1168
1661
  "/health",
@@ -1170,8 +1663,10 @@ async function startRelay(config) {
1170
1663
  );
1171
1664
  const metrics = createMetricsRegistry({
1172
1665
  verifyImplementation,
1173
- verifyWorkers: 0
1666
+ verifyWorkers: 0,
1174
1667
  // updated once the pool reports its live size below
1668
+ ephemeralRateLimit,
1669
+ ephemeralMaxBodyBytes
1175
1670
  });
1176
1671
  const verifyPool = createVerifyPool({
1177
1672
  size: verifyWorkers,
@@ -1187,23 +1682,39 @@ async function startRelay(config) {
1187
1682
  console.log(
1188
1683
  `[relay] verify pool: ${verifyPool.size > 0 ? `${verifyPool.size} worker thread(s)` : "inline (0 workers -- verification on the event loop)"}`
1189
1684
  );
1685
+ const verifyViaPool = (event) => {
1686
+ metrics.setVerifyWorkers(verifyPool.size);
1687
+ return verifyPool.verify(event);
1688
+ };
1689
+ const broadcastToReaders = (event) => {
1690
+ try {
1691
+ wsRelay.broadcastEvent(event);
1692
+ } catch {
1693
+ }
1694
+ };
1190
1695
  const writeHandler = createWriteHandler({
1191
1696
  eventStore,
1192
1697
  devMode,
1193
1698
  verifyEphemeral,
1194
- verifyEvent: (event) => {
1195
- metrics.setVerifyWorkers(verifyPool.size);
1196
- return verifyPool.verify(event);
1197
- },
1699
+ verifyEvent: verifyViaPool,
1198
1700
  logWrites,
1199
- onStored: (event) => {
1200
- try {
1201
- wsRelay.broadcastEvent(event);
1202
- } catch {
1203
- }
1204
- }
1701
+ onStored: broadcastToReaders
1205
1702
  });
1206
1703
  app.post("/write", (c) => writeHandler.handleWrite(c));
1704
+ console.log(
1705
+ `[relay] ephemeral free write lane: enabled on POST /write-ephemeral (full schnorr verify always; rate limit ${ephemeralRateLimit.maxRequests} req / ${ephemeralRateLimit.windowMs}ms per key; max body ${ephemeralMaxBodyBytes} bytes)`
1706
+ );
1707
+ const ephemeralWriteHandler = createEphemeralWriteHandler({
1708
+ rateLimit: ephemeralRateLimit,
1709
+ maxBodyBytes: ephemeralMaxBodyBytes,
1710
+ verifyEvent: verifyViaPool,
1711
+ logWrites,
1712
+ onBroadcast: broadcastToReaders
1713
+ });
1714
+ app.post(
1715
+ "/write-ephemeral",
1716
+ (c) => ephemeralWriteHandler.handleWrite(c)
1717
+ );
1207
1718
  const blsServer = await new Promise((resolve) => {
1208
1719
  const server = serve(
1209
1720
  { fetch: app.fetch, port: blsPort, hostname: writeHost },
@@ -1235,6 +1746,7 @@ async function startRelay(config) {
1235
1746
  sub.close();
1236
1747
  }
1237
1748
  activeSubscriptions.clear();
1749
+ if (reapTimer) clearInterval(reapTimer);
1238
1750
  await wsRelay.stop();
1239
1751
  blsServer.close();
1240
1752
  metrics.stop();
@@ -1253,9 +1765,18 @@ export {
1253
1765
  VERSION,
1254
1766
  DEFAULT_RELAY_CONFIG,
1255
1767
  matchFilter,
1768
+ EXPIRATION_TAG,
1769
+ getExpiration,
1770
+ isExpired,
1771
+ DELETION_KIND,
1772
+ isDeletionKind,
1773
+ parseAddressCoordinate,
1774
+ parseDeletionTargets,
1775
+ isDeletableBy,
1256
1776
  InMemoryEventStore,
1257
1777
  RelayError,
1258
1778
  SqliteEventStore,
1779
+ parseBlockedEventIds,
1259
1780
  defaultVerifyWorkers,
1260
1781
  createVerifyPool,
1261
1782
  serializeEventFrame,
@@ -1264,10 +1785,15 @@ export {
1264
1785
  NostrRelayServer,
1265
1786
  RelaySubscriber,
1266
1787
  createMetricsRegistry,
1788
+ readPaymentAttribution,
1267
1789
  createWriteHandler,
1790
+ createRateLimiter,
1791
+ DEFAULT_EPHEMERAL_RATE_LIMIT,
1792
+ DEFAULT_EPHEMERAL_MAX_BODY_BYTES,
1793
+ createEphemeralWriteHandler,
1268
1794
  createHealthResponse,
1269
1795
  isInternalBindHost,
1270
1796
  warnIfWritePortExposed,
1271
1797
  startRelay
1272
1798
  };
1273
- //# sourceMappingURL=chunk-QZQRHQEQ.js.map
1799
+ //# sourceMappingURL=chunk-IZOSPMWV.js.map