@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
|
@@ -5,16 +5,17 @@ import {
|
|
|
5
5
|
} from "./chunk-SMT6G3XD.js";
|
|
6
6
|
|
|
7
7
|
// src/version.ts
|
|
8
|
-
var VERSION = "
|
|
8
|
+
var VERSION = "2.1.0";
|
|
9
9
|
|
|
10
10
|
// src/types.ts
|
|
11
11
|
var DEFAULT_RELAY_CONFIG = {
|
|
12
|
-
port:
|
|
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
|
-
|
|
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
|
|
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
|
-
*
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
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:
|
|
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 (
|
|
413
|
-
sql += ` WHERE ${
|
|
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
|
|
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
|
-
|
|
1007
|
-
|
|
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
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
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
|
-
|
|
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:
|
|
1195
|
-
metrics.setVerifyWorkers(verifyPool.size);
|
|
1196
|
-
return verifyPool.verify(event);
|
|
1197
|
-
},
|
|
1699
|
+
verifyEvent: verifyViaPool,
|
|
1198
1700
|
logWrites,
|
|
1199
|
-
onStored:
|
|
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-
|
|
1799
|
+
//# sourceMappingURL=chunk-IZOSPMWV.js.map
|