@openclaw/nostr 2026.7.2-beta.2 → 2026.7.2-beta.4
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 +20 -6
- package/dist/{channel-B51rExHD.js → channel-DqbdMXMg.js} +810 -428
- package/dist/channel-plugin-api.js +1 -1
- package/dist/inbound-direct-dm-runtime-DHn9NoYN.js +2 -0
- package/dist/{setup-adapter-DEU3o8MF.js → setup-adapter-BjKmSwdW.js} +31 -1
- package/dist/setup-api.js +1 -1
- package/dist/setup-plugin-api.js +5 -3
- package/dist/{setup-surface-B52P4uo2.js → setup-surface-ChtM-WSY.js} +6 -4
- package/node_modules/nostr-tools/lib/cjs/abstract-pool.js +35 -3
- package/node_modules/nostr-tools/lib/cjs/abstract-pool.js.map +2 -2
- package/node_modules/nostr-tools/lib/cjs/abstract-relay.js +30 -2
- package/node_modules/nostr-tools/lib/cjs/abstract-relay.js.map +2 -2
- package/node_modules/nostr-tools/lib/cjs/index.js +35 -3
- package/node_modules/nostr-tools/lib/cjs/index.js.map +2 -2
- package/node_modules/nostr-tools/lib/cjs/nip46.js +35 -3
- package/node_modules/nostr-tools/lib/cjs/nip46.js.map +2 -2
- package/node_modules/nostr-tools/lib/cjs/nipb0.js +4 -1
- package/node_modules/nostr-tools/lib/cjs/nipb0.js.map +2 -2
- package/node_modules/nostr-tools/lib/cjs/pool.js +35 -3
- package/node_modules/nostr-tools/lib/cjs/pool.js.map +2 -2
- package/node_modules/nostr-tools/lib/cjs/relay.js +30 -2
- package/node_modules/nostr-tools/lib/cjs/relay.js.map +2 -2
- package/node_modules/nostr-tools/lib/esm/abstract-pool.js +35 -3
- package/node_modules/nostr-tools/lib/esm/abstract-pool.js.map +2 -2
- package/node_modules/nostr-tools/lib/esm/abstract-relay.js +30 -2
- package/node_modules/nostr-tools/lib/esm/abstract-relay.js.map +2 -2
- package/node_modules/nostr-tools/lib/esm/index.js +35 -3
- package/node_modules/nostr-tools/lib/esm/index.js.map +2 -2
- package/node_modules/nostr-tools/lib/esm/nip46.js +35 -3
- package/node_modules/nostr-tools/lib/esm/nip46.js.map +2 -2
- package/node_modules/nostr-tools/lib/esm/nipb0.js +4 -1
- package/node_modules/nostr-tools/lib/esm/nipb0.js.map +2 -2
- package/node_modules/nostr-tools/lib/esm/pool.js +35 -3
- package/node_modules/nostr-tools/lib/esm/pool.js.map +2 -2
- package/node_modules/nostr-tools/lib/esm/relay.js +30 -2
- package/node_modules/nostr-tools/lib/esm/relay.js.map +2 -2
- package/node_modules/nostr-tools/lib/nostr.bundle.js +35 -3
- package/node_modules/nostr-tools/lib/nostr.bundle.js.map +2 -2
- package/node_modules/nostr-tools/lib/types/abstract-pool.d.ts +1 -0
- package/node_modules/nostr-tools/lib/types/abstract-relay.d.ts +5 -0
- package/node_modules/nostr-tools/lib/types/relay.d.ts +2 -2
- package/node_modules/nostr-tools/package.json +1 -1
- package/npm-shrinkwrap.json +7 -7
- package/openclaw.plugin.json +0 -5
- package/package.json +41 -15
- package/dist/inbound-direct-dm-runtime-EdLLVtkh.js +0 -2
|
@@ -1,23 +1,24 @@
|
|
|
1
|
-
import { a as
|
|
1
|
+
import { a as listNostrAccountIds, c as normalizePubkey, l as validatePrivateKey, n as nostrSetupContract, o as resolveDefaultNostrAccountId, r as nostrSetupWizard, s as resolveNostrAccount, t as nostrSetupAdapter } from "./setup-surface-ChtM-WSY.js";
|
|
2
2
|
import { a as collectStatusIssuesFromLastError, i as buildChannelConfigSchema, n as NostrProfileSchema, o as createDefaultChannelRuntimeState, r as DEFAULT_ACCOUNT_ID, s as formatPairingApproveHint, t as NostrConfigSchema } from "./config-schema-CelPzg1N.js";
|
|
3
|
-
import {
|
|
3
|
+
import { a as DEFAULT_RELAYS } from "./setup-adapter-BjKmSwdW.js";
|
|
4
4
|
import { t as normalizeNostrStateAccountId } from "./state-account-id-CvBZ9s6P.js";
|
|
5
5
|
import { describeAccountSnapshot } from "openclaw/plugin-sdk/account-helpers";
|
|
6
6
|
import { createScopedDmSecurityResolver, createTopLevelChannelConfigAdapter } from "openclaw/plugin-sdk/channel-config-helpers";
|
|
7
7
|
import { createChatChannelPlugin } from "openclaw/plugin-sdk/channel-core";
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
8
|
+
import { missingTargetError } from "openclaw/plugin-sdk/channel-feedback";
|
|
9
|
+
import { DEFAULT_INGRESS_ADOPTION_STALL_MS, bindIngressLifecycleToReplyOptions, createChannelIngressMonitor, createChannelMessageAdapterFromOutbound, runPassiveAccountLifecycle } from "openclaw/plugin-sdk/channel-outbound";
|
|
10
|
+
import { buildPassiveChannelStatusSummary, buildTrafficStatusSummary } from "openclaw/plugin-sdk/extension-shared";
|
|
10
11
|
import { createComputedAccountStatusAdapter } from "openclaw/plugin-sdk/status-helpers";
|
|
11
12
|
import { normalizeStringEntries } from "openclaw/plugin-sdk/string-coerce-runtime";
|
|
12
13
|
import { resolveStableChannelMessageIngress } from "openclaw/plugin-sdk/channel-ingress-runtime";
|
|
13
14
|
import { createChannelPairingController } from "openclaw/plugin-sdk/channel-pairing";
|
|
14
15
|
import { attachChannelToResult } from "openclaw/plugin-sdk/channel-send-result";
|
|
16
|
+
import { stripMarkdown } from "openclaw/plugin-sdk/text-chunking";
|
|
15
17
|
import { SimplePool, finalizeEvent, getPublicKey, verifyEvent } from "nostr-tools";
|
|
16
18
|
import { decrypt, encrypt } from "nostr-tools/nip04";
|
|
17
19
|
import { createDirectDmPreCryptoGuardPolicy } from "openclaw/plugin-sdk/direct-dm-guard-policy";
|
|
18
20
|
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
|
19
21
|
import { createPluginRuntimeStore } from "openclaw/plugin-sdk/runtime-store";
|
|
20
|
-
import { resolveIntegerOption, resolvePositiveTimerTimeoutMs } from "openclaw/plugin-sdk/number-runtime";
|
|
21
22
|
import { buildChannelOutboundSessionRoute, stripChannelTargetPrefix } from "openclaw/plugin-sdk/core";
|
|
22
23
|
//#region extensions/nostr/src/metrics.ts
|
|
23
24
|
function createZeroMetricsState() {
|
|
@@ -240,6 +241,395 @@ function createNoopMetrics() {
|
|
|
240
241
|
};
|
|
241
242
|
}
|
|
242
243
|
//#endregion
|
|
244
|
+
//#region extensions/nostr/src/nostr-cursor.ts
|
|
245
|
+
const CURSOR_WRITE_RETRY_MS = [
|
|
246
|
+
0,
|
|
247
|
+
100,
|
|
248
|
+
300
|
|
249
|
+
];
|
|
250
|
+
const CURSOR_RECOVERY_RETRY_MS = 1e3;
|
|
251
|
+
/** Tracks the largest EOSE-safe Nostr timestamp without skipping undurable relay events. */
|
|
252
|
+
function createNostrDurableCursor(options) {
|
|
253
|
+
const nowSec = options.nowSec ?? (() => Math.floor(Date.now() / 1e3));
|
|
254
|
+
let durableCandidate;
|
|
255
|
+
let transientReplayCeiling;
|
|
256
|
+
let backfillComplete = false;
|
|
257
|
+
const safeCandidate = () => {
|
|
258
|
+
if (durableCandidate === void 0) return;
|
|
259
|
+
return Math.min(durableCandidate, transientReplayCeiling ?? Number.MAX_SAFE_INTEGER);
|
|
260
|
+
};
|
|
261
|
+
return {
|
|
262
|
+
recordDurableAppend: (event) => {
|
|
263
|
+
if (!Number.isSafeInteger(event.created_at)) return;
|
|
264
|
+
const previousSafeCandidate = safeCandidate();
|
|
265
|
+
durableCandidate = Math.max(durableCandidate ?? 0, Math.min(event.created_at, nowSec()));
|
|
266
|
+
const nextSafeCandidate = safeCandidate();
|
|
267
|
+
return backfillComplete && nextSafeCandidate !== previousSafeCandidate ? nextSafeCandidate : void 0;
|
|
268
|
+
},
|
|
269
|
+
recordTransientRejection: (event) => {
|
|
270
|
+
if (!Number.isSafeInteger(event.created_at) || event.created_at < options.since) return;
|
|
271
|
+
const previousSafeCandidate = safeCandidate();
|
|
272
|
+
transientReplayCeiling = Math.min(transientReplayCeiling ?? Number.MAX_SAFE_INTEGER, event.created_at + options.replayOverlapSec);
|
|
273
|
+
const nextSafeCandidate = safeCandidate();
|
|
274
|
+
return backfillComplete && nextSafeCandidate !== previousSafeCandidate ? nextSafeCandidate : void 0;
|
|
275
|
+
},
|
|
276
|
+
markBackfillComplete: () => {
|
|
277
|
+
backfillComplete = true;
|
|
278
|
+
return safeCandidate();
|
|
279
|
+
}
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
/** Serializes cursor writes so a safety rewind always lands after older progress writes. */
|
|
283
|
+
function createNostrCursorStateWriter(options) {
|
|
284
|
+
let desiredCursor = Math.max(options.minimumCursor, options.initialCursor);
|
|
285
|
+
let dirty = false;
|
|
286
|
+
let timer;
|
|
287
|
+
let writeTail = Promise.resolve();
|
|
288
|
+
let activeFlush;
|
|
289
|
+
let recoveryFlush;
|
|
290
|
+
const writeWithRetry = async (cursor) => {
|
|
291
|
+
let lastError;
|
|
292
|
+
for (const delayMs of CURSOR_WRITE_RETRY_MS) {
|
|
293
|
+
if (delayMs > 0) await new Promise((resolve) => {
|
|
294
|
+
setTimeout(resolve, delayMs);
|
|
295
|
+
});
|
|
296
|
+
try {
|
|
297
|
+
await options.write(cursor);
|
|
298
|
+
return;
|
|
299
|
+
} catch (error) {
|
|
300
|
+
lastError = error;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
throw new Error("Nostr cursor state write failed.", { cause: lastError });
|
|
304
|
+
};
|
|
305
|
+
const enqueueWrite = (cursor) => {
|
|
306
|
+
const write = writeTail.then(() => writeWithRetry(cursor));
|
|
307
|
+
writeTail = write.catch(() => void 0);
|
|
308
|
+
return write;
|
|
309
|
+
};
|
|
310
|
+
const clearTimer = () => {
|
|
311
|
+
if (timer) {
|
|
312
|
+
clearTimeout(timer);
|
|
313
|
+
timer = void 0;
|
|
314
|
+
}
|
|
315
|
+
};
|
|
316
|
+
const runFlush = async () => {
|
|
317
|
+
for (;;) {
|
|
318
|
+
if (!dirty) {
|
|
319
|
+
await writeTail;
|
|
320
|
+
if (!dirty) return;
|
|
321
|
+
}
|
|
322
|
+
dirty = false;
|
|
323
|
+
const cursor = desiredCursor;
|
|
324
|
+
try {
|
|
325
|
+
await enqueueWrite(cursor);
|
|
326
|
+
} catch (error) {
|
|
327
|
+
dirty = true;
|
|
328
|
+
throw error;
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
};
|
|
332
|
+
const flush = () => {
|
|
333
|
+
clearTimer();
|
|
334
|
+
if (activeFlush) return activeFlush;
|
|
335
|
+
const tracked = runFlush().finally(() => {
|
|
336
|
+
if (activeFlush === tracked) activeFlush = void 0;
|
|
337
|
+
});
|
|
338
|
+
activeFlush = tracked;
|
|
339
|
+
return tracked;
|
|
340
|
+
};
|
|
341
|
+
const setDesiredCursor = (cursor) => {
|
|
342
|
+
desiredCursor = Math.max(options.minimumCursor, cursor);
|
|
343
|
+
dirty = true;
|
|
344
|
+
};
|
|
345
|
+
return {
|
|
346
|
+
schedule: (cursor) => {
|
|
347
|
+
setDesiredCursor(cursor);
|
|
348
|
+
clearTimer();
|
|
349
|
+
timer = setTimeout(() => {
|
|
350
|
+
timer = void 0;
|
|
351
|
+
flush().catch((error) => options.onBackgroundError?.(error));
|
|
352
|
+
}, options.debounceMs);
|
|
353
|
+
timer.unref?.();
|
|
354
|
+
},
|
|
355
|
+
persistNow: async (cursor) => {
|
|
356
|
+
setDesiredCursor(cursor);
|
|
357
|
+
await flush();
|
|
358
|
+
},
|
|
359
|
+
flush,
|
|
360
|
+
flushUntilSuccess: () => {
|
|
361
|
+
recoveryFlush ??= (async () => {
|
|
362
|
+
for (;;) try {
|
|
363
|
+
await flush();
|
|
364
|
+
return;
|
|
365
|
+
} catch (error) {
|
|
366
|
+
options.onBackgroundError?.(error);
|
|
367
|
+
await new Promise((resolve) => {
|
|
368
|
+
setTimeout(resolve, options.recoveryRetryMs ?? CURSOR_RECOVERY_RETRY_MS);
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
})().finally(() => {
|
|
372
|
+
recoveryFlush = void 0;
|
|
373
|
+
});
|
|
374
|
+
return recoveryFlush;
|
|
375
|
+
}
|
|
376
|
+
};
|
|
377
|
+
}
|
|
378
|
+
var NostrIngressPermanentError = class extends Error {
|
|
379
|
+
constructor(reason, message, options) {
|
|
380
|
+
super(message, options);
|
|
381
|
+
this.name = "NostrIngressPermanentError";
|
|
382
|
+
this.reason = reason;
|
|
383
|
+
}
|
|
384
|
+
};
|
|
385
|
+
function isNostrIngressRecord(value) {
|
|
386
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
387
|
+
}
|
|
388
|
+
function requiredString(value, field) {
|
|
389
|
+
if (typeof value === "string" && value.trim()) return value;
|
|
390
|
+
throw new NostrIngressPermanentError("invalid-event", `Nostr event is missing ${field}.`);
|
|
391
|
+
}
|
|
392
|
+
function inspectNostrIngressEvent(event) {
|
|
393
|
+
if (!isNostrIngressRecord(event)) throw new NostrIngressPermanentError("invalid-event", "Nostr event must be an object.");
|
|
394
|
+
return {
|
|
395
|
+
eventId: requiredString(event.id, "id"),
|
|
396
|
+
laneKey: `direct:${requiredString(event.pubkey, "pubkey")}`
|
|
397
|
+
};
|
|
398
|
+
}
|
|
399
|
+
/** Convert the retired persisted LRU seed into durable completion tombstones. */
|
|
400
|
+
async function migrateNostrLegacyRecentEventIds(params) {
|
|
401
|
+
const migratedAt = params.migratedAt ?? Date.now();
|
|
402
|
+
let migrated = 0;
|
|
403
|
+
for (const eventId of new Set(params.eventIds)) {
|
|
404
|
+
if (!eventId.trim()) continue;
|
|
405
|
+
const result = await params.queue.enqueue(eventId, {
|
|
406
|
+
version: 1,
|
|
407
|
+
receivedAt: migratedAt,
|
|
408
|
+
rawEvent: ""
|
|
409
|
+
}, {
|
|
410
|
+
receivedAt: migratedAt,
|
|
411
|
+
laneKey: `legacy:${eventId}`
|
|
412
|
+
});
|
|
413
|
+
if (result.kind === "accepted" || result.kind === "pending" && result.record.payload.rawEvent === "") {
|
|
414
|
+
if (!await params.queue.complete(eventId, { completedAt: migratedAt })) throw new Error(`Failed to migrate Nostr replay event ${eventId}.`);
|
|
415
|
+
}
|
|
416
|
+
migrated += 1;
|
|
417
|
+
}
|
|
418
|
+
return migrated;
|
|
419
|
+
}
|
|
420
|
+
//#endregion
|
|
421
|
+
//#region extensions/nostr/src/runtime.ts
|
|
422
|
+
const { setRuntime: setNostrRuntime, getRuntime: getNostrRuntime } = createPluginRuntimeStore({
|
|
423
|
+
pluginId: "nostr",
|
|
424
|
+
errorMessage: "Nostr runtime not initialized"
|
|
425
|
+
});
|
|
426
|
+
//#endregion
|
|
427
|
+
//#region extensions/nostr/src/nostr-ingress.ts
|
|
428
|
+
const NOSTR_INGRESS_POLL_INTERVAL_MS = 500;
|
|
429
|
+
const NOSTR_INGRESS_PRUNE_INTERVAL_MS = 3600 * 1e3;
|
|
430
|
+
const NOSTR_INGRESS_COMPLETED_TTL_MS = 720 * 60 * 60 * 1e3;
|
|
431
|
+
const NOSTR_INGRESS_COMPLETED_MAX_ENTRIES = 1e5;
|
|
432
|
+
const NOSTR_INGRESS_FAILED_TTL_MS = 720 * 60 * 60 * 1e3;
|
|
433
|
+
const NOSTR_INGRESS_FAILED_MAX_ENTRIES = 1e5;
|
|
434
|
+
const NOSTR_INGRESS_APPEND_RETRY_MS = [
|
|
435
|
+
0,
|
|
436
|
+
100,
|
|
437
|
+
300
|
|
438
|
+
];
|
|
439
|
+
var NostrIngressAdmissionRejectedError = class extends Error {
|
|
440
|
+
constructor(reason, message) {
|
|
441
|
+
super(message);
|
|
442
|
+
this.name = "NostrIngressAdmissionRejectedError";
|
|
443
|
+
this.reason = reason;
|
|
444
|
+
}
|
|
445
|
+
};
|
|
446
|
+
function deserializeNostrIngressEvent(rawEvent, claimedId) {
|
|
447
|
+
let parsed;
|
|
448
|
+
try {
|
|
449
|
+
parsed = JSON.parse(rawEvent);
|
|
450
|
+
} catch (error) {
|
|
451
|
+
throw new NostrIngressPermanentError("invalid-event", `Nostr ingress row ${claimedId} contains invalid JSON.`, { cause: error });
|
|
452
|
+
}
|
|
453
|
+
if (!isNostrIngressRecord(parsed)) throw new NostrIngressPermanentError("invalid-event", `Nostr ingress row ${claimedId} has an invalid event shape.`);
|
|
454
|
+
if (typeof parsed.kind !== "number" || typeof parsed.created_at !== "number" || typeof parsed.content !== "string" || typeof parsed.sig !== "string" || !Array.isArray(parsed.tags)) throw new NostrIngressPermanentError("invalid-event", `Nostr ingress row ${claimedId} has an invalid event shape.`);
|
|
455
|
+
return parsed;
|
|
456
|
+
}
|
|
457
|
+
function createNostrIngress(options) {
|
|
458
|
+
let queue = options.queue;
|
|
459
|
+
let admissionFailure;
|
|
460
|
+
let admissionWindowStartedAt = Date.now();
|
|
461
|
+
let admissionWindowCount = 0;
|
|
462
|
+
let queuedAdmissions = 0;
|
|
463
|
+
let stopping = false;
|
|
464
|
+
let stopTask;
|
|
465
|
+
const createStoppedError = () => /* @__PURE__ */ new Error("Nostr ingress stopped");
|
|
466
|
+
const getQueue = () => {
|
|
467
|
+
queue ??= getNostrRuntime().state.openChannelIngressQueue({ accountId: options.accountId });
|
|
468
|
+
return queue;
|
|
469
|
+
};
|
|
470
|
+
const legacyMigration = migrateNostrLegacyRecentEventIds({
|
|
471
|
+
queue: getQueue(),
|
|
472
|
+
eventIds: options.legacyEventIds ?? []
|
|
473
|
+
});
|
|
474
|
+
const monitor = createChannelIngressMonitor({
|
|
475
|
+
queue: getQueue,
|
|
476
|
+
inspect: (event) => {
|
|
477
|
+
const facts = inspectNostrIngressEvent(event);
|
|
478
|
+
return {
|
|
479
|
+
eventId: facts.eventId,
|
|
480
|
+
laneKey: facts.laneKey
|
|
481
|
+
};
|
|
482
|
+
},
|
|
483
|
+
payload: {
|
|
484
|
+
version: 1,
|
|
485
|
+
serialize: (event, { receivedAt }) => ({
|
|
486
|
+
receivedAt,
|
|
487
|
+
rawEvent: JSON.stringify(event)
|
|
488
|
+
}),
|
|
489
|
+
deserialize: (body, { claim }) => deserializeNostrIngressEvent(body.rawEvent, claim.id),
|
|
490
|
+
encode: ({ body }) => ({
|
|
491
|
+
version: 1,
|
|
492
|
+
...body
|
|
493
|
+
}),
|
|
494
|
+
decode: (payload, { claim }) => {
|
|
495
|
+
if (typeof payload !== "object" || payload === null || typeof payload.rawEvent !== "string") throw new NostrIngressPermanentError("invalid-event", `Nostr ingress row ${claim.id} has an invalid payload.`);
|
|
496
|
+
return {
|
|
497
|
+
version: payload.version,
|
|
498
|
+
body: {
|
|
499
|
+
receivedAt: typeof payload.receivedAt === "number" ? payload.receivedAt : 0,
|
|
500
|
+
rawEvent: payload.rawEvent
|
|
501
|
+
}
|
|
502
|
+
};
|
|
503
|
+
},
|
|
504
|
+
createClaimError: (kind, claim) => new NostrIngressPermanentError("invalid-event", kind === "invalid-version" ? `Nostr ingress row ${claim.id} has an unsupported version.` : `Nostr ingress row ${claim.id} changed event identity.`)
|
|
505
|
+
},
|
|
506
|
+
deliver: (event, lifecycle) => options.deliver(event, lifecycle),
|
|
507
|
+
pollIntervalMs: options.pollIntervalMs ?? NOSTR_INGRESS_POLL_INTERVAL_MS,
|
|
508
|
+
retention: {
|
|
509
|
+
pruneIntervalMs: NOSTR_INGRESS_PRUNE_INTERVAL_MS,
|
|
510
|
+
completedTtlMs: NOSTR_INGRESS_COMPLETED_TTL_MS,
|
|
511
|
+
completedMaxEntries: NOSTR_INGRESS_COMPLETED_MAX_ENTRIES,
|
|
512
|
+
failedTtlMs: NOSTR_INGRESS_FAILED_TTL_MS,
|
|
513
|
+
failedMaxEntries: NOSTR_INGRESS_FAILED_MAX_ENTRIES
|
|
514
|
+
},
|
|
515
|
+
drain: {
|
|
516
|
+
adoptionStallTimeoutMs: options.adoptionStallTimeoutMs ?? DEFAULT_INGRESS_ADOPTION_STALL_MS,
|
|
517
|
+
resolveNonRetryableFailure: (error) => error instanceof NostrIngressPermanentError ? {
|
|
518
|
+
reason: error.reason,
|
|
519
|
+
message: error.message
|
|
520
|
+
} : null,
|
|
521
|
+
onLog: (message) => options.onError?.(new Error(message), "ingress drain")
|
|
522
|
+
},
|
|
523
|
+
createStoppedError,
|
|
524
|
+
onError: (error) => options.onError?.(error, "ingress drain")
|
|
525
|
+
});
|
|
526
|
+
const monitorStart = legacyMigration.then(() => {
|
|
527
|
+
if (!stopping) monitor.start();
|
|
528
|
+
});
|
|
529
|
+
monitorStart.catch((error) => options.onError?.(error, "ingress drain"));
|
|
530
|
+
let admissionTail = Promise.resolve();
|
|
531
|
+
const prepareAdmission = (event) => {
|
|
532
|
+
const facts = inspectNostrIngressEvent(event);
|
|
533
|
+
const receivedAt = Date.now();
|
|
534
|
+
if (receivedAt - admissionWindowStartedAt >= options.admissionRateLimit.windowMs) {
|
|
535
|
+
admissionWindowStartedAt = receivedAt;
|
|
536
|
+
admissionWindowCount = 0;
|
|
537
|
+
}
|
|
538
|
+
if (admissionWindowCount >= options.admissionRateLimit.maxEvents) throw new NostrIngressAdmissionRejectedError("rate-limited", "Nostr event exceeds the durable admission rate.");
|
|
539
|
+
admissionWindowCount += 1;
|
|
540
|
+
if (queuedAdmissions >= options.maxQueuedAdmissions) throw new NostrIngressAdmissionRejectedError("backpressure", "Nostr event exceeds the in-memory admission backlog.");
|
|
541
|
+
let payload;
|
|
542
|
+
let serializedPayload;
|
|
543
|
+
try {
|
|
544
|
+
payload = {
|
|
545
|
+
version: 1,
|
|
546
|
+
receivedAt,
|
|
547
|
+
rawEvent: JSON.stringify(event)
|
|
548
|
+
};
|
|
549
|
+
serializedPayload = JSON.stringify(payload);
|
|
550
|
+
} catch (error) {
|
|
551
|
+
throw new NostrIngressPermanentError("invalid-event", "Nostr event could not be serialized for durable ingress.", { cause: error });
|
|
552
|
+
}
|
|
553
|
+
if (Buffer.byteLength(serializedPayload, "utf8") > options.maxSerializedPayloadBytes) throw new NostrIngressAdmissionRejectedError("oversized-event", "Nostr event exceeds the durable ingress size limit.");
|
|
554
|
+
return {
|
|
555
|
+
event,
|
|
556
|
+
facts,
|
|
557
|
+
receivedAt,
|
|
558
|
+
payload
|
|
559
|
+
};
|
|
560
|
+
};
|
|
561
|
+
const admitOnce = async (prepared) => {
|
|
562
|
+
await legacyMigration;
|
|
563
|
+
const pending = await getQueue().listPending({ limit: options.maxPendingEvents });
|
|
564
|
+
const claims = await getQueue().listClaims();
|
|
565
|
+
if (pending.length + claims.length >= options.maxPendingEvents) throw new NostrIngressAdmissionRejectedError("backpressure", "Nostr event exceeds the durable ingress backlog.");
|
|
566
|
+
let lastError;
|
|
567
|
+
for (const delayMs of NOSTR_INGRESS_APPEND_RETRY_MS) {
|
|
568
|
+
if (delayMs > 0) await new Promise((resolve) => {
|
|
569
|
+
setTimeout(resolve, delayMs);
|
|
570
|
+
});
|
|
571
|
+
try {
|
|
572
|
+
const result = await getQueue().enqueue(prepared.facts.eventId, prepared.payload, {
|
|
573
|
+
receivedAt: prepared.receivedAt,
|
|
574
|
+
laneKey: prepared.facts.laneKey
|
|
575
|
+
});
|
|
576
|
+
options.afterDurableAppend(prepared.event);
|
|
577
|
+
monitor.requestDrain();
|
|
578
|
+
return result.kind === "accepted" ? "accepted" : "duplicate";
|
|
579
|
+
} catch (error) {
|
|
580
|
+
lastError = error;
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
throw new Error(`Nostr durable admission failed: ${formatErrorMessage(lastError)}`, { cause: lastError });
|
|
584
|
+
};
|
|
585
|
+
return {
|
|
586
|
+
ready: async () => {
|
|
587
|
+
await monitorStart;
|
|
588
|
+
},
|
|
589
|
+
receive: (event) => {
|
|
590
|
+
if (stopping) return Promise.reject(createStoppedError());
|
|
591
|
+
let prepared;
|
|
592
|
+
try {
|
|
593
|
+
prepared = prepareAdmission(event);
|
|
594
|
+
} catch (error) {
|
|
595
|
+
return Promise.reject(error);
|
|
596
|
+
}
|
|
597
|
+
queuedAdmissions += 1;
|
|
598
|
+
const settledAdmission = admissionTail.then(async () => {
|
|
599
|
+
if (admissionFailure) throw admissionFailure;
|
|
600
|
+
try {
|
|
601
|
+
return await admitOnce(prepared);
|
|
602
|
+
} catch (error) {
|
|
603
|
+
if (error instanceof NostrIngressAdmissionRejectedError || error instanceof NostrIngressPermanentError) throw error;
|
|
604
|
+
admissionFailure = error instanceof Error ? error : new Error(formatErrorMessage(error), { cause: error });
|
|
605
|
+
throw admissionFailure;
|
|
606
|
+
}
|
|
607
|
+
}).finally(() => {
|
|
608
|
+
queuedAdmissions -= 1;
|
|
609
|
+
});
|
|
610
|
+
admissionTail = settledAdmission.then(() => void 0, () => void 0);
|
|
611
|
+
return settledAdmission;
|
|
612
|
+
},
|
|
613
|
+
stop: () => {
|
|
614
|
+
if (stopTask) return stopTask;
|
|
615
|
+
stopping = true;
|
|
616
|
+
const pauseTask = monitor.pause();
|
|
617
|
+
stopTask = (async () => {
|
|
618
|
+
await admissionTail;
|
|
619
|
+
await monitorStart.catch(() => void 0);
|
|
620
|
+
await pauseTask;
|
|
621
|
+
await monitor.stop();
|
|
622
|
+
})();
|
|
623
|
+
return stopTask;
|
|
624
|
+
},
|
|
625
|
+
waitForIdle: async () => {
|
|
626
|
+
await admissionTail;
|
|
627
|
+
await monitorStart;
|
|
628
|
+
await monitor.waitForIdle();
|
|
629
|
+
}
|
|
630
|
+
};
|
|
631
|
+
}
|
|
632
|
+
//#endregion
|
|
243
633
|
//#region extensions/nostr/src/nostr-profile-core.ts
|
|
244
634
|
/**
|
|
245
635
|
* Convert our config profile schema to NIP-01 content format.
|
|
@@ -275,6 +665,16 @@ function contentToProfile(content) {
|
|
|
275
665
|
return profile;
|
|
276
666
|
}
|
|
277
667
|
//#endregion
|
|
668
|
+
//#region extensions/nostr/src/relay-publish.ts
|
|
669
|
+
const CONNECTION_FAILURE_PREFIX = "connection failure: ";
|
|
670
|
+
async function publishNostrEventToRelay(pool, relay, event) {
|
|
671
|
+
const publishPromise = pool.publish([relay], event)[0];
|
|
672
|
+
if (!publishPromise) throw new Error(`Failed to create publish promise for relay ${relay}`);
|
|
673
|
+
const result = await publishPromise;
|
|
674
|
+
if (result.startsWith(CONNECTION_FAILURE_PREFIX)) throw new Error(result.slice(20));
|
|
675
|
+
return result;
|
|
676
|
+
}
|
|
677
|
+
//#endregion
|
|
278
678
|
//#region extensions/nostr/src/nostr-profile.ts
|
|
279
679
|
/**
|
|
280
680
|
* Nostr Profile Management (NIP-01 kind:0)
|
|
@@ -323,7 +723,7 @@ async function publishProfileEvent(pool, relays, event) {
|
|
|
323
723
|
const timeoutPromise = new Promise((_, reject) => {
|
|
324
724
|
timer = setTimeout(() => reject(/* @__PURE__ */ new Error("timeout")), RELAY_PUBLISH_TIMEOUT_MS);
|
|
325
725
|
});
|
|
326
|
-
await Promise.race([
|
|
726
|
+
await Promise.race([publishNostrEventToRelay(pool, relay, event), timeoutPromise]);
|
|
327
727
|
successes.push(relay);
|
|
328
728
|
} catch (err) {
|
|
329
729
|
const errorMessage = formatErrorMessage(err);
|
|
@@ -357,11 +757,111 @@ async function publishProfile(pool, sk, relays, profile, lastPublishedAt) {
|
|
|
357
757
|
return publishProfileEvent(pool, relays, createProfileEvent(sk, profile, lastPublishedAt));
|
|
358
758
|
}
|
|
359
759
|
//#endregion
|
|
360
|
-
//#region extensions/nostr/src/
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
760
|
+
//#region extensions/nostr/src/nostr-rate-limiter.ts
|
|
761
|
+
function createFixedWindowRateLimiter(params) {
|
|
762
|
+
const windowMs = Math.max(1, Math.floor(params.windowMs));
|
|
763
|
+
const maxRequests = Math.max(1, Math.floor(params.maxRequests));
|
|
764
|
+
const maxTrackedKeys = Math.max(1, Math.floor(params.maxTrackedKeys));
|
|
765
|
+
const state = /* @__PURE__ */ new Map();
|
|
766
|
+
const touch = (key, value) => {
|
|
767
|
+
state.delete(key);
|
|
768
|
+
state.set(key, value);
|
|
769
|
+
};
|
|
770
|
+
const prune = (nowMs) => {
|
|
771
|
+
for (const [key, entry] of state) if (nowMs - entry.windowStartMs >= windowMs) state.delete(key);
|
|
772
|
+
while (state.size > maxTrackedKeys) {
|
|
773
|
+
const oldest = state.keys().next().value;
|
|
774
|
+
if (!oldest) break;
|
|
775
|
+
state.delete(oldest);
|
|
776
|
+
}
|
|
777
|
+
};
|
|
778
|
+
return {
|
|
779
|
+
isRateLimited: (key, nowMs = Date.now()) => {
|
|
780
|
+
if (!key) return false;
|
|
781
|
+
prune(nowMs);
|
|
782
|
+
const existing = state.get(key);
|
|
783
|
+
if (!existing || nowMs - existing.windowStartMs >= windowMs) {
|
|
784
|
+
touch(key, {
|
|
785
|
+
count: 1,
|
|
786
|
+
windowStartMs: nowMs
|
|
787
|
+
});
|
|
788
|
+
return false;
|
|
789
|
+
}
|
|
790
|
+
const nextCount = existing.count + 1;
|
|
791
|
+
touch(key, {
|
|
792
|
+
count: nextCount,
|
|
793
|
+
windowStartMs: existing.windowStartMs
|
|
794
|
+
});
|
|
795
|
+
return nextCount > maxRequests;
|
|
796
|
+
},
|
|
797
|
+
size: () => state.size,
|
|
798
|
+
clear: () => state.clear()
|
|
799
|
+
};
|
|
800
|
+
}
|
|
801
|
+
//#endregion
|
|
802
|
+
//#region extensions/nostr/src/nostr-relay-subscription.ts
|
|
803
|
+
const DEFAULT_EOSE_CONFIRM_DEADLINE_MS = 1e4;
|
|
804
|
+
const LIBRARY_EOSE_TIMEOUT_MARGIN_MS = 1e3;
|
|
805
|
+
/** Separates real relay EOSE frames from nostr-tools timeout/close synthesis. */
|
|
806
|
+
function createNostrRelaySubscriptionGroup(options) {
|
|
807
|
+
const relays = [...new Set(options.relays)];
|
|
808
|
+
const subscriptions = [];
|
|
809
|
+
const deadlineTimers = /* @__PURE__ */ new Set();
|
|
810
|
+
let locallyClosing = false;
|
|
811
|
+
const backfillStatus = new Map(relays.map((relay) => [relay, "pending"]));
|
|
812
|
+
const confirmDeadlineMs = options.eoseConfirmDeadlineMs ?? DEFAULT_EOSE_CONFIRM_DEADLINE_MS;
|
|
813
|
+
const settleBackfill = (relay, status) => {
|
|
814
|
+
if (backfillStatus.get(relay) !== "pending") return;
|
|
815
|
+
backfillStatus.set(relay, status);
|
|
816
|
+
if ([...backfillStatus.values()].some((value) => value === "pending")) return;
|
|
817
|
+
if ([...backfillStatus.values()].every((value) => value === "confirmed")) options.onBackfillComplete(relays);
|
|
818
|
+
};
|
|
819
|
+
const clearDeadlines = () => {
|
|
820
|
+
for (const timer of deadlineTimers) clearTimeout(timer);
|
|
821
|
+
deadlineTimers.clear();
|
|
822
|
+
};
|
|
823
|
+
return {
|
|
824
|
+
start: () => {
|
|
825
|
+
for (const relay of relays) {
|
|
826
|
+
let relayClosed = false;
|
|
827
|
+
let deadlineReached = false;
|
|
828
|
+
const deadlineTimer = setTimeout(() => {
|
|
829
|
+
deadlineTimers.delete(deadlineTimer);
|
|
830
|
+
deadlineReached = true;
|
|
831
|
+
settleBackfill(relay, "incomplete");
|
|
832
|
+
}, confirmDeadlineMs);
|
|
833
|
+
deadlineTimer.unref?.();
|
|
834
|
+
deadlineTimers.add(deadlineTimer);
|
|
835
|
+
subscriptions.push(options.pool.subscribeMany([relay], options.filter, {
|
|
836
|
+
onevent: options.onEvent,
|
|
837
|
+
oneose: () => {
|
|
838
|
+
queueMicrotask(() => {
|
|
839
|
+
if (!relayClosed && !deadlineReached) {
|
|
840
|
+
clearTimeout(deadlineTimer);
|
|
841
|
+
deadlineTimers.delete(deadlineTimer);
|
|
842
|
+
settleBackfill(relay, "confirmed");
|
|
843
|
+
}
|
|
844
|
+
});
|
|
845
|
+
},
|
|
846
|
+
onclose: (reasons) => {
|
|
847
|
+
relayClosed = true;
|
|
848
|
+
clearTimeout(deadlineTimer);
|
|
849
|
+
deadlineTimers.delete(deadlineTimer);
|
|
850
|
+
settleBackfill(relay, "incomplete");
|
|
851
|
+
if (!locallyClosing && !options.abort.aborted) options.onClose(relay, reasons);
|
|
852
|
+
},
|
|
853
|
+
maxWait: confirmDeadlineMs + LIBRARY_EOSE_TIMEOUT_MARGIN_MS,
|
|
854
|
+
abort: options.abort
|
|
855
|
+
}));
|
|
856
|
+
}
|
|
857
|
+
},
|
|
858
|
+
close: async (reason) => {
|
|
859
|
+
locallyClosing = true;
|
|
860
|
+
clearDeadlines();
|
|
861
|
+
await Promise.all(subscriptions.map(async (subscription) => subscription.close(reason)));
|
|
862
|
+
}
|
|
863
|
+
};
|
|
864
|
+
}
|
|
365
865
|
//#endregion
|
|
366
866
|
//#region extensions/nostr/src/nostr-state-store.ts
|
|
367
867
|
const STORE_VERSION = 2;
|
|
@@ -416,210 +916,15 @@ async function writeNostrProfileState(params) {
|
|
|
416
916
|
await openNostrProfileStateStore(params.env).register(normalizeNostrStateAccountId(params.accountId), payload);
|
|
417
917
|
}
|
|
418
918
|
//#endregion
|
|
419
|
-
//#region extensions/nostr/src/seen-tracker.ts
|
|
420
|
-
/**
|
|
421
|
-
* LRU-based seen event tracker with TTL support.
|
|
422
|
-
* Prevents unbounded memory growth under high load or abuse.
|
|
423
|
-
*/
|
|
424
|
-
/**
|
|
425
|
-
* Create a new seen tracker with LRU eviction and TTL expiration.
|
|
426
|
-
*/
|
|
427
|
-
function createSeenTracker(options) {
|
|
428
|
-
const maxEntries = resolveIntegerOption(options?.maxEntries, 1e5, { min: 1 });
|
|
429
|
-
const ttlMs = resolvePositiveTimerTimeoutMs(options?.ttlMs, 3600 * 1e3);
|
|
430
|
-
const pruneIntervalMs = resolvePositiveTimerTimeoutMs(options?.pruneIntervalMs, 600 * 1e3);
|
|
431
|
-
const entries = /* @__PURE__ */ new Map();
|
|
432
|
-
let head = null;
|
|
433
|
-
let tail = null;
|
|
434
|
-
function moveToFront(id) {
|
|
435
|
-
const entry = entries.get(id);
|
|
436
|
-
if (!entry) return;
|
|
437
|
-
if (head === id) return;
|
|
438
|
-
if (entry.prev) {
|
|
439
|
-
const prevEntry = entries.get(entry.prev);
|
|
440
|
-
if (prevEntry) prevEntry.next = entry.next;
|
|
441
|
-
}
|
|
442
|
-
if (entry.next) {
|
|
443
|
-
const nextEntry = entries.get(entry.next);
|
|
444
|
-
if (nextEntry) nextEntry.prev = entry.prev;
|
|
445
|
-
}
|
|
446
|
-
if (tail === id) tail = entry.prev;
|
|
447
|
-
entry.prev = null;
|
|
448
|
-
entry.next = head;
|
|
449
|
-
if (head) {
|
|
450
|
-
const headEntry = entries.get(head);
|
|
451
|
-
if (headEntry) headEntry.prev = id;
|
|
452
|
-
}
|
|
453
|
-
head = id;
|
|
454
|
-
if (!tail) tail = id;
|
|
455
|
-
}
|
|
456
|
-
function removeFromList(id) {
|
|
457
|
-
const entry = entries.get(id);
|
|
458
|
-
if (!entry) return;
|
|
459
|
-
if (entry.prev) {
|
|
460
|
-
const prevEntry = entries.get(entry.prev);
|
|
461
|
-
if (prevEntry) prevEntry.next = entry.next;
|
|
462
|
-
} else head = entry.next;
|
|
463
|
-
if (entry.next) {
|
|
464
|
-
const nextEntry = entries.get(entry.next);
|
|
465
|
-
if (nextEntry) nextEntry.prev = entry.prev;
|
|
466
|
-
} else tail = entry.prev;
|
|
467
|
-
}
|
|
468
|
-
function evictLRU() {
|
|
469
|
-
if (!tail) return;
|
|
470
|
-
const idToEvict = tail;
|
|
471
|
-
removeFromList(idToEvict);
|
|
472
|
-
entries.delete(idToEvict);
|
|
473
|
-
}
|
|
474
|
-
function insertAtFront(id, seenAt) {
|
|
475
|
-
const newEntry = {
|
|
476
|
-
seenAt,
|
|
477
|
-
prev: null,
|
|
478
|
-
next: head
|
|
479
|
-
};
|
|
480
|
-
if (head) {
|
|
481
|
-
const headEntry = entries.get(head);
|
|
482
|
-
if (headEntry) headEntry.prev = id;
|
|
483
|
-
}
|
|
484
|
-
entries.set(id, newEntry);
|
|
485
|
-
head = id;
|
|
486
|
-
if (!tail) tail = id;
|
|
487
|
-
}
|
|
488
|
-
function pruneExpired() {
|
|
489
|
-
const now = Date.now();
|
|
490
|
-
const toDelete = [];
|
|
491
|
-
for (const [id, entry] of entries) if (now - entry.seenAt > ttlMs) toDelete.push(id);
|
|
492
|
-
for (const id of toDelete) {
|
|
493
|
-
removeFromList(id);
|
|
494
|
-
entries.delete(id);
|
|
495
|
-
}
|
|
496
|
-
}
|
|
497
|
-
let pruneTimer;
|
|
498
|
-
if (pruneIntervalMs > 0) {
|
|
499
|
-
pruneTimer = setInterval(pruneExpired, pruneIntervalMs);
|
|
500
|
-
if (pruneTimer.unref) pruneTimer.unref();
|
|
501
|
-
}
|
|
502
|
-
function add(id) {
|
|
503
|
-
const now = Date.now();
|
|
504
|
-
const existing = entries.get(id);
|
|
505
|
-
if (existing) {
|
|
506
|
-
existing.seenAt = now;
|
|
507
|
-
moveToFront(id);
|
|
508
|
-
return;
|
|
509
|
-
}
|
|
510
|
-
while (entries.size >= maxEntries) evictLRU();
|
|
511
|
-
insertAtFront(id, now);
|
|
512
|
-
}
|
|
513
|
-
function has(id) {
|
|
514
|
-
const entry = entries.get(id);
|
|
515
|
-
if (!entry) {
|
|
516
|
-
add(id);
|
|
517
|
-
return false;
|
|
518
|
-
}
|
|
519
|
-
if (Date.now() - entry.seenAt > ttlMs) {
|
|
520
|
-
removeFromList(id);
|
|
521
|
-
entries.delete(id);
|
|
522
|
-
add(id);
|
|
523
|
-
return false;
|
|
524
|
-
}
|
|
525
|
-
entry.seenAt = Date.now();
|
|
526
|
-
moveToFront(id);
|
|
527
|
-
return true;
|
|
528
|
-
}
|
|
529
|
-
function peek(id) {
|
|
530
|
-
const entry = entries.get(id);
|
|
531
|
-
if (!entry) return false;
|
|
532
|
-
if (Date.now() - entry.seenAt > ttlMs) {
|
|
533
|
-
removeFromList(id);
|
|
534
|
-
entries.delete(id);
|
|
535
|
-
return false;
|
|
536
|
-
}
|
|
537
|
-
return true;
|
|
538
|
-
}
|
|
539
|
-
function deleteEntry(id) {
|
|
540
|
-
if (entries.has(id)) {
|
|
541
|
-
removeFromList(id);
|
|
542
|
-
entries.delete(id);
|
|
543
|
-
}
|
|
544
|
-
}
|
|
545
|
-
function clear() {
|
|
546
|
-
entries.clear();
|
|
547
|
-
head = null;
|
|
548
|
-
tail = null;
|
|
549
|
-
}
|
|
550
|
-
function size() {
|
|
551
|
-
return entries.size;
|
|
552
|
-
}
|
|
553
|
-
function stop() {
|
|
554
|
-
if (pruneTimer) {
|
|
555
|
-
clearInterval(pruneTimer);
|
|
556
|
-
pruneTimer = void 0;
|
|
557
|
-
}
|
|
558
|
-
}
|
|
559
|
-
function seed(ids) {
|
|
560
|
-
const now = Date.now();
|
|
561
|
-
for (const id of ids.toReversed()) if (!entries.has(id) && entries.size < maxEntries) insertAtFront(id, now);
|
|
562
|
-
}
|
|
563
|
-
return {
|
|
564
|
-
has,
|
|
565
|
-
add,
|
|
566
|
-
peek,
|
|
567
|
-
delete: deleteEntry,
|
|
568
|
-
clear,
|
|
569
|
-
size,
|
|
570
|
-
stop,
|
|
571
|
-
seed
|
|
572
|
-
};
|
|
573
|
-
}
|
|
574
|
-
//#endregion
|
|
575
919
|
//#region extensions/nostr/src/nostr-bus.ts
|
|
576
920
|
const STARTUP_LOOKBACK_SEC = 120;
|
|
577
|
-
const MAX_PERSISTED_EVENT_IDS = 5e3;
|
|
578
921
|
const STATE_PERSIST_DEBOUNCE_MS = 5e3;
|
|
922
|
+
const NOSTR_INGRESS_ENVELOPE_OVERHEAD_BYTES = 16 * 1024;
|
|
923
|
+
const NOSTR_INGRESS_MAX_PENDING_EVENTS = 1e3;
|
|
579
924
|
const DEFAULT_INBOUND_GUARD_POLICY = createDirectDmPreCryptoGuardPolicy();
|
|
580
925
|
const CIRCUIT_BREAKER_THRESHOLD = 5;
|
|
581
926
|
const CIRCUIT_BREAKER_RESET_MS = 3e4;
|
|
582
927
|
const HEALTH_WINDOW_MS = 6e4;
|
|
583
|
-
function createFixedWindowRateLimiter(params) {
|
|
584
|
-
const windowMs = Math.max(1, Math.floor(params.windowMs));
|
|
585
|
-
const maxRequests = Math.max(1, Math.floor(params.maxRequests));
|
|
586
|
-
const maxTrackedKeys = Math.max(1, Math.floor(params.maxTrackedKeys));
|
|
587
|
-
const state = /* @__PURE__ */ new Map();
|
|
588
|
-
const touch = (key, value) => {
|
|
589
|
-
state.delete(key);
|
|
590
|
-
state.set(key, value);
|
|
591
|
-
};
|
|
592
|
-
const prune = (nowMs) => {
|
|
593
|
-
for (const [key, entry] of state) if (nowMs - entry.windowStartMs >= windowMs) state.delete(key);
|
|
594
|
-
while (state.size > maxTrackedKeys) {
|
|
595
|
-
const oldest = state.keys().next().value;
|
|
596
|
-
if (!oldest) break;
|
|
597
|
-
state.delete(oldest);
|
|
598
|
-
}
|
|
599
|
-
};
|
|
600
|
-
return {
|
|
601
|
-
isRateLimited: (key, nowMs = Date.now()) => {
|
|
602
|
-
if (!key) return false;
|
|
603
|
-
prune(nowMs);
|
|
604
|
-
const existing = state.get(key);
|
|
605
|
-
if (!existing || nowMs - existing.windowStartMs >= windowMs) {
|
|
606
|
-
touch(key, {
|
|
607
|
-
count: 1,
|
|
608
|
-
windowStartMs: nowMs
|
|
609
|
-
});
|
|
610
|
-
return false;
|
|
611
|
-
}
|
|
612
|
-
const nextCount = existing.count + 1;
|
|
613
|
-
touch(key, {
|
|
614
|
-
count: nextCount,
|
|
615
|
-
windowStartMs: existing.windowStartMs
|
|
616
|
-
});
|
|
617
|
-
return nextCount > maxRequests;
|
|
618
|
-
},
|
|
619
|
-
size: () => state.size,
|
|
620
|
-
clear: () => state.clear()
|
|
621
|
-
};
|
|
622
|
-
}
|
|
623
928
|
function createCircuitBreaker(relay, metrics, threshold = CIRCUIT_BREAKER_THRESHOLD, resetMs = CIRCUIT_BREAKER_RESET_MS) {
|
|
624
929
|
const state = {
|
|
625
930
|
state: "closed",
|
|
@@ -715,10 +1020,11 @@ function createRelayHealthTracker() {
|
|
|
715
1020
|
* Start the Nostr DM bus - subscribes to NIP-04 encrypted DMs
|
|
716
1021
|
*/
|
|
717
1022
|
async function startNostrBus(options) {
|
|
718
|
-
const { privateKey, relays = DEFAULT_RELAYS, onMessage, authorizeSender, onError, onEose, onMetric
|
|
1023
|
+
const { privateKey, relays = DEFAULT_RELAYS, onMessage, authorizeSender, onError, onEose, onMetric } = options;
|
|
719
1024
|
const sk = validatePrivateKey(privateKey);
|
|
720
1025
|
const pk = getPublicKey(sk);
|
|
721
1026
|
const pool = new SimplePool();
|
|
1027
|
+
pool.onRelayConnectionSuccess = options.onConnect;
|
|
722
1028
|
const accountId = options.accountId ?? pk.slice(0, 16);
|
|
723
1029
|
const gatewayStartedAt = Math.floor(Date.now() / 1e3);
|
|
724
1030
|
const guardPolicy = createDirectDmPreCryptoGuardPolicy({
|
|
@@ -730,41 +1036,32 @@ async function startNostrBus(options) {
|
|
|
730
1036
|
}
|
|
731
1037
|
});
|
|
732
1038
|
const metrics = onMetric ? createMetrics(onMetric) : createNoopMetrics();
|
|
733
|
-
const seen = createSeenTracker({
|
|
734
|
-
maxEntries: maxSeenEntries,
|
|
735
|
-
ttlMs: seenTtlMs
|
|
736
|
-
});
|
|
737
1039
|
const circuitBreakers = /* @__PURE__ */ new Map();
|
|
738
1040
|
const healthTracker = createRelayHealthTracker();
|
|
739
1041
|
for (const relay of relays) circuitBreakers.set(relay, createCircuitBreaker(relay, metrics));
|
|
740
1042
|
const state = await readNostrBusState({ accountId });
|
|
741
1043
|
const baseSince = computeSinceTimestamp(state, gatewayStartedAt);
|
|
742
1044
|
const since = Math.max(0, baseSince - STARTUP_LOOKBACK_SEC);
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
let lastProcessedAt = state?.lastProcessedAt ?? gatewayStartedAt;
|
|
752
|
-
let recentEventIds = (state?.recentEventIds ?? []).slice(-5e3);
|
|
753
|
-
function scheduleStatePersist(eventCreatedAt, eventId) {
|
|
754
|
-
lastProcessedAt = Math.max(lastProcessedAt, eventCreatedAt);
|
|
755
|
-
recentEventIds.push(eventId);
|
|
756
|
-
if (recentEventIds.length > MAX_PERSISTED_EVENT_IDS) recentEventIds = recentEventIds.slice(-5e3);
|
|
757
|
-
if (pendingWrite) clearTimeout(pendingWrite);
|
|
758
|
-
pendingWrite = setTimeout(() => {
|
|
759
|
-
writeNostrBusState({
|
|
1045
|
+
const cursorStartedAt = state?.gatewayStartedAt ?? gatewayStartedAt;
|
|
1046
|
+
const initialCursor = Math.max(baseSince, state?.lastProcessedAt ?? cursorStartedAt);
|
|
1047
|
+
const cursorWriter = createNostrCursorStateWriter({
|
|
1048
|
+
initialCursor,
|
|
1049
|
+
minimumCursor: baseSince,
|
|
1050
|
+
debounceMs: STATE_PERSIST_DEBOUNCE_MS,
|
|
1051
|
+
write: async (cursor) => {
|
|
1052
|
+
await writeNostrBusState({
|
|
760
1053
|
accountId,
|
|
761
|
-
lastProcessedAt,
|
|
762
|
-
gatewayStartedAt,
|
|
763
|
-
recentEventIds
|
|
764
|
-
})
|
|
765
|
-
},
|
|
766
|
-
|
|
767
|
-
|
|
1054
|
+
lastProcessedAt: cursor,
|
|
1055
|
+
gatewayStartedAt: cursorStartedAt,
|
|
1056
|
+
recentEventIds: []
|
|
1057
|
+
});
|
|
1058
|
+
},
|
|
1059
|
+
onBackgroundError: (error) => onError?.(error, "persist state")
|
|
1060
|
+
});
|
|
1061
|
+
const durableCursor = createNostrDurableCursor({
|
|
1062
|
+
since,
|
|
1063
|
+
replayOverlapSec: STARTUP_LOOKBACK_SEC
|
|
1064
|
+
});
|
|
768
1065
|
const perSenderRateLimiter = createFixedWindowRateLimiter({
|
|
769
1066
|
windowMs: guardPolicy.rateLimit.windowMs,
|
|
770
1067
|
maxRequests: guardPolicy.rateLimit.maxPerSenderPerWindow,
|
|
@@ -778,147 +1075,217 @@ async function startNostrBus(options) {
|
|
|
778
1075
|
const updateRateLimiterSizeMetric = () => {
|
|
779
1076
|
metrics.emit("memory.rate_limiter_entries", perSenderRateLimiter.size() + globalRateLimiter.size());
|
|
780
1077
|
};
|
|
781
|
-
|
|
1078
|
+
const rejectIfGlobalRateLimited = () => {
|
|
1079
|
+
updateRateLimiterSizeMetric();
|
|
1080
|
+
if (globalRateLimiter.isRateLimited("global")) {
|
|
1081
|
+
metrics.emit("rate_limit.global");
|
|
1082
|
+
metrics.emit("event.rejected.rate_limited");
|
|
1083
|
+
updateRateLimiterSizeMetric();
|
|
1084
|
+
return true;
|
|
1085
|
+
}
|
|
1086
|
+
updateRateLimiterSizeMetric();
|
|
1087
|
+
return false;
|
|
1088
|
+
};
|
|
1089
|
+
const rejectIfVerifiedSenderRateLimited = (senderPubkey) => {
|
|
1090
|
+
updateRateLimiterSizeMetric();
|
|
1091
|
+
if (perSenderRateLimiter.isRateLimited(senderPubkey)) {
|
|
1092
|
+
metrics.emit("rate_limit.per_sender");
|
|
1093
|
+
metrics.emit("event.rejected.rate_limited");
|
|
1094
|
+
updateRateLimiterSizeMetric();
|
|
1095
|
+
return true;
|
|
1096
|
+
}
|
|
1097
|
+
updateRateLimiterSizeMetric();
|
|
1098
|
+
return false;
|
|
1099
|
+
};
|
|
1100
|
+
async function dispatchEvent(event, lifecycle) {
|
|
1101
|
+
if (event.pubkey === pk) {
|
|
1102
|
+
metrics.emit("event.rejected.self_message");
|
|
1103
|
+
return;
|
|
1104
|
+
}
|
|
1105
|
+
if (event.created_at > Math.floor(Date.now() / 1e3) + guardPolicy.maxFutureSkewSec) {
|
|
1106
|
+
metrics.emit("event.rejected.future");
|
|
1107
|
+
throw new Error(`Nostr event ${event.id} is too far in the future.`);
|
|
1108
|
+
}
|
|
1109
|
+
if (!guardPolicy.allowedKinds.includes(event.kind)) {
|
|
1110
|
+
metrics.emit("event.rejected.wrong_kind");
|
|
1111
|
+
return;
|
|
1112
|
+
}
|
|
1113
|
+
let targetsUs = false;
|
|
1114
|
+
for (const tag of event.tags) if (tag[0] === "p" && tag[1] === pk) {
|
|
1115
|
+
targetsUs = true;
|
|
1116
|
+
break;
|
|
1117
|
+
}
|
|
1118
|
+
if (!targetsUs) {
|
|
1119
|
+
metrics.emit("event.rejected.wrong_kind");
|
|
1120
|
+
return;
|
|
1121
|
+
}
|
|
1122
|
+
const replyTo = async (text) => {
|
|
1123
|
+
await sendEncryptedDm(pool, sk, event.pubkey, text, relays, metrics, circuitBreakers, healthTracker, onError, event.id);
|
|
1124
|
+
};
|
|
1125
|
+
if (Buffer.byteLength(event.content, "utf8") > guardPolicy.maxCiphertextBytes) {
|
|
1126
|
+
if (rejectIfGlobalRateLimited()) throw new Error(`Nostr event ${event.id} hit the global rate limit.`);
|
|
1127
|
+
metrics.emit("event.rejected.oversized_ciphertext");
|
|
1128
|
+
return;
|
|
1129
|
+
}
|
|
1130
|
+
if (rejectIfGlobalRateLimited()) throw new Error(`Nostr event ${event.id} hit the global rate limit.`);
|
|
1131
|
+
if (!verifyEvent(event)) {
|
|
1132
|
+
metrics.emit("event.rejected.invalid_signature");
|
|
1133
|
+
const error = new NostrIngressPermanentError("invalid-signature", `Nostr event ${event.id} has an invalid signature.`);
|
|
1134
|
+
onError?.(error, `event ${event.id}`);
|
|
1135
|
+
throw error;
|
|
1136
|
+
}
|
|
1137
|
+
if (rejectIfVerifiedSenderRateLimited(event.pubkey)) throw new Error(`Nostr sender ${event.pubkey} hit the rate limit.`);
|
|
1138
|
+
if (authorizeSender) {
|
|
1139
|
+
if (await authorizeSender({
|
|
1140
|
+
senderPubkey: event.pubkey,
|
|
1141
|
+
reply: replyTo
|
|
1142
|
+
}) !== "allow") return;
|
|
1143
|
+
}
|
|
1144
|
+
let plaintext;
|
|
782
1145
|
try {
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
}
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
1146
|
+
plaintext = decrypt(sk, event.pubkey, event.content);
|
|
1147
|
+
metrics.emit("decrypt.success");
|
|
1148
|
+
} catch (error) {
|
|
1149
|
+
metrics.emit("decrypt.failure");
|
|
1150
|
+
metrics.emit("event.rejected.decrypt_failed");
|
|
1151
|
+
onError?.(error, `decrypt from ${event.pubkey}`);
|
|
1152
|
+
throw new NostrIngressPermanentError("decrypt-failed", `Nostr event ${event.id} could not be decrypted.`, { cause: error });
|
|
1153
|
+
}
|
|
1154
|
+
if (Buffer.byteLength(plaintext, "utf8") > guardPolicy.maxPlaintextBytes) {
|
|
1155
|
+
metrics.emit("event.rejected.oversized_plaintext");
|
|
1156
|
+
return;
|
|
1157
|
+
}
|
|
1158
|
+
if (lifecycle.abortSignal.aborted) throw new Error(`Nostr event ${event.id} stopped before dispatch.`);
|
|
1159
|
+
await onMessage(event.pubkey, plaintext, replyTo, {
|
|
1160
|
+
eventId: event.id,
|
|
1161
|
+
createdAt: event.created_at
|
|
1162
|
+
}, lifecycle);
|
|
1163
|
+
metrics.emit("event.processed");
|
|
1164
|
+
}
|
|
1165
|
+
const dmFilter = {
|
|
1166
|
+
kinds: [4],
|
|
1167
|
+
"#p": [pk],
|
|
1168
|
+
since
|
|
1169
|
+
};
|
|
1170
|
+
const relayAbort = new AbortController();
|
|
1171
|
+
let relaySubscriptions;
|
|
1172
|
+
let relayStopPromise;
|
|
1173
|
+
const stopRelays = (reason) => {
|
|
1174
|
+
relayStopPromise ??= (async () => {
|
|
1175
|
+
relayAbort.abort(reason);
|
|
1176
|
+
try {
|
|
1177
|
+
await relaySubscriptions?.close(reason);
|
|
1178
|
+
} catch (error) {
|
|
1179
|
+
onError?.(error, "close subscription");
|
|
1180
|
+
} finally {
|
|
1181
|
+
try {
|
|
1182
|
+
pool.close(relays);
|
|
1183
|
+
} catch (error) {
|
|
1184
|
+
onError?.(error, "close relay pool");
|
|
1185
|
+
}
|
|
821
1186
|
}
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
1187
|
+
})();
|
|
1188
|
+
return relayStopPromise;
|
|
1189
|
+
};
|
|
1190
|
+
const ingress = createNostrIngress({
|
|
1191
|
+
accountId,
|
|
1192
|
+
legacyEventIds: state?.recentEventIds ?? [],
|
|
1193
|
+
maxSerializedPayloadBytes: guardPolicy.maxCiphertextBytes + NOSTR_INGRESS_ENVELOPE_OVERHEAD_BYTES,
|
|
1194
|
+
maxPendingEvents: NOSTR_INGRESS_MAX_PENDING_EVENTS,
|
|
1195
|
+
maxQueuedAdmissions: guardPolicy.rateLimit.maxGlobalPerWindow,
|
|
1196
|
+
admissionRateLimit: {
|
|
1197
|
+
windowMs: guardPolicy.rateLimit.windowMs,
|
|
1198
|
+
maxEvents: guardPolicy.rateLimit.maxGlobalPerWindow
|
|
1199
|
+
},
|
|
1200
|
+
afterDurableAppend: (event) => {
|
|
1201
|
+
const cursor = durableCursor.recordDurableAppend(event);
|
|
1202
|
+
if (cursor !== void 0) cursorWriter.schedule(cursor);
|
|
1203
|
+
},
|
|
1204
|
+
deliver: dispatchEvent,
|
|
1205
|
+
onError
|
|
1206
|
+
});
|
|
1207
|
+
const persistTransientReplayCursor = async (event) => {
|
|
1208
|
+
const cursor = durableCursor.recordTransientRejection(event);
|
|
1209
|
+
if (cursor !== void 0) await cursorWriter.persistNow(cursor);
|
|
1210
|
+
};
|
|
1211
|
+
const recoverCursorPersistence = async () => {
|
|
1212
|
+
await cursorWriter.flushUntilSuccess();
|
|
1213
|
+
};
|
|
1214
|
+
const handleRelayEvent = async (event) => {
|
|
1215
|
+
metrics.emit("event.received");
|
|
1216
|
+
if (typeof event.created_at === "number" && event.created_at < since) {
|
|
1217
|
+
metrics.emit("event.rejected.stale");
|
|
1218
|
+
return;
|
|
1219
|
+
}
|
|
1220
|
+
try {
|
|
1221
|
+
if (await ingress.receive(event) === "duplicate") metrics.emit("event.duplicate");
|
|
1222
|
+
} catch (error) {
|
|
1223
|
+
onError?.(error, `durable admission for event ${event.id}`);
|
|
1224
|
+
if (error instanceof NostrIngressAdmissionRejectedError) {
|
|
1225
|
+
if (error.reason === "rate-limited") {
|
|
828
1226
|
metrics.emit("rate_limit.global");
|
|
829
1227
|
metrics.emit("event.rejected.rate_limited");
|
|
830
|
-
updateRateLimiterSizeMetric();
|
|
831
|
-
return true;
|
|
832
1228
|
}
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
metrics.emit("rate_limit.per_sender");
|
|
840
|
-
metrics.emit("event.rejected.rate_limited");
|
|
841
|
-
updateRateLimiterSizeMetric();
|
|
842
|
-
return true;
|
|
1229
|
+
if (error.reason !== "oversized-event") try {
|
|
1230
|
+
await persistTransientReplayCursor(event);
|
|
1231
|
+
} catch (cursorError) {
|
|
1232
|
+
onError?.(cursorError, "persist transient replay cursor");
|
|
1233
|
+
await stopRelays("cursor persistence failed");
|
|
1234
|
+
await recoverCursorPersistence();
|
|
843
1235
|
}
|
|
844
|
-
updateRateLimiterSizeMetric();
|
|
845
|
-
return false;
|
|
846
|
-
};
|
|
847
|
-
if (Buffer.byteLength(event.content, "utf8") > guardPolicy.maxCiphertextBytes) {
|
|
848
|
-
if (rejectIfGlobalRateLimited()) return;
|
|
849
|
-
rejectAndMarkSeen("event.rejected.oversized_ciphertext");
|
|
850
1236
|
return;
|
|
851
1237
|
}
|
|
852
|
-
if (
|
|
853
|
-
|
|
854
|
-
rejectAndMarkSeen("event.rejected.invalid_signature");
|
|
855
|
-
onError?.(/* @__PURE__ */ new Error("Invalid signature"), `event ${event.id}`);
|
|
856
|
-
return;
|
|
857
|
-
}
|
|
858
|
-
if (rejectIfVerifiedSenderRateLimited()) return;
|
|
859
|
-
if (authorizeSender) {
|
|
860
|
-
if (await authorizeSender({
|
|
861
|
-
senderPubkey: event.pubkey,
|
|
862
|
-
reply: replyTo
|
|
863
|
-
}) !== "allow") {
|
|
864
|
-
markSeen();
|
|
865
|
-
return;
|
|
866
|
-
}
|
|
867
|
-
}
|
|
868
|
-
let plaintext;
|
|
1238
|
+
if (error instanceof NostrIngressPermanentError) return;
|
|
1239
|
+
let cursorPersistenceFailed = false;
|
|
869
1240
|
try {
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
metrics.emit("decrypt.failure");
|
|
875
|
-
metrics.emit("event.rejected.decrypt_failed");
|
|
876
|
-
onError?.(err, `decrypt from ${event.pubkey}`);
|
|
877
|
-
return;
|
|
878
|
-
}
|
|
879
|
-
if (Buffer.byteLength(plaintext, "utf8") > guardPolicy.maxPlaintextBytes) {
|
|
880
|
-
markSeen();
|
|
881
|
-
metrics.emit("event.rejected.oversized_plaintext");
|
|
882
|
-
return;
|
|
1241
|
+
await persistTransientReplayCursor(event);
|
|
1242
|
+
} catch (cursorError) {
|
|
1243
|
+
onError?.(cursorError, "persist transient replay cursor");
|
|
1244
|
+
cursorPersistenceFailed = true;
|
|
883
1245
|
}
|
|
884
|
-
await
|
|
885
|
-
|
|
886
|
-
createdAt: event.created_at
|
|
887
|
-
});
|
|
888
|
-
markSeen();
|
|
889
|
-
metrics.emit("event.processed");
|
|
890
|
-
scheduleStatePersist(event.created_at, event.id);
|
|
891
|
-
} catch (err) {
|
|
892
|
-
onError?.(err, `event ${event.id}`);
|
|
893
|
-
} finally {
|
|
894
|
-
inflight.delete(event.id);
|
|
1246
|
+
await stopRelays("durable admission failed");
|
|
1247
|
+
if (cursorPersistenceFailed) await recoverCursorPersistence();
|
|
895
1248
|
}
|
|
896
|
-
}
|
|
897
|
-
const dmFilter = {
|
|
898
|
-
kinds: [4],
|
|
899
|
-
"#p": [pk],
|
|
900
|
-
since
|
|
901
1249
|
};
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
}
|
|
911
|
-
|
|
912
|
-
|
|
1250
|
+
let backfillFinalizePromise;
|
|
1251
|
+
try {
|
|
1252
|
+
await ingress.ready();
|
|
1253
|
+
await writeNostrBusState({
|
|
1254
|
+
accountId,
|
|
1255
|
+
lastProcessedAt: initialCursor,
|
|
1256
|
+
gatewayStartedAt: cursorStartedAt,
|
|
1257
|
+
recentEventIds: []
|
|
1258
|
+
});
|
|
1259
|
+
relaySubscriptions = createNostrRelaySubscriptionGroup({
|
|
1260
|
+
pool,
|
|
1261
|
+
relays,
|
|
1262
|
+
filter: dmFilter,
|
|
1263
|
+
abort: relayAbort.signal,
|
|
1264
|
+
onEvent: (event) => {
|
|
1265
|
+
const task = handleRelayEvent(event);
|
|
1266
|
+
if (options.trackIngressTask) options.trackIngressTask(task.then(() => ingress.waitForIdle()));
|
|
1267
|
+
},
|
|
1268
|
+
onBackfillComplete: (confirmedRelays) => {
|
|
1269
|
+
backfillFinalizePromise ??= ingress.waitForIdle().then(() => {
|
|
1270
|
+
const cursor = durableCursor.markBackfillComplete();
|
|
1271
|
+
if (cursor !== void 0) cursorWriter.schedule(cursor);
|
|
1272
|
+
for (const relay of confirmedRelays) metrics.emit("relay.message.eose", 1, { relay });
|
|
1273
|
+
onEose?.(confirmedRelays.join(", "));
|
|
1274
|
+
}).catch((error) => onError?.(error, "finalize relay backfill"));
|
|
1275
|
+
},
|
|
1276
|
+
onClose: (relay, reasons) => {
|
|
913
1277
|
metrics.emit("relay.message.closed", 1, { relay });
|
|
914
1278
|
options.onDisconnect?.(relay);
|
|
1279
|
+
onError?.(/* @__PURE__ */ new Error(`Subscription closed: ${reasons.join(", ")}`), "subscription");
|
|
915
1280
|
}
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
1281
|
+
});
|
|
1282
|
+
relaySubscriptions.start();
|
|
1283
|
+
} catch (error) {
|
|
1284
|
+
await Promise.allSettled([stopRelays("startup failed"), ingress.stop()]);
|
|
1285
|
+
throw error;
|
|
1286
|
+
}
|
|
920
1287
|
const sendDm = async (toPubkey, text) => {
|
|
921
|
-
await sendEncryptedDm(pool, sk, toPubkey, text, relays, metrics, circuitBreakers, healthTracker, onError);
|
|
1288
|
+
return await sendEncryptedDm(pool, sk, toPubkey, text, relays, metrics, circuitBreakers, healthTracker, onError);
|
|
922
1289
|
};
|
|
923
1290
|
const publishProfile$1 = async (profile) => {
|
|
924
1291
|
const lastPublishedAt = (await readNostrProfileState({ accountId }))?.lastPublishedAt ?? void 0;
|
|
@@ -942,25 +1309,20 @@ async function startNostrBus(options) {
|
|
|
942
1309
|
lastPublishResults: stateLocal?.lastPublishResults ?? null
|
|
943
1310
|
};
|
|
944
1311
|
};
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
1312
|
+
let closePromise;
|
|
1313
|
+
const close = () => {
|
|
1314
|
+
closePromise ??= (async () => {
|
|
1315
|
+
await stopRelays("closed by caller");
|
|
1316
|
+
await ingress.stop();
|
|
1317
|
+
await backfillFinalizePromise;
|
|
1318
|
+
await cursorWriter.flushUntilSuccess();
|
|
952
1319
|
perSenderRateLimiter.clear();
|
|
953
1320
|
globalRateLimiter.clear();
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
gatewayStartedAt,
|
|
960
|
-
recentEventIds
|
|
961
|
-
}).catch((err) => onError?.(err, "persist state on close"));
|
|
962
|
-
}
|
|
963
|
-
},
|
|
1321
|
+
})();
|
|
1322
|
+
return closePromise;
|
|
1323
|
+
};
|
|
1324
|
+
return {
|
|
1325
|
+
close,
|
|
964
1326
|
publicKey: pk,
|
|
965
1327
|
sendDm,
|
|
966
1328
|
getMetrics: () => metrics.getSnapshot(),
|
|
@@ -971,11 +1333,14 @@ async function startNostrBus(options) {
|
|
|
971
1333
|
/**
|
|
972
1334
|
* Send an encrypted DM to a pubkey
|
|
973
1335
|
*/
|
|
974
|
-
async function sendEncryptedDm(pool, sk, toPubkey, text, relays, metrics, circuitBreakers, healthTracker, onError) {
|
|
1336
|
+
async function sendEncryptedDm(pool, sk, toPubkey, text, relays, metrics, circuitBreakers, healthTracker, onError, replyToEventId) {
|
|
1337
|
+
const ciphertext = encrypt(sk, toPubkey, text);
|
|
1338
|
+
const tags = [["p", toPubkey]];
|
|
1339
|
+
if (replyToEventId) tags.push(["e", replyToEventId]);
|
|
975
1340
|
const reply = finalizeEvent({
|
|
976
1341
|
kind: 4,
|
|
977
|
-
content:
|
|
978
|
-
tags
|
|
1342
|
+
content: ciphertext,
|
|
1343
|
+
tags,
|
|
979
1344
|
created_at: Math.floor(Date.now() / 1e3)
|
|
980
1345
|
}, sk);
|
|
981
1346
|
const sortedRelays = healthTracker.getSortedRelays(relays);
|
|
@@ -985,15 +1350,13 @@ async function sendEncryptedDm(pool, sk, toPubkey, text, relays, metrics, circui
|
|
|
985
1350
|
if (cb && !cb.canAttempt()) continue;
|
|
986
1351
|
const startTime = Date.now();
|
|
987
1352
|
try {
|
|
988
|
-
|
|
989
|
-
if (publishPromises.length === 0) throw new Error(`Failed to create publish promise for relay ${relay}`);
|
|
990
|
-
await publishPromises[0];
|
|
1353
|
+
await publishNostrEventToRelay(pool, relay, reply);
|
|
991
1354
|
const latency = Date.now() - startTime;
|
|
992
1355
|
cb?.recordSuccess();
|
|
993
1356
|
healthTracker.recordSuccess(relay, latency);
|
|
994
|
-
return;
|
|
1357
|
+
return reply.id;
|
|
995
1358
|
} catch (err) {
|
|
996
|
-
lastError = err;
|
|
1359
|
+
lastError = err instanceof Error ? err : new Error(String(err));
|
|
997
1360
|
const latency = Date.now() - startTime;
|
|
998
1361
|
cb?.recordFailure();
|
|
999
1362
|
healthTracker.recordFailure(relay);
|
|
@@ -1092,7 +1455,7 @@ const startNostrGatewayAccount = async (ctx) => {
|
|
|
1092
1455
|
ctx.log?.debug?.(`[${account.accountId}] blocked Nostr sender ${input.senderId} (${resolved.senderAccess.reasonCode})`);
|
|
1093
1456
|
return "block";
|
|
1094
1457
|
};
|
|
1095
|
-
await
|
|
1458
|
+
await runPassiveAccountLifecycle({
|
|
1096
1459
|
abortSignal: ctx.abortSignal,
|
|
1097
1460
|
start: async () => {
|
|
1098
1461
|
const bus = await startNostrBus({
|
|
@@ -1103,16 +1466,15 @@ const startNostrGatewayAccount = async (ctx) => {
|
|
|
1103
1466
|
senderId: senderPubkey,
|
|
1104
1467
|
reply
|
|
1105
1468
|
}),
|
|
1106
|
-
onMessage: async (senderPubkey, text, reply, meta) => {
|
|
1469
|
+
onMessage: async (senderPubkey, text, reply, meta, lifecycle) => {
|
|
1107
1470
|
const resolvedAccess = await resolveInboundAccess(senderPubkey, text);
|
|
1108
1471
|
if (resolvedAccess.senderAccess.decision !== "allow") {
|
|
1109
1472
|
ctx.log?.warn?.(`[${account.accountId}] dropping Nostr DM after preflight drift (${senderPubkey}, ${resolvedAccess.senderAccess.reasonCode})`);
|
|
1110
1473
|
return;
|
|
1111
1474
|
}
|
|
1112
|
-
const {
|
|
1113
|
-
await
|
|
1475
|
+
const { dispatchInboundDirectDm } = await import("./inbound-direct-dm-runtime-DHn9NoYN.js");
|
|
1476
|
+
await dispatchInboundDirectDm({
|
|
1114
1477
|
cfg: ctx.cfg,
|
|
1115
|
-
runtime,
|
|
1116
1478
|
channel: "nostr",
|
|
1117
1479
|
channelLabel: "Nostr",
|
|
1118
1480
|
accountId: account.accountId,
|
|
@@ -1128,6 +1490,7 @@ const startNostrGatewayAccount = async (ctx) => {
|
|
|
1128
1490
|
messageId: meta.eventId,
|
|
1129
1491
|
timestamp: meta.createdAt * 1e3,
|
|
1130
1492
|
commandAuthorized: resolvedAccess.commandAccess.requested ? resolvedAccess.commandAccess.authorized : void 0,
|
|
1493
|
+
turnAdoptionLifecycle: bindIngressLifecycleToReplyOptions(lifecycle).turnAdoptionLifecycle,
|
|
1131
1494
|
deliver: async (payload) => {
|
|
1132
1495
|
const outboundText = payload && typeof payload === "object" && "text" in payload ? payload.text ?? "" : "";
|
|
1133
1496
|
if (!outboundText.trim()) return;
|
|
@@ -1136,7 +1499,8 @@ const startNostrGatewayAccount = async (ctx) => {
|
|
|
1136
1499
|
channel: "nostr",
|
|
1137
1500
|
accountId: account.accountId
|
|
1138
1501
|
});
|
|
1139
|
-
|
|
1502
|
+
const message = stripMarkdown(runtime.channel.text.convertMarkdownTables(outboundText, tableMode));
|
|
1503
|
+
if (message) await reply(message);
|
|
1140
1504
|
},
|
|
1141
1505
|
onRecordError: (err) => {
|
|
1142
1506
|
ctx.log?.error?.(`[${account.accountId}] failed recording Nostr inbound session: ${String(err)}`);
|
|
@@ -1166,19 +1530,19 @@ const startNostrGatewayAccount = async (ctx) => {
|
|
|
1166
1530
|
if (busHandle) metricsSnapshots.set(account.accountId, busHandle.getMetrics());
|
|
1167
1531
|
}
|
|
1168
1532
|
});
|
|
1169
|
-
let stopped = false;
|
|
1170
1533
|
busHandle = bus;
|
|
1171
1534
|
activeBuses.set(account.accountId, bus);
|
|
1172
|
-
ctx.log?.info?.(`[${account.accountId}] Nostr provider started
|
|
1173
|
-
return { stop: () => {
|
|
1174
|
-
|
|
1175
|
-
stopped = true;
|
|
1176
|
-
bus.close();
|
|
1535
|
+
ctx.log?.info?.(`[${account.accountId}] Nostr provider started with ${account.relays.length} configured relay(s)`);
|
|
1536
|
+
return { stop: async () => {
|
|
1537
|
+
await bus.close();
|
|
1177
1538
|
if (busHandle === bus) busHandle = null;
|
|
1178
1539
|
if (activeBuses.get(account.accountId) === bus) activeBuses.delete(account.accountId);
|
|
1179
1540
|
metricsSnapshots.delete(account.accountId);
|
|
1180
1541
|
ctx.log?.info?.(`[${account.accountId}] Nostr provider stopped`);
|
|
1181
1542
|
} };
|
|
1543
|
+
},
|
|
1544
|
+
stop: async (monitor) => {
|
|
1545
|
+
await monitor.stop();
|
|
1182
1546
|
}
|
|
1183
1547
|
});
|
|
1184
1548
|
};
|
|
@@ -1214,12 +1578,12 @@ const nostrOutboundAdapter = {
|
|
|
1214
1578
|
channel: "nostr",
|
|
1215
1579
|
accountId: aid
|
|
1216
1580
|
});
|
|
1217
|
-
const message = core.channel.text.convertMarkdownTables(text ?? "", tableMode);
|
|
1581
|
+
const message = stripMarkdown(core.channel.text.convertMarkdownTables(text ?? "", tableMode));
|
|
1582
|
+
if (!message) throw new Error("Nostr send requires non-empty text after markdown stripping.");
|
|
1218
1583
|
const normalizedTo = normalizePubkey(to);
|
|
1219
|
-
await bus.sendDm(normalizedTo, message);
|
|
1220
1584
|
return attachChannelToResult("nostr", {
|
|
1221
1585
|
to: normalizedTo,
|
|
1222
|
-
messageId:
|
|
1586
|
+
messageId: await bus.sendDm(normalizedTo, message)
|
|
1223
1587
|
});
|
|
1224
1588
|
}
|
|
1225
1589
|
};
|
|
@@ -1253,6 +1617,18 @@ function resolveNostrOutboundSessionRoute(params) {
|
|
|
1253
1617
|
}
|
|
1254
1618
|
//#endregion
|
|
1255
1619
|
//#region extensions/nostr/src/channel.ts
|
|
1620
|
+
const NOSTR_TARGET_HINT = "<npub|hex pubkey|nostr:npub...>";
|
|
1621
|
+
function stripNostrTargetPrefix(target) {
|
|
1622
|
+
return target.trim().replace(/^nostr:/i, "");
|
|
1623
|
+
}
|
|
1624
|
+
function normalizeNostrTarget(target) {
|
|
1625
|
+
const cleaned = stripNostrTargetPrefix(target);
|
|
1626
|
+
try {
|
|
1627
|
+
return normalizePubkey(cleaned);
|
|
1628
|
+
} catch {
|
|
1629
|
+
return target.trim();
|
|
1630
|
+
}
|
|
1631
|
+
}
|
|
1256
1632
|
const resolveNostrDmPolicy = createScopedDmSecurityResolver({
|
|
1257
1633
|
channelKey: "nostr",
|
|
1258
1634
|
resolvePolicy: (account) => account.config.dmPolicy,
|
|
@@ -1260,13 +1636,7 @@ const resolveNostrDmPolicy = createScopedDmSecurityResolver({
|
|
|
1260
1636
|
policyPathSuffix: "dmPolicy",
|
|
1261
1637
|
defaultPolicy: "pairing",
|
|
1262
1638
|
approveHint: formatPairingApproveHint("nostr"),
|
|
1263
|
-
normalizeEntry:
|
|
1264
|
-
try {
|
|
1265
|
-
return normalizePubkey(raw.trim().replace(/^nostr:/i, ""));
|
|
1266
|
-
} catch {
|
|
1267
|
-
return raw.trim();
|
|
1268
|
-
}
|
|
1269
|
-
}
|
|
1639
|
+
normalizeEntry: normalizeNostrTarget
|
|
1270
1640
|
});
|
|
1271
1641
|
const nostrConfigAdapter = createTopLevelChannelConfigAdapter({
|
|
1272
1642
|
sectionKey: "nostr",
|
|
@@ -1286,17 +1656,35 @@ const nostrConfigAdapter = createTopLevelChannelConfigAdapter({
|
|
|
1286
1656
|
resolveAllowFrom: (account) => account.config.allowFrom,
|
|
1287
1657
|
formatAllowFrom: (allowFrom) => normalizeStringEntries(allowFrom).map((entry) => {
|
|
1288
1658
|
if (entry === "*") return "*";
|
|
1289
|
-
|
|
1290
|
-
return normalizePubkey(entry);
|
|
1291
|
-
} catch {
|
|
1292
|
-
return entry;
|
|
1293
|
-
}
|
|
1659
|
+
return normalizeNostrTarget(entry);
|
|
1294
1660
|
}).filter(Boolean)
|
|
1295
1661
|
});
|
|
1296
1662
|
const nostrMessageAdapter = createChannelMessageAdapterFromOutbound({
|
|
1297
1663
|
id: "nostr",
|
|
1298
1664
|
outbound: nostrOutboundAdapter
|
|
1299
1665
|
});
|
|
1666
|
+
const nostrPluginOutboundAdapter = {
|
|
1667
|
+
...nostrOutboundAdapter,
|
|
1668
|
+
resolveTarget: ({ to }) => {
|
|
1669
|
+
const trimmed = to?.trim() ?? "";
|
|
1670
|
+
if (!trimmed) return {
|
|
1671
|
+
ok: false,
|
|
1672
|
+
error: missingTargetError("Nostr", NOSTR_TARGET_HINT)
|
|
1673
|
+
};
|
|
1674
|
+
const normalized = normalizeNostrTarget(trimmed);
|
|
1675
|
+
try {
|
|
1676
|
+
return {
|
|
1677
|
+
ok: true,
|
|
1678
|
+
to: normalizePubkey(normalized)
|
|
1679
|
+
};
|
|
1680
|
+
} catch {
|
|
1681
|
+
return {
|
|
1682
|
+
ok: false,
|
|
1683
|
+
error: /* @__PURE__ */ new Error("Nostr target must be a 64-character hex pubkey or npub value")
|
|
1684
|
+
};
|
|
1685
|
+
}
|
|
1686
|
+
}
|
|
1687
|
+
};
|
|
1300
1688
|
const nostrPlugin = createChatChannelPlugin({
|
|
1301
1689
|
base: {
|
|
1302
1690
|
id: "nostr",
|
|
@@ -1316,6 +1704,7 @@ const nostrPlugin = createChatChannelPlugin({
|
|
|
1316
1704
|
reload: { configPrefixes: ["channels.nostr"] },
|
|
1317
1705
|
configSchema: buildChannelConfigSchema(NostrConfigSchema),
|
|
1318
1706
|
setup: nostrSetupAdapter,
|
|
1707
|
+
setupContract: nostrSetupContract,
|
|
1319
1708
|
setupWizard: nostrSetupWizard,
|
|
1320
1709
|
config: {
|
|
1321
1710
|
...nostrConfigAdapter,
|
|
@@ -1328,20 +1717,13 @@ const nostrPlugin = createChatChannelPlugin({
|
|
|
1328
1717
|
},
|
|
1329
1718
|
messaging: {
|
|
1330
1719
|
targetPrefixes: ["nostr"],
|
|
1331
|
-
normalizeTarget:
|
|
1332
|
-
const cleaned = target.trim().replace(/^nostr:/i, "");
|
|
1333
|
-
try {
|
|
1334
|
-
return normalizePubkey(cleaned);
|
|
1335
|
-
} catch {
|
|
1336
|
-
return cleaned;
|
|
1337
|
-
}
|
|
1338
|
-
},
|
|
1720
|
+
normalizeTarget: normalizeNostrTarget,
|
|
1339
1721
|
targetResolver: {
|
|
1340
|
-
looksLikeId: (input) => {
|
|
1341
|
-
const trimmed =
|
|
1342
|
-
return trimmed.startsWith("npub1") || /^[0-9a-fA-F]{64}$/.test(trimmed);
|
|
1722
|
+
looksLikeId: (input, normalized) => {
|
|
1723
|
+
const trimmed = normalized?.trim() || stripNostrTargetPrefix(input);
|
|
1724
|
+
return trimmed.startsWith("npub1") || trimmed.startsWith("NPUB1") || /^[0-9a-fA-F]{64}$/.test(trimmed);
|
|
1343
1725
|
},
|
|
1344
|
-
hint:
|
|
1726
|
+
hint: NOSTR_TARGET_HINT
|
|
1345
1727
|
},
|
|
1346
1728
|
resolveOutboundSessionRoute: (params) => resolveNostrOutboundSessionRoute(params)
|
|
1347
1729
|
},
|
|
@@ -1366,7 +1748,7 @@ const nostrPlugin = createChatChannelPlugin({
|
|
|
1366
1748
|
},
|
|
1367
1749
|
pairing: { text: nostrPairingTextAdapter },
|
|
1368
1750
|
security: { resolveDmPolicy: resolveNostrDmPolicy },
|
|
1369
|
-
outbound:
|
|
1751
|
+
outbound: nostrPluginOutboundAdapter
|
|
1370
1752
|
});
|
|
1371
1753
|
/**
|
|
1372
1754
|
* Publish a profile (kind:0) for a Nostr account.
|
|
@@ -1392,4 +1774,4 @@ async function getNostrProfileState(accountId = DEFAULT_ACCOUNT_ID) {
|
|
|
1392
1774
|
return bus.getProfileState();
|
|
1393
1775
|
}
|
|
1394
1776
|
//#endregion
|
|
1395
|
-
export {
|
|
1777
|
+
export { getNostrRuntime as a, contentToProfile as i, nostrPlugin as n, setNostrRuntime as o, publishNostrProfile as r, getNostrProfileState as t };
|