@openclaw/nostr 2026.5.2 → 2026.5.3-beta.2
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/api.js +532 -0
- package/dist/channel-DfEqBtUh.js +1466 -0
- package/dist/channel-plugin-api.js +2 -0
- package/dist/config-schema-DIk4jlBg.js +64 -0
- package/dist/default-relays-DLwdWOTu.js +4 -0
- package/dist/inbound-direct-dm-runtime-22bZWcIW.js +2 -0
- package/dist/index.js +84 -0
- package/dist/runtime-api.js +2 -0
- package/dist/setup-api.js +2 -0
- package/dist/setup-entry.js +11 -0
- package/dist/setup-plugin-api.js +165 -0
- package/dist/setup-surface-DxAaUTyC.js +336 -0
- package/dist/test-api.js +2 -0
- package/package.json +15 -6
- package/api.ts +0 -10
- package/channel-plugin-api.ts +0 -1
- package/index.ts +0 -97
- package/runtime-api.ts +0 -6
- package/setup-api.ts +0 -1
- package/setup-entry.ts +0 -9
- package/setup-plugin-api.ts +0 -3
- package/src/channel-api.ts +0 -15
- package/src/channel.inbound.test.ts +0 -176
- package/src/channel.outbound.test.ts +0 -128
- package/src/channel.setup.ts +0 -231
- package/src/channel.test.ts +0 -519
- package/src/channel.ts +0 -207
- package/src/config-schema.ts +0 -98
- package/src/default-relays.ts +0 -1
- package/src/gateway.ts +0 -302
- package/src/inbound-direct-dm-runtime.ts +0 -1
- package/src/metrics.ts +0 -458
- package/src/nostr-bus.fuzz.test.ts +0 -360
- package/src/nostr-bus.inbound.test.ts +0 -526
- package/src/nostr-bus.integration.test.ts +0 -472
- package/src/nostr-bus.test.ts +0 -190
- package/src/nostr-bus.ts +0 -789
- package/src/nostr-key-utils.ts +0 -94
- package/src/nostr-profile-core.ts +0 -134
- package/src/nostr-profile-http-runtime.ts +0 -6
- package/src/nostr-profile-http.test.ts +0 -632
- package/src/nostr-profile-http.ts +0 -594
- package/src/nostr-profile-import.test.ts +0 -119
- package/src/nostr-profile-import.ts +0 -262
- package/src/nostr-profile-url-safety.ts +0 -21
- package/src/nostr-profile.fuzz.test.ts +0 -430
- package/src/nostr-profile.test.ts +0 -412
- package/src/nostr-profile.ts +0 -144
- package/src/nostr-state-store.test.ts +0 -237
- package/src/nostr-state-store.ts +0 -223
- package/src/runtime.ts +0 -9
- package/src/seen-tracker.ts +0 -289
- package/src/session-route.ts +0 -25
- package/src/setup-surface.ts +0 -265
- package/src/test-fixtures.ts +0 -45
- package/src/types.ts +0 -117
- package/test/setup.ts +0 -5
- package/test-api.ts +0 -1
- package/tsconfig.json +0 -16
package/src/nostr-bus.ts
DELETED
|
@@ -1,789 +0,0 @@
|
|
|
1
|
-
import { SimplePool, finalizeEvent, getPublicKey, verifyEvent, type Event } from "nostr-tools";
|
|
2
|
-
import { decrypt, encrypt } from "nostr-tools/nip04";
|
|
3
|
-
import {
|
|
4
|
-
createDirectDmPreCryptoGuardPolicy,
|
|
5
|
-
type DirectDmPreCryptoGuardPolicyOverrides,
|
|
6
|
-
} from "openclaw/plugin-sdk/direct-dm-guard-policy";
|
|
7
|
-
import type { NostrProfile } from "./config-schema.js";
|
|
8
|
-
import { DEFAULT_RELAYS } from "./default-relays.js";
|
|
9
|
-
import {
|
|
10
|
-
createMetrics,
|
|
11
|
-
createNoopMetrics,
|
|
12
|
-
type NostrMetrics,
|
|
13
|
-
type MetricsSnapshot,
|
|
14
|
-
type MetricEvent,
|
|
15
|
-
} from "./metrics.js";
|
|
16
|
-
import { validatePrivateKey } from "./nostr-key-utils.js";
|
|
17
|
-
import { publishProfile as publishProfileFn, type ProfilePublishResult } from "./nostr-profile.js";
|
|
18
|
-
import {
|
|
19
|
-
readNostrBusState,
|
|
20
|
-
writeNostrBusState,
|
|
21
|
-
computeSinceTimestamp,
|
|
22
|
-
readNostrProfileState,
|
|
23
|
-
writeNostrProfileState,
|
|
24
|
-
} from "./nostr-state-store.js";
|
|
25
|
-
import { createSeenTracker, type SeenTracker } from "./seen-tracker.js";
|
|
26
|
-
|
|
27
|
-
// ============================================================================
|
|
28
|
-
// Constants
|
|
29
|
-
// ============================================================================
|
|
30
|
-
|
|
31
|
-
const STARTUP_LOOKBACK_SEC = 120; // tolerate relay lag / clock skew
|
|
32
|
-
const MAX_PERSISTED_EVENT_IDS = 5000;
|
|
33
|
-
const STATE_PERSIST_DEBOUNCE_MS = 5000; // Debounce state writes
|
|
34
|
-
const DEFAULT_INBOUND_GUARD_POLICY = createDirectDmPreCryptoGuardPolicy();
|
|
35
|
-
|
|
36
|
-
// Circuit breaker configuration
|
|
37
|
-
const CIRCUIT_BREAKER_THRESHOLD = 5; // failures before opening
|
|
38
|
-
const CIRCUIT_BREAKER_RESET_MS = 30000; // 30 seconds before half-open
|
|
39
|
-
|
|
40
|
-
// Health tracker configuration
|
|
41
|
-
const HEALTH_WINDOW_MS = 60000; // 1 minute window for health stats
|
|
42
|
-
|
|
43
|
-
// ============================================================================
|
|
44
|
-
// Types
|
|
45
|
-
// ============================================================================
|
|
46
|
-
|
|
47
|
-
interface NostrBusOptions {
|
|
48
|
-
/** Private key in hex or nsec format */
|
|
49
|
-
privateKey: string;
|
|
50
|
-
/** WebSocket relay URLs (defaults to damus + nos.lol) */
|
|
51
|
-
relays?: string[];
|
|
52
|
-
/** Account ID for state persistence (optional, defaults to pubkey prefix) */
|
|
53
|
-
accountId?: string;
|
|
54
|
-
/** Called when a DM is received */
|
|
55
|
-
onMessage: (
|
|
56
|
-
pubkey: string,
|
|
57
|
-
text: string,
|
|
58
|
-
reply: (text: string) => Promise<void>,
|
|
59
|
-
meta: { eventId: string; createdAt: number },
|
|
60
|
-
) => Promise<void>;
|
|
61
|
-
/** Called after signature verification and before decrypt to allow sender policy checks (optional) */
|
|
62
|
-
authorizeSender?: (params: {
|
|
63
|
-
senderPubkey: string;
|
|
64
|
-
reply: (text: string) => Promise<void>;
|
|
65
|
-
}) => Promise<"allow" | "block" | "pairing">;
|
|
66
|
-
/** Override pre-crypto DM guardrails for tests or future channel tuning (optional) */
|
|
67
|
-
guardPolicy?: DirectDmPreCryptoGuardPolicyOverrides;
|
|
68
|
-
/** Called on errors (optional) */
|
|
69
|
-
onError?: (error: Error, context: string) => void;
|
|
70
|
-
/** Called on connection status changes (optional) */
|
|
71
|
-
onConnect?: (relay: string) => void;
|
|
72
|
-
/** Called on disconnection (optional) */
|
|
73
|
-
onDisconnect?: (relay: string) => void;
|
|
74
|
-
/** Called on EOSE (end of stored events) for initial sync (optional) */
|
|
75
|
-
onEose?: (relay: string) => void;
|
|
76
|
-
/** Called on each metric event (optional) */
|
|
77
|
-
onMetric?: (event: MetricEvent) => void;
|
|
78
|
-
/** Maximum entries in seen tracker (default: 100,000) */
|
|
79
|
-
maxSeenEntries?: number;
|
|
80
|
-
/** Seen tracker TTL in ms (default: 1 hour) */
|
|
81
|
-
seenTtlMs?: number;
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
type FixedWindowRateLimiter = {
|
|
85
|
-
isRateLimited: (key: string, nowMs?: number) => boolean;
|
|
86
|
-
size: () => number;
|
|
87
|
-
clear: () => void;
|
|
88
|
-
};
|
|
89
|
-
|
|
90
|
-
function createFixedWindowRateLimiter(params: {
|
|
91
|
-
windowMs: number;
|
|
92
|
-
maxRequests: number;
|
|
93
|
-
maxTrackedKeys: number;
|
|
94
|
-
}): FixedWindowRateLimiter {
|
|
95
|
-
const windowMs = Math.max(1, Math.floor(params.windowMs));
|
|
96
|
-
const maxRequests = Math.max(1, Math.floor(params.maxRequests));
|
|
97
|
-
const maxTrackedKeys = Math.max(1, Math.floor(params.maxTrackedKeys));
|
|
98
|
-
const state = new Map<string, { count: number; windowStartMs: number }>();
|
|
99
|
-
|
|
100
|
-
const touch = (key: string, value: { count: number; windowStartMs: number }) => {
|
|
101
|
-
state.delete(key);
|
|
102
|
-
state.set(key, value);
|
|
103
|
-
};
|
|
104
|
-
|
|
105
|
-
const prune = (nowMs: number) => {
|
|
106
|
-
for (const [key, entry] of state) {
|
|
107
|
-
if (nowMs - entry.windowStartMs >= windowMs) {
|
|
108
|
-
state.delete(key);
|
|
109
|
-
}
|
|
110
|
-
}
|
|
111
|
-
while (state.size > maxTrackedKeys) {
|
|
112
|
-
const oldest = state.keys().next().value;
|
|
113
|
-
if (!oldest) {
|
|
114
|
-
break;
|
|
115
|
-
}
|
|
116
|
-
state.delete(oldest);
|
|
117
|
-
}
|
|
118
|
-
};
|
|
119
|
-
|
|
120
|
-
return {
|
|
121
|
-
isRateLimited: (key: string, nowMs = Date.now()) => {
|
|
122
|
-
if (!key) {
|
|
123
|
-
return false;
|
|
124
|
-
}
|
|
125
|
-
prune(nowMs);
|
|
126
|
-
const existing = state.get(key);
|
|
127
|
-
if (!existing || nowMs - existing.windowStartMs >= windowMs) {
|
|
128
|
-
touch(key, { count: 1, windowStartMs: nowMs });
|
|
129
|
-
return false;
|
|
130
|
-
}
|
|
131
|
-
const nextCount = existing.count + 1;
|
|
132
|
-
touch(key, { count: nextCount, windowStartMs: existing.windowStartMs });
|
|
133
|
-
return nextCount > maxRequests;
|
|
134
|
-
},
|
|
135
|
-
size: () => state.size,
|
|
136
|
-
clear: () => state.clear(),
|
|
137
|
-
};
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
export interface NostrBusHandle {
|
|
141
|
-
/** Stop the bus and close connections */
|
|
142
|
-
close: () => void;
|
|
143
|
-
/** Get the bot's public key */
|
|
144
|
-
publicKey: string;
|
|
145
|
-
/** Send a DM to a pubkey */
|
|
146
|
-
sendDm: (toPubkey: string, text: string) => Promise<void>;
|
|
147
|
-
/** Get current metrics snapshot */
|
|
148
|
-
getMetrics: () => MetricsSnapshot;
|
|
149
|
-
/** Publish a profile (kind:0) to all relays */
|
|
150
|
-
publishProfile: (profile: NostrProfile) => Promise<ProfilePublishResult>;
|
|
151
|
-
/** Get the last profile publish state */
|
|
152
|
-
getProfileState: () => Promise<{
|
|
153
|
-
lastPublishedAt: number | null;
|
|
154
|
-
lastPublishedEventId: string | null;
|
|
155
|
-
lastPublishResults: Record<string, "ok" | "failed" | "timeout"> | null;
|
|
156
|
-
}>;
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
// ============================================================================
|
|
160
|
-
// Circuit Breaker
|
|
161
|
-
// ============================================================================
|
|
162
|
-
|
|
163
|
-
interface CircuitBreakerState {
|
|
164
|
-
state: "closed" | "open" | "half_open";
|
|
165
|
-
failures: number;
|
|
166
|
-
lastFailure: number;
|
|
167
|
-
lastSuccess: number;
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
interface CircuitBreaker {
|
|
171
|
-
/** Check if requests should be allowed */
|
|
172
|
-
canAttempt: () => boolean;
|
|
173
|
-
/** Record a success */
|
|
174
|
-
recordSuccess: () => void;
|
|
175
|
-
/** Record a failure */
|
|
176
|
-
recordFailure: () => void;
|
|
177
|
-
/** Get current state */
|
|
178
|
-
getState: () => CircuitBreakerState["state"];
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
function createCircuitBreaker(
|
|
182
|
-
relay: string,
|
|
183
|
-
metrics: NostrMetrics,
|
|
184
|
-
threshold: number = CIRCUIT_BREAKER_THRESHOLD,
|
|
185
|
-
resetMs: number = CIRCUIT_BREAKER_RESET_MS,
|
|
186
|
-
): CircuitBreaker {
|
|
187
|
-
const state: CircuitBreakerState = {
|
|
188
|
-
state: "closed",
|
|
189
|
-
failures: 0,
|
|
190
|
-
lastFailure: 0,
|
|
191
|
-
lastSuccess: Date.now(),
|
|
192
|
-
};
|
|
193
|
-
|
|
194
|
-
return {
|
|
195
|
-
canAttempt(): boolean {
|
|
196
|
-
if (state.state === "closed") {
|
|
197
|
-
return true;
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
if (state.state === "open") {
|
|
201
|
-
// Check if enough time has passed to try half-open
|
|
202
|
-
if (Date.now() - state.lastFailure >= resetMs) {
|
|
203
|
-
state.state = "half_open";
|
|
204
|
-
metrics.emit("relay.circuit_breaker.half_open", 1, { relay });
|
|
205
|
-
return true;
|
|
206
|
-
}
|
|
207
|
-
return false;
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
// half_open: allow one attempt
|
|
211
|
-
return true;
|
|
212
|
-
},
|
|
213
|
-
|
|
214
|
-
recordSuccess(): void {
|
|
215
|
-
if (state.state === "half_open") {
|
|
216
|
-
state.state = "closed";
|
|
217
|
-
state.failures = 0;
|
|
218
|
-
metrics.emit("relay.circuit_breaker.close", 1, { relay });
|
|
219
|
-
} else if (state.state === "closed") {
|
|
220
|
-
state.failures = 0;
|
|
221
|
-
}
|
|
222
|
-
state.lastSuccess = Date.now();
|
|
223
|
-
},
|
|
224
|
-
|
|
225
|
-
recordFailure(): void {
|
|
226
|
-
state.failures++;
|
|
227
|
-
state.lastFailure = Date.now();
|
|
228
|
-
|
|
229
|
-
if (state.state === "half_open") {
|
|
230
|
-
state.state = "open";
|
|
231
|
-
metrics.emit("relay.circuit_breaker.open", 1, { relay });
|
|
232
|
-
} else if (state.state === "closed" && state.failures >= threshold) {
|
|
233
|
-
state.state = "open";
|
|
234
|
-
metrics.emit("relay.circuit_breaker.open", 1, { relay });
|
|
235
|
-
}
|
|
236
|
-
},
|
|
237
|
-
|
|
238
|
-
getState(): CircuitBreakerState["state"] {
|
|
239
|
-
return state.state;
|
|
240
|
-
},
|
|
241
|
-
};
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
// ============================================================================
|
|
245
|
-
// Relay Health Tracker
|
|
246
|
-
// ============================================================================
|
|
247
|
-
|
|
248
|
-
interface RelayHealthStats {
|
|
249
|
-
successCount: number;
|
|
250
|
-
failureCount: number;
|
|
251
|
-
latencySum: number;
|
|
252
|
-
latencyCount: number;
|
|
253
|
-
lastSuccess: number;
|
|
254
|
-
lastFailure: number;
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
interface RelayHealthTracker {
|
|
258
|
-
/** Record a successful operation */
|
|
259
|
-
recordSuccess: (relay: string, latencyMs: number) => void;
|
|
260
|
-
/** Record a failed operation */
|
|
261
|
-
recordFailure: (relay: string) => void;
|
|
262
|
-
/** Get health score (0-1, higher is better) */
|
|
263
|
-
getScore: (relay: string) => number;
|
|
264
|
-
/** Get relays sorted by health (best first) */
|
|
265
|
-
getSortedRelays: (relays: string[]) => string[];
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
function createRelayHealthTracker(): RelayHealthTracker {
|
|
269
|
-
const stats = new Map<string, RelayHealthStats>();
|
|
270
|
-
|
|
271
|
-
function getOrCreate(relay: string): RelayHealthStats {
|
|
272
|
-
let s = stats.get(relay);
|
|
273
|
-
if (!s) {
|
|
274
|
-
s = {
|
|
275
|
-
successCount: 0,
|
|
276
|
-
failureCount: 0,
|
|
277
|
-
latencySum: 0,
|
|
278
|
-
latencyCount: 0,
|
|
279
|
-
lastSuccess: 0,
|
|
280
|
-
lastFailure: 0,
|
|
281
|
-
};
|
|
282
|
-
stats.set(relay, s);
|
|
283
|
-
}
|
|
284
|
-
return s;
|
|
285
|
-
}
|
|
286
|
-
|
|
287
|
-
return {
|
|
288
|
-
recordSuccess(relay: string, latencyMs: number): void {
|
|
289
|
-
const s = getOrCreate(relay);
|
|
290
|
-
s.successCount++;
|
|
291
|
-
s.latencySum += latencyMs;
|
|
292
|
-
s.latencyCount++;
|
|
293
|
-
s.lastSuccess = Date.now();
|
|
294
|
-
},
|
|
295
|
-
|
|
296
|
-
recordFailure(relay: string): void {
|
|
297
|
-
const s = getOrCreate(relay);
|
|
298
|
-
s.failureCount++;
|
|
299
|
-
s.lastFailure = Date.now();
|
|
300
|
-
},
|
|
301
|
-
|
|
302
|
-
getScore(relay: string): number {
|
|
303
|
-
const s = stats.get(relay);
|
|
304
|
-
if (!s) {
|
|
305
|
-
return 0.5;
|
|
306
|
-
} // Unknown relay gets neutral score
|
|
307
|
-
|
|
308
|
-
const total = s.successCount + s.failureCount;
|
|
309
|
-
if (total === 0) {
|
|
310
|
-
return 0.5;
|
|
311
|
-
}
|
|
312
|
-
|
|
313
|
-
// Success rate (0-1)
|
|
314
|
-
const successRate = s.successCount / total;
|
|
315
|
-
|
|
316
|
-
// Recency bonus (prefer recently successful relays)
|
|
317
|
-
const now = Date.now();
|
|
318
|
-
const recencyBonus =
|
|
319
|
-
s.lastSuccess > s.lastFailure
|
|
320
|
-
? Math.max(0, 1 - (now - s.lastSuccess) / HEALTH_WINDOW_MS) * 0.2
|
|
321
|
-
: 0;
|
|
322
|
-
|
|
323
|
-
// Latency penalty (lower is better)
|
|
324
|
-
const avgLatency = s.latencyCount > 0 ? s.latencySum / s.latencyCount : 1000;
|
|
325
|
-
const latencyPenalty = Math.min(0.2, avgLatency / 10000);
|
|
326
|
-
|
|
327
|
-
return Math.max(0, Math.min(1, successRate + recencyBonus - latencyPenalty));
|
|
328
|
-
},
|
|
329
|
-
|
|
330
|
-
getSortedRelays(relays: string[]): string[] {
|
|
331
|
-
return [...relays].toSorted((a, b) => this.getScore(b) - this.getScore(a));
|
|
332
|
-
},
|
|
333
|
-
};
|
|
334
|
-
}
|
|
335
|
-
|
|
336
|
-
// ============================================================================
|
|
337
|
-
// Main Bus
|
|
338
|
-
// ============================================================================
|
|
339
|
-
|
|
340
|
-
/**
|
|
341
|
-
* Start the Nostr DM bus - subscribes to NIP-04 encrypted DMs
|
|
342
|
-
*/
|
|
343
|
-
export async function startNostrBus(options: NostrBusOptions): Promise<NostrBusHandle> {
|
|
344
|
-
const {
|
|
345
|
-
privateKey,
|
|
346
|
-
relays = DEFAULT_RELAYS,
|
|
347
|
-
onMessage,
|
|
348
|
-
authorizeSender,
|
|
349
|
-
onError,
|
|
350
|
-
onEose,
|
|
351
|
-
onMetric,
|
|
352
|
-
maxSeenEntries = 100_000,
|
|
353
|
-
seenTtlMs = 60 * 60 * 1000,
|
|
354
|
-
} = options;
|
|
355
|
-
|
|
356
|
-
const sk = validatePrivateKey(privateKey);
|
|
357
|
-
const pk = getPublicKey(sk);
|
|
358
|
-
const pool = new SimplePool();
|
|
359
|
-
const accountId = options.accountId ?? pk.slice(0, 16);
|
|
360
|
-
const gatewayStartedAt = Math.floor(Date.now() / 1000);
|
|
361
|
-
const guardPolicy = createDirectDmPreCryptoGuardPolicy({
|
|
362
|
-
...DEFAULT_INBOUND_GUARD_POLICY,
|
|
363
|
-
...options.guardPolicy,
|
|
364
|
-
rateLimit: {
|
|
365
|
-
...DEFAULT_INBOUND_GUARD_POLICY.rateLimit,
|
|
366
|
-
...options.guardPolicy?.rateLimit,
|
|
367
|
-
},
|
|
368
|
-
});
|
|
369
|
-
|
|
370
|
-
// Initialize metrics
|
|
371
|
-
const metrics = onMetric ? createMetrics(onMetric) : createNoopMetrics();
|
|
372
|
-
|
|
373
|
-
// Initialize seen tracker with LRU
|
|
374
|
-
const seen: SeenTracker = createSeenTracker({
|
|
375
|
-
maxEntries: maxSeenEntries,
|
|
376
|
-
ttlMs: seenTtlMs,
|
|
377
|
-
});
|
|
378
|
-
|
|
379
|
-
// Initialize circuit breakers and health tracker
|
|
380
|
-
const circuitBreakers = new Map<string, CircuitBreaker>();
|
|
381
|
-
const healthTracker = createRelayHealthTracker();
|
|
382
|
-
|
|
383
|
-
for (const relay of relays) {
|
|
384
|
-
circuitBreakers.set(relay, createCircuitBreaker(relay, metrics));
|
|
385
|
-
}
|
|
386
|
-
|
|
387
|
-
// Read persisted state and compute `since` timestamp (with small overlap)
|
|
388
|
-
const state = await readNostrBusState({ accountId });
|
|
389
|
-
const baseSince = computeSinceTimestamp(state, gatewayStartedAt);
|
|
390
|
-
const since = Math.max(0, baseSince - STARTUP_LOOKBACK_SEC);
|
|
391
|
-
|
|
392
|
-
// Seed in-memory dedupe with recent IDs from disk (prevents restart replay)
|
|
393
|
-
if (state?.recentEventIds?.length) {
|
|
394
|
-
seen.seed(state.recentEventIds);
|
|
395
|
-
}
|
|
396
|
-
|
|
397
|
-
// Persist startup timestamp
|
|
398
|
-
await writeNostrBusState({
|
|
399
|
-
accountId,
|
|
400
|
-
lastProcessedAt: state?.lastProcessedAt ?? gatewayStartedAt,
|
|
401
|
-
gatewayStartedAt,
|
|
402
|
-
recentEventIds: state?.recentEventIds ?? [],
|
|
403
|
-
});
|
|
404
|
-
|
|
405
|
-
// Debounced state persistence
|
|
406
|
-
let pendingWrite: ReturnType<typeof setTimeout> | undefined;
|
|
407
|
-
let lastProcessedAt = state?.lastProcessedAt ?? gatewayStartedAt;
|
|
408
|
-
let recentEventIds = (state?.recentEventIds ?? []).slice(-MAX_PERSISTED_EVENT_IDS);
|
|
409
|
-
|
|
410
|
-
function scheduleStatePersist(eventCreatedAt: number, eventId: string): void {
|
|
411
|
-
lastProcessedAt = Math.max(lastProcessedAt, eventCreatedAt);
|
|
412
|
-
recentEventIds.push(eventId);
|
|
413
|
-
if (recentEventIds.length > MAX_PERSISTED_EVENT_IDS) {
|
|
414
|
-
recentEventIds = recentEventIds.slice(-MAX_PERSISTED_EVENT_IDS);
|
|
415
|
-
}
|
|
416
|
-
|
|
417
|
-
if (pendingWrite) {
|
|
418
|
-
clearTimeout(pendingWrite);
|
|
419
|
-
}
|
|
420
|
-
pendingWrite = setTimeout(() => {
|
|
421
|
-
writeNostrBusState({
|
|
422
|
-
accountId,
|
|
423
|
-
lastProcessedAt,
|
|
424
|
-
gatewayStartedAt,
|
|
425
|
-
recentEventIds,
|
|
426
|
-
}).catch((err) => onError?.(err as Error, "persist state"));
|
|
427
|
-
}, STATE_PERSIST_DEBOUNCE_MS);
|
|
428
|
-
}
|
|
429
|
-
|
|
430
|
-
const inflight = new Set<string>();
|
|
431
|
-
const perSenderRateLimiter = createFixedWindowRateLimiter({
|
|
432
|
-
windowMs: guardPolicy.rateLimit.windowMs,
|
|
433
|
-
maxRequests: guardPolicy.rateLimit.maxPerSenderPerWindow,
|
|
434
|
-
maxTrackedKeys: guardPolicy.rateLimit.maxTrackedSenderKeys,
|
|
435
|
-
});
|
|
436
|
-
const globalRateLimiter = createFixedWindowRateLimiter({
|
|
437
|
-
windowMs: guardPolicy.rateLimit.windowMs,
|
|
438
|
-
maxRequests: guardPolicy.rateLimit.maxGlobalPerWindow,
|
|
439
|
-
maxTrackedKeys: 1,
|
|
440
|
-
});
|
|
441
|
-
|
|
442
|
-
const updateRateLimiterSizeMetric = () => {
|
|
443
|
-
metrics.emit(
|
|
444
|
-
"memory.rate_limiter_entries",
|
|
445
|
-
perSenderRateLimiter.size() + globalRateLimiter.size(),
|
|
446
|
-
);
|
|
447
|
-
};
|
|
448
|
-
|
|
449
|
-
// Event handler
|
|
450
|
-
async function handleEvent(event: Event): Promise<void> {
|
|
451
|
-
try {
|
|
452
|
-
metrics.emit("event.received");
|
|
453
|
-
|
|
454
|
-
// Fast dedupe check (handles relay reconnections)
|
|
455
|
-
if (seen.peek(event.id) || inflight.has(event.id)) {
|
|
456
|
-
metrics.emit("event.duplicate");
|
|
457
|
-
return;
|
|
458
|
-
}
|
|
459
|
-
inflight.add(event.id);
|
|
460
|
-
|
|
461
|
-
const markSeen = () => {
|
|
462
|
-
seen.add(event.id);
|
|
463
|
-
metrics.emit("memory.seen_tracker_size", seen.size());
|
|
464
|
-
};
|
|
465
|
-
const rejectAndMarkSeen = (metric: Parameters<typeof metrics.emit>[0]) => {
|
|
466
|
-
markSeen();
|
|
467
|
-
metrics.emit(metric);
|
|
468
|
-
};
|
|
469
|
-
|
|
470
|
-
// Self-message loop prevention: skip our own messages
|
|
471
|
-
if (event.pubkey === pk) {
|
|
472
|
-
rejectAndMarkSeen("event.rejected.self_message");
|
|
473
|
-
return;
|
|
474
|
-
}
|
|
475
|
-
|
|
476
|
-
// Skip events older than our `since` (relay may ignore filter)
|
|
477
|
-
if (event.created_at < since) {
|
|
478
|
-
rejectAndMarkSeen("event.rejected.stale");
|
|
479
|
-
return;
|
|
480
|
-
}
|
|
481
|
-
|
|
482
|
-
if (event.created_at > Math.floor(Date.now() / 1000) + guardPolicy.maxFutureSkewSec) {
|
|
483
|
-
metrics.emit("event.rejected.future");
|
|
484
|
-
return;
|
|
485
|
-
}
|
|
486
|
-
|
|
487
|
-
if (!guardPolicy.allowedKinds.includes(event.kind)) {
|
|
488
|
-
rejectAndMarkSeen("event.rejected.wrong_kind");
|
|
489
|
-
return;
|
|
490
|
-
}
|
|
491
|
-
|
|
492
|
-
// Fast p-tag check BEFORE crypto (no allocation, cheaper)
|
|
493
|
-
let targetsUs = false;
|
|
494
|
-
for (const t of event.tags) {
|
|
495
|
-
if (t[0] === "p" && t[1] === pk) {
|
|
496
|
-
targetsUs = true;
|
|
497
|
-
break;
|
|
498
|
-
}
|
|
499
|
-
}
|
|
500
|
-
if (!targetsUs) {
|
|
501
|
-
rejectAndMarkSeen("event.rejected.wrong_kind");
|
|
502
|
-
return;
|
|
503
|
-
}
|
|
504
|
-
|
|
505
|
-
const replyTo = async (text: string): Promise<void> => {
|
|
506
|
-
await sendEncryptedDm(
|
|
507
|
-
pool,
|
|
508
|
-
sk,
|
|
509
|
-
event.pubkey,
|
|
510
|
-
text,
|
|
511
|
-
relays,
|
|
512
|
-
metrics,
|
|
513
|
-
circuitBreakers,
|
|
514
|
-
healthTracker,
|
|
515
|
-
onError,
|
|
516
|
-
);
|
|
517
|
-
};
|
|
518
|
-
|
|
519
|
-
const rejectIfGlobalRateLimited = (): boolean => {
|
|
520
|
-
updateRateLimiterSizeMetric();
|
|
521
|
-
if (globalRateLimiter.isRateLimited("global")) {
|
|
522
|
-
metrics.emit("rate_limit.global");
|
|
523
|
-
metrics.emit("event.rejected.rate_limited");
|
|
524
|
-
updateRateLimiterSizeMetric();
|
|
525
|
-
return true;
|
|
526
|
-
}
|
|
527
|
-
updateRateLimiterSizeMetric();
|
|
528
|
-
return false;
|
|
529
|
-
};
|
|
530
|
-
|
|
531
|
-
const rejectIfVerifiedSenderRateLimited = (): boolean => {
|
|
532
|
-
updateRateLimiterSizeMetric();
|
|
533
|
-
if (perSenderRateLimiter.isRateLimited(event.pubkey)) {
|
|
534
|
-
metrics.emit("rate_limit.per_sender");
|
|
535
|
-
metrics.emit("event.rejected.rate_limited");
|
|
536
|
-
updateRateLimiterSizeMetric();
|
|
537
|
-
return true;
|
|
538
|
-
}
|
|
539
|
-
updateRateLimiterSizeMetric();
|
|
540
|
-
return false;
|
|
541
|
-
};
|
|
542
|
-
|
|
543
|
-
if (Buffer.byteLength(event.content, "utf8") > guardPolicy.maxCiphertextBytes) {
|
|
544
|
-
if (rejectIfGlobalRateLimited()) {
|
|
545
|
-
return;
|
|
546
|
-
}
|
|
547
|
-
rejectAndMarkSeen("event.rejected.oversized_ciphertext");
|
|
548
|
-
return;
|
|
549
|
-
}
|
|
550
|
-
|
|
551
|
-
if (rejectIfGlobalRateLimited()) {
|
|
552
|
-
return;
|
|
553
|
-
}
|
|
554
|
-
|
|
555
|
-
// Verify signature (must pass before we trust the event)
|
|
556
|
-
if (!verifyEvent(event)) {
|
|
557
|
-
rejectAndMarkSeen("event.rejected.invalid_signature");
|
|
558
|
-
onError?.(new Error("Invalid signature"), `event ${event.id}`);
|
|
559
|
-
return;
|
|
560
|
-
}
|
|
561
|
-
|
|
562
|
-
if (rejectIfVerifiedSenderRateLimited()) {
|
|
563
|
-
return;
|
|
564
|
-
}
|
|
565
|
-
|
|
566
|
-
if (authorizeSender) {
|
|
567
|
-
const decision = await authorizeSender({
|
|
568
|
-
senderPubkey: event.pubkey,
|
|
569
|
-
reply: replyTo,
|
|
570
|
-
});
|
|
571
|
-
if (decision !== "allow") {
|
|
572
|
-
markSeen();
|
|
573
|
-
return;
|
|
574
|
-
}
|
|
575
|
-
}
|
|
576
|
-
|
|
577
|
-
// Decrypt the message
|
|
578
|
-
let plaintext: string;
|
|
579
|
-
try {
|
|
580
|
-
plaintext = decrypt(sk, event.pubkey, event.content);
|
|
581
|
-
metrics.emit("decrypt.success");
|
|
582
|
-
} catch (err) {
|
|
583
|
-
markSeen();
|
|
584
|
-
metrics.emit("decrypt.failure");
|
|
585
|
-
metrics.emit("event.rejected.decrypt_failed");
|
|
586
|
-
onError?.(err as Error, `decrypt from ${event.pubkey}`);
|
|
587
|
-
return;
|
|
588
|
-
}
|
|
589
|
-
|
|
590
|
-
if (Buffer.byteLength(plaintext, "utf8") > guardPolicy.maxPlaintextBytes) {
|
|
591
|
-
markSeen();
|
|
592
|
-
metrics.emit("event.rejected.oversized_plaintext");
|
|
593
|
-
return;
|
|
594
|
-
}
|
|
595
|
-
|
|
596
|
-
// Call the message handler
|
|
597
|
-
await onMessage(event.pubkey, plaintext, replyTo, {
|
|
598
|
-
eventId: event.id,
|
|
599
|
-
createdAt: event.created_at,
|
|
600
|
-
});
|
|
601
|
-
|
|
602
|
-
// Only cache successful deliveries so handler failures can retry.
|
|
603
|
-
markSeen();
|
|
604
|
-
|
|
605
|
-
// Mark as processed
|
|
606
|
-
metrics.emit("event.processed");
|
|
607
|
-
|
|
608
|
-
// Persist progress (debounced)
|
|
609
|
-
scheduleStatePersist(event.created_at, event.id);
|
|
610
|
-
} catch (err) {
|
|
611
|
-
onError?.(err as Error, `event ${event.id}`);
|
|
612
|
-
} finally {
|
|
613
|
-
inflight.delete(event.id);
|
|
614
|
-
}
|
|
615
|
-
}
|
|
616
|
-
|
|
617
|
-
const sub = pool.subscribeMany(
|
|
618
|
-
relays,
|
|
619
|
-
[{ kinds: [4], "#p": [pk], since }] as unknown as Parameters<typeof pool.subscribeMany>[1],
|
|
620
|
-
{
|
|
621
|
-
onevent: handleEvent,
|
|
622
|
-
oneose: () => {
|
|
623
|
-
// EOSE handler - called when all stored events have been received
|
|
624
|
-
for (const relay of relays) {
|
|
625
|
-
metrics.emit("relay.message.eose", 1, { relay });
|
|
626
|
-
}
|
|
627
|
-
onEose?.(relays.join(", "));
|
|
628
|
-
},
|
|
629
|
-
onclose: (reason) => {
|
|
630
|
-
// Handle subscription close
|
|
631
|
-
for (const relay of relays) {
|
|
632
|
-
metrics.emit("relay.message.closed", 1, { relay });
|
|
633
|
-
options.onDisconnect?.(relay);
|
|
634
|
-
}
|
|
635
|
-
onError?.(new Error(`Subscription closed: ${reason.join(", ")}`), "subscription");
|
|
636
|
-
},
|
|
637
|
-
},
|
|
638
|
-
);
|
|
639
|
-
|
|
640
|
-
// Public sendDm function
|
|
641
|
-
const sendDm = async (toPubkey: string, text: string): Promise<void> => {
|
|
642
|
-
await sendEncryptedDm(
|
|
643
|
-
pool,
|
|
644
|
-
sk,
|
|
645
|
-
toPubkey,
|
|
646
|
-
text,
|
|
647
|
-
relays,
|
|
648
|
-
metrics,
|
|
649
|
-
circuitBreakers,
|
|
650
|
-
healthTracker,
|
|
651
|
-
onError,
|
|
652
|
-
);
|
|
653
|
-
};
|
|
654
|
-
|
|
655
|
-
// Profile publishing function
|
|
656
|
-
const publishProfile = async (profile: NostrProfile): Promise<ProfilePublishResult> => {
|
|
657
|
-
// Read last published timestamp for monotonic ordering
|
|
658
|
-
const profileState = await readNostrProfileState({ accountId });
|
|
659
|
-
const lastPublishedAt = profileState?.lastPublishedAt ?? undefined;
|
|
660
|
-
|
|
661
|
-
// Publish the profile
|
|
662
|
-
const result = await publishProfileFn(pool, sk, relays, profile, lastPublishedAt);
|
|
663
|
-
|
|
664
|
-
// Convert results to state format
|
|
665
|
-
const publishResults: Record<string, "ok" | "failed" | "timeout"> = {};
|
|
666
|
-
for (const relay of result.successes) {
|
|
667
|
-
publishResults[relay] = "ok";
|
|
668
|
-
}
|
|
669
|
-
for (const { relay, error } of result.failures) {
|
|
670
|
-
publishResults[relay] = error === "timeout" ? "timeout" : "failed";
|
|
671
|
-
}
|
|
672
|
-
|
|
673
|
-
// Persist the publish state
|
|
674
|
-
await writeNostrProfileState({
|
|
675
|
-
accountId,
|
|
676
|
-
lastPublishedAt: result.createdAt,
|
|
677
|
-
lastPublishedEventId: result.eventId,
|
|
678
|
-
lastPublishResults: publishResults,
|
|
679
|
-
});
|
|
680
|
-
|
|
681
|
-
return result;
|
|
682
|
-
};
|
|
683
|
-
|
|
684
|
-
// Get profile state function
|
|
685
|
-
const getProfileState = async () => {
|
|
686
|
-
const state = await readNostrProfileState({ accountId });
|
|
687
|
-
return {
|
|
688
|
-
lastPublishedAt: state?.lastPublishedAt ?? null,
|
|
689
|
-
lastPublishedEventId: state?.lastPublishedEventId ?? null,
|
|
690
|
-
lastPublishResults: state?.lastPublishResults ?? null,
|
|
691
|
-
};
|
|
692
|
-
};
|
|
693
|
-
|
|
694
|
-
return {
|
|
695
|
-
close: () => {
|
|
696
|
-
sub.close();
|
|
697
|
-
seen.stop();
|
|
698
|
-
perSenderRateLimiter.clear();
|
|
699
|
-
globalRateLimiter.clear();
|
|
700
|
-
// Flush pending state write synchronously on close
|
|
701
|
-
if (pendingWrite) {
|
|
702
|
-
clearTimeout(pendingWrite);
|
|
703
|
-
writeNostrBusState({
|
|
704
|
-
accountId,
|
|
705
|
-
lastProcessedAt,
|
|
706
|
-
gatewayStartedAt,
|
|
707
|
-
recentEventIds,
|
|
708
|
-
}).catch((err) => onError?.(err as Error, "persist state on close"));
|
|
709
|
-
}
|
|
710
|
-
},
|
|
711
|
-
publicKey: pk,
|
|
712
|
-
sendDm,
|
|
713
|
-
getMetrics: () => metrics.getSnapshot(),
|
|
714
|
-
publishProfile,
|
|
715
|
-
getProfileState,
|
|
716
|
-
};
|
|
717
|
-
}
|
|
718
|
-
|
|
719
|
-
// ============================================================================
|
|
720
|
-
// Send DM with Circuit Breaker + Health Scoring
|
|
721
|
-
// ============================================================================
|
|
722
|
-
|
|
723
|
-
/**
|
|
724
|
-
* Send an encrypted DM to a pubkey
|
|
725
|
-
*/
|
|
726
|
-
async function sendEncryptedDm(
|
|
727
|
-
pool: SimplePool,
|
|
728
|
-
sk: Uint8Array,
|
|
729
|
-
toPubkey: string,
|
|
730
|
-
text: string,
|
|
731
|
-
relays: string[],
|
|
732
|
-
metrics: NostrMetrics,
|
|
733
|
-
circuitBreakers: Map<string, CircuitBreaker>,
|
|
734
|
-
healthTracker: RelayHealthTracker,
|
|
735
|
-
onError?: (error: Error, context: string) => void,
|
|
736
|
-
): Promise<void> {
|
|
737
|
-
const ciphertext = encrypt(sk, toPubkey, text);
|
|
738
|
-
const reply = finalizeEvent(
|
|
739
|
-
{
|
|
740
|
-
kind: 4,
|
|
741
|
-
content: ciphertext,
|
|
742
|
-
tags: [["p", toPubkey]],
|
|
743
|
-
created_at: Math.floor(Date.now() / 1000),
|
|
744
|
-
},
|
|
745
|
-
sk,
|
|
746
|
-
);
|
|
747
|
-
|
|
748
|
-
// Sort relays by health score (best first)
|
|
749
|
-
const sortedRelays = healthTracker.getSortedRelays(relays);
|
|
750
|
-
|
|
751
|
-
// Try relays in order of health, respecting circuit breakers
|
|
752
|
-
let lastError: Error | undefined;
|
|
753
|
-
for (const relay of sortedRelays) {
|
|
754
|
-
const cb = circuitBreakers.get(relay);
|
|
755
|
-
|
|
756
|
-
// Skip if circuit breaker is open
|
|
757
|
-
if (cb && !cb.canAttempt()) {
|
|
758
|
-
continue;
|
|
759
|
-
}
|
|
760
|
-
|
|
761
|
-
const startTime = Date.now();
|
|
762
|
-
try {
|
|
763
|
-
const [publishPromise] = pool.publish([relay], reply);
|
|
764
|
-
if (!publishPromise) {
|
|
765
|
-
throw new Error(`Failed to create publish promise for relay ${relay}`);
|
|
766
|
-
}
|
|
767
|
-
await publishPromise;
|
|
768
|
-
const latency = Date.now() - startTime;
|
|
769
|
-
|
|
770
|
-
// Record success
|
|
771
|
-
cb?.recordSuccess();
|
|
772
|
-
healthTracker.recordSuccess(relay, latency);
|
|
773
|
-
|
|
774
|
-
return; // Success - exit early
|
|
775
|
-
} catch (err) {
|
|
776
|
-
lastError = err as Error;
|
|
777
|
-
const latency = Date.now() - startTime;
|
|
778
|
-
|
|
779
|
-
// Record failure
|
|
780
|
-
cb?.recordFailure();
|
|
781
|
-
healthTracker.recordFailure(relay);
|
|
782
|
-
metrics.emit("relay.error", 1, { relay, latency });
|
|
783
|
-
|
|
784
|
-
onError?.(lastError, `publish to ${relay}`);
|
|
785
|
-
}
|
|
786
|
-
}
|
|
787
|
-
|
|
788
|
-
throw new Error(`Failed to publish to any relay: ${lastError?.message}`);
|
|
789
|
-
}
|