@openclaw/nostr 2026.7.2-beta.3 → 2026.7.2-beta.5

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.
Files changed (44) hide show
  1. package/dist/api.js +12 -4
  2. package/dist/{channel-DgBHZ53s.js → channel-DNeI-LB-.js} +785 -412
  3. package/dist/channel-plugin-api.js +1 -1
  4. package/dist/{setup-adapter-DEU3o8MF.js → setup-adapter-BjKmSwdW.js} +31 -1
  5. package/dist/setup-api.js +1 -1
  6. package/dist/setup-plugin-api.js +4 -2
  7. package/dist/{setup-surface-BF7_7BEx.js → setup-surface-BJCFpe02.js} +12 -26
  8. package/node_modules/nostr-tools/lib/cjs/abstract-pool.js +35 -3
  9. package/node_modules/nostr-tools/lib/cjs/abstract-pool.js.map +2 -2
  10. package/node_modules/nostr-tools/lib/cjs/abstract-relay.js +30 -2
  11. package/node_modules/nostr-tools/lib/cjs/abstract-relay.js.map +2 -2
  12. package/node_modules/nostr-tools/lib/cjs/index.js +35 -3
  13. package/node_modules/nostr-tools/lib/cjs/index.js.map +2 -2
  14. package/node_modules/nostr-tools/lib/cjs/nip46.js +35 -3
  15. package/node_modules/nostr-tools/lib/cjs/nip46.js.map +2 -2
  16. package/node_modules/nostr-tools/lib/cjs/nipb0.js +4 -1
  17. package/node_modules/nostr-tools/lib/cjs/nipb0.js.map +2 -2
  18. package/node_modules/nostr-tools/lib/cjs/pool.js +35 -3
  19. package/node_modules/nostr-tools/lib/cjs/pool.js.map +2 -2
  20. package/node_modules/nostr-tools/lib/cjs/relay.js +30 -2
  21. package/node_modules/nostr-tools/lib/cjs/relay.js.map +2 -2
  22. package/node_modules/nostr-tools/lib/esm/abstract-pool.js +35 -3
  23. package/node_modules/nostr-tools/lib/esm/abstract-pool.js.map +2 -2
  24. package/node_modules/nostr-tools/lib/esm/abstract-relay.js +30 -2
  25. package/node_modules/nostr-tools/lib/esm/abstract-relay.js.map +2 -2
  26. package/node_modules/nostr-tools/lib/esm/index.js +35 -3
  27. package/node_modules/nostr-tools/lib/esm/index.js.map +2 -2
  28. package/node_modules/nostr-tools/lib/esm/nip46.js +35 -3
  29. package/node_modules/nostr-tools/lib/esm/nip46.js.map +2 -2
  30. package/node_modules/nostr-tools/lib/esm/nipb0.js +4 -1
  31. package/node_modules/nostr-tools/lib/esm/nipb0.js.map +2 -2
  32. package/node_modules/nostr-tools/lib/esm/pool.js +35 -3
  33. package/node_modules/nostr-tools/lib/esm/pool.js.map +2 -2
  34. package/node_modules/nostr-tools/lib/esm/relay.js +30 -2
  35. package/node_modules/nostr-tools/lib/esm/relay.js.map +2 -2
  36. package/node_modules/nostr-tools/lib/nostr.bundle.js +35 -3
  37. package/node_modules/nostr-tools/lib/nostr.bundle.js.map +2 -2
  38. package/node_modules/nostr-tools/lib/types/abstract-pool.d.ts +1 -0
  39. package/node_modules/nostr-tools/lib/types/abstract-relay.d.ts +5 -0
  40. package/node_modules/nostr-tools/lib/types/relay.d.ts +2 -2
  41. package/node_modules/nostr-tools/package.json +1 -1
  42. package/openclaw.plugin.json +0 -5
  43. package/package.json +41 -16
  44. package/npm-shrinkwrap.json +0 -137
@@ -1,23 +1,24 @@
1
- import { a as resolveDefaultNostrAccountId, c as validatePrivateKey, i as listNostrAccountIds, n as nostrSetupWizard, o as resolveNostrAccount, s as normalizePubkey, t as nostrSetupAdapter } from "./setup-surface-BF7_7BEx.js";
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-BJCFpe02.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 { i as DEFAULT_RELAYS } from "./setup-adapter-DEU3o8MF.js";
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 { createChannelMessageAdapterFromOutbound } from "openclaw/plugin-sdk/channel-outbound";
9
- import { buildPassiveChannelStatusSummary, buildTrafficStatusSummary, runStoppablePassiveMonitor } from "openclaw/plugin-sdk/extension-shared";
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.
@@ -367,11 +757,111 @@ async function publishProfile(pool, sk, relays, profile, lastPublishedAt) {
367
757
  return publishProfileEvent(pool, relays, createProfileEvent(sk, profile, lastPublishedAt));
368
758
  }
369
759
  //#endregion
370
- //#region extensions/nostr/src/runtime.ts
371
- const { setRuntime: setNostrRuntime, getRuntime: getNostrRuntime } = createPluginRuntimeStore({
372
- pluginId: "nostr",
373
- errorMessage: "Nostr runtime not initialized"
374
- });
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
+ }
375
865
  //#endregion
376
866
  //#region extensions/nostr/src/nostr-state-store.ts
377
867
  const STORE_VERSION = 2;
@@ -426,210 +916,15 @@ async function writeNostrProfileState(params) {
426
916
  await openNostrProfileStateStore(params.env).register(normalizeNostrStateAccountId(params.accountId), payload);
427
917
  }
428
918
  //#endregion
429
- //#region extensions/nostr/src/seen-tracker.ts
430
- /**
431
- * LRU-based seen event tracker with TTL support.
432
- * Prevents unbounded memory growth under high load or abuse.
433
- */
434
- /**
435
- * Create a new seen tracker with LRU eviction and TTL expiration.
436
- */
437
- function createSeenTracker(options) {
438
- const maxEntries = resolveIntegerOption(options?.maxEntries, 1e5, { min: 1 });
439
- const ttlMs = resolvePositiveTimerTimeoutMs(options?.ttlMs, 3600 * 1e3);
440
- const pruneIntervalMs = resolvePositiveTimerTimeoutMs(options?.pruneIntervalMs, 600 * 1e3);
441
- const entries = /* @__PURE__ */ new Map();
442
- let head = null;
443
- let tail = null;
444
- function moveToFront(id) {
445
- const entry = entries.get(id);
446
- if (!entry) return;
447
- if (head === id) return;
448
- if (entry.prev) {
449
- const prevEntry = entries.get(entry.prev);
450
- if (prevEntry) prevEntry.next = entry.next;
451
- }
452
- if (entry.next) {
453
- const nextEntry = entries.get(entry.next);
454
- if (nextEntry) nextEntry.prev = entry.prev;
455
- }
456
- if (tail === id) tail = entry.prev;
457
- entry.prev = null;
458
- entry.next = head;
459
- if (head) {
460
- const headEntry = entries.get(head);
461
- if (headEntry) headEntry.prev = id;
462
- }
463
- head = id;
464
- if (!tail) tail = id;
465
- }
466
- function removeFromList(id) {
467
- const entry = entries.get(id);
468
- if (!entry) return;
469
- if (entry.prev) {
470
- const prevEntry = entries.get(entry.prev);
471
- if (prevEntry) prevEntry.next = entry.next;
472
- } else head = entry.next;
473
- if (entry.next) {
474
- const nextEntry = entries.get(entry.next);
475
- if (nextEntry) nextEntry.prev = entry.prev;
476
- } else tail = entry.prev;
477
- }
478
- function evictLRU() {
479
- if (!tail) return;
480
- const idToEvict = tail;
481
- removeFromList(idToEvict);
482
- entries.delete(idToEvict);
483
- }
484
- function insertAtFront(id, seenAt) {
485
- const newEntry = {
486
- seenAt,
487
- prev: null,
488
- next: head
489
- };
490
- if (head) {
491
- const headEntry = entries.get(head);
492
- if (headEntry) headEntry.prev = id;
493
- }
494
- entries.set(id, newEntry);
495
- head = id;
496
- if (!tail) tail = id;
497
- }
498
- function pruneExpired() {
499
- const now = Date.now();
500
- const toDelete = [];
501
- for (const [id, entry] of entries) if (now - entry.seenAt > ttlMs) toDelete.push(id);
502
- for (const id of toDelete) {
503
- removeFromList(id);
504
- entries.delete(id);
505
- }
506
- }
507
- let pruneTimer;
508
- if (pruneIntervalMs > 0) {
509
- pruneTimer = setInterval(pruneExpired, pruneIntervalMs);
510
- if (pruneTimer.unref) pruneTimer.unref();
511
- }
512
- function add(id) {
513
- const now = Date.now();
514
- const existing = entries.get(id);
515
- if (existing) {
516
- existing.seenAt = now;
517
- moveToFront(id);
518
- return;
519
- }
520
- while (entries.size >= maxEntries) evictLRU();
521
- insertAtFront(id, now);
522
- }
523
- function has(id) {
524
- const entry = entries.get(id);
525
- if (!entry) {
526
- add(id);
527
- return false;
528
- }
529
- if (Date.now() - entry.seenAt > ttlMs) {
530
- removeFromList(id);
531
- entries.delete(id);
532
- add(id);
533
- return false;
534
- }
535
- entry.seenAt = Date.now();
536
- moveToFront(id);
537
- return true;
538
- }
539
- function peek(id) {
540
- const entry = entries.get(id);
541
- if (!entry) return false;
542
- if (Date.now() - entry.seenAt > ttlMs) {
543
- removeFromList(id);
544
- entries.delete(id);
545
- return false;
546
- }
547
- return true;
548
- }
549
- function deleteEntry(id) {
550
- if (entries.has(id)) {
551
- removeFromList(id);
552
- entries.delete(id);
553
- }
554
- }
555
- function clear() {
556
- entries.clear();
557
- head = null;
558
- tail = null;
559
- }
560
- function size() {
561
- return entries.size;
562
- }
563
- function stop() {
564
- if (pruneTimer) {
565
- clearInterval(pruneTimer);
566
- pruneTimer = void 0;
567
- }
568
- }
569
- function seed(ids) {
570
- const now = Date.now();
571
- for (const id of ids.toReversed()) if (!entries.has(id) && entries.size < maxEntries) insertAtFront(id, now);
572
- }
573
- return {
574
- has,
575
- add,
576
- peek,
577
- delete: deleteEntry,
578
- clear,
579
- size,
580
- stop,
581
- seed
582
- };
583
- }
584
- //#endregion
585
919
  //#region extensions/nostr/src/nostr-bus.ts
586
920
  const STARTUP_LOOKBACK_SEC = 120;
587
- const MAX_PERSISTED_EVENT_IDS = 5e3;
588
921
  const STATE_PERSIST_DEBOUNCE_MS = 5e3;
922
+ const NOSTR_INGRESS_ENVELOPE_OVERHEAD_BYTES = 16 * 1024;
923
+ const NOSTR_INGRESS_MAX_PENDING_EVENTS = 1e3;
589
924
  const DEFAULT_INBOUND_GUARD_POLICY = createDirectDmPreCryptoGuardPolicy();
590
925
  const CIRCUIT_BREAKER_THRESHOLD = 5;
591
926
  const CIRCUIT_BREAKER_RESET_MS = 3e4;
592
927
  const HEALTH_WINDOW_MS = 6e4;
593
- function createFixedWindowRateLimiter(params) {
594
- const windowMs = Math.max(1, Math.floor(params.windowMs));
595
- const maxRequests = Math.max(1, Math.floor(params.maxRequests));
596
- const maxTrackedKeys = Math.max(1, Math.floor(params.maxTrackedKeys));
597
- const state = /* @__PURE__ */ new Map();
598
- const touch = (key, value) => {
599
- state.delete(key);
600
- state.set(key, value);
601
- };
602
- const prune = (nowMs) => {
603
- for (const [key, entry] of state) if (nowMs - entry.windowStartMs >= windowMs) state.delete(key);
604
- while (state.size > maxTrackedKeys) {
605
- const oldest = state.keys().next().value;
606
- if (!oldest) break;
607
- state.delete(oldest);
608
- }
609
- };
610
- return {
611
- isRateLimited: (key, nowMs = Date.now()) => {
612
- if (!key) return false;
613
- prune(nowMs);
614
- const existing = state.get(key);
615
- if (!existing || nowMs - existing.windowStartMs >= windowMs) {
616
- touch(key, {
617
- count: 1,
618
- windowStartMs: nowMs
619
- });
620
- return false;
621
- }
622
- const nextCount = existing.count + 1;
623
- touch(key, {
624
- count: nextCount,
625
- windowStartMs: existing.windowStartMs
626
- });
627
- return nextCount > maxRequests;
628
- },
629
- size: () => state.size,
630
- clear: () => state.clear()
631
- };
632
- }
633
928
  function createCircuitBreaker(relay, metrics, threshold = CIRCUIT_BREAKER_THRESHOLD, resetMs = CIRCUIT_BREAKER_RESET_MS) {
634
929
  const state = {
635
930
  state: "closed",
@@ -725,10 +1020,11 @@ function createRelayHealthTracker() {
725
1020
  * Start the Nostr DM bus - subscribes to NIP-04 encrypted DMs
726
1021
  */
727
1022
  async function startNostrBus(options) {
728
- const { privateKey, relays = DEFAULT_RELAYS, onMessage, authorizeSender, onError, onEose, onMetric, maxSeenEntries = 1e5, seenTtlMs = 3600 * 1e3 } = options;
1023
+ const { privateKey, relays = DEFAULT_RELAYS, onMessage, authorizeSender, onError, onEose, onMetric } = options;
729
1024
  const sk = validatePrivateKey(privateKey);
730
1025
  const pk = getPublicKey(sk);
731
1026
  const pool = new SimplePool();
1027
+ pool.onRelayConnectionSuccess = options.onConnect;
732
1028
  const accountId = options.accountId ?? pk.slice(0, 16);
733
1029
  const gatewayStartedAt = Math.floor(Date.now() / 1e3);
734
1030
  const guardPolicy = createDirectDmPreCryptoGuardPolicy({
@@ -740,41 +1036,32 @@ async function startNostrBus(options) {
740
1036
  }
741
1037
  });
742
1038
  const metrics = onMetric ? createMetrics(onMetric) : createNoopMetrics();
743
- const seen = createSeenTracker({
744
- maxEntries: maxSeenEntries,
745
- ttlMs: seenTtlMs
746
- });
747
1039
  const circuitBreakers = /* @__PURE__ */ new Map();
748
1040
  const healthTracker = createRelayHealthTracker();
749
1041
  for (const relay of relays) circuitBreakers.set(relay, createCircuitBreaker(relay, metrics));
750
1042
  const state = await readNostrBusState({ accountId });
751
1043
  const baseSince = computeSinceTimestamp(state, gatewayStartedAt);
752
1044
  const since = Math.max(0, baseSince - STARTUP_LOOKBACK_SEC);
753
- if (state?.recentEventIds?.length) seen.seed(state.recentEventIds);
754
- await writeNostrBusState({
755
- accountId,
756
- lastProcessedAt: state?.lastProcessedAt ?? gatewayStartedAt,
757
- gatewayStartedAt,
758
- recentEventIds: state?.recentEventIds ?? []
759
- });
760
- let pendingWrite;
761
- let lastProcessedAt = state?.lastProcessedAt ?? gatewayStartedAt;
762
- let recentEventIds = (state?.recentEventIds ?? []).slice(-5e3);
763
- function scheduleStatePersist(eventCreatedAt, eventId) {
764
- lastProcessedAt = Math.max(lastProcessedAt, eventCreatedAt);
765
- recentEventIds.push(eventId);
766
- if (recentEventIds.length > MAX_PERSISTED_EVENT_IDS) recentEventIds = recentEventIds.slice(-5e3);
767
- if (pendingWrite) clearTimeout(pendingWrite);
768
- pendingWrite = setTimeout(() => {
769
- 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({
770
1053
  accountId,
771
- lastProcessedAt,
772
- gatewayStartedAt,
773
- recentEventIds
774
- }).catch((err) => onError?.(err, "persist state"));
775
- }, STATE_PERSIST_DEBOUNCE_MS);
776
- }
777
- const inflight = /* @__PURE__ */ new Set();
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
+ });
778
1065
  const perSenderRateLimiter = createFixedWindowRateLimiter({
779
1066
  windowMs: guardPolicy.rateLimit.windowMs,
780
1067
  maxRequests: guardPolicy.rateLimit.maxPerSenderPerWindow,
@@ -788,145 +1075,215 @@ async function startNostrBus(options) {
788
1075
  const updateRateLimiterSizeMetric = () => {
789
1076
  metrics.emit("memory.rate_limiter_entries", perSenderRateLimiter.size() + globalRateLimiter.size());
790
1077
  };
791
- async function handleEvent(event) {
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;
792
1145
  try {
793
- metrics.emit("event.received");
794
- if (seen.peek(event.id) || inflight.has(event.id)) {
795
- metrics.emit("event.duplicate");
796
- return;
797
- }
798
- inflight.add(event.id);
799
- const markSeen = () => {
800
- seen.add(event.id);
801
- metrics.emit("memory.seen_tracker_size", seen.size());
802
- };
803
- const rejectAndMarkSeen = (metric) => {
804
- markSeen();
805
- metrics.emit(metric);
806
- };
807
- if (event.pubkey === pk) {
808
- rejectAndMarkSeen("event.rejected.self_message");
809
- return;
810
- }
811
- if (event.created_at < since) {
812
- rejectAndMarkSeen("event.rejected.stale");
813
- return;
814
- }
815
- if (event.created_at > Math.floor(Date.now() / 1e3) + guardPolicy.maxFutureSkewSec) {
816
- metrics.emit("event.rejected.future");
817
- return;
818
- }
819
- if (!guardPolicy.allowedKinds.includes(event.kind)) {
820
- rejectAndMarkSeen("event.rejected.wrong_kind");
821
- return;
822
- }
823
- let targetsUs = false;
824
- for (const t of event.tags) if (t[0] === "p" && t[1] === pk) {
825
- targetsUs = true;
826
- break;
827
- }
828
- if (!targetsUs) {
829
- rejectAndMarkSeen("event.rejected.wrong_kind");
830
- return;
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
+ }
831
1186
  }
832
- const replyTo = async (text) => {
833
- await sendEncryptedDm(pool, sk, event.pubkey, text, relays, metrics, circuitBreakers, healthTracker, onError, event.id);
834
- };
835
- const rejectIfGlobalRateLimited = () => {
836
- updateRateLimiterSizeMetric();
837
- if (globalRateLimiter.isRateLimited("global")) {
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") {
838
1226
  metrics.emit("rate_limit.global");
839
1227
  metrics.emit("event.rejected.rate_limited");
840
- updateRateLimiterSizeMetric();
841
- return true;
842
1228
  }
843
- updateRateLimiterSizeMetric();
844
- return false;
845
- };
846
- const rejectIfVerifiedSenderRateLimited = () => {
847
- updateRateLimiterSizeMetric();
848
- if (perSenderRateLimiter.isRateLimited(event.pubkey)) {
849
- metrics.emit("rate_limit.per_sender");
850
- metrics.emit("event.rejected.rate_limited");
851
- updateRateLimiterSizeMetric();
852
- 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();
853
1235
  }
854
- updateRateLimiterSizeMetric();
855
- return false;
856
- };
857
- if (Buffer.byteLength(event.content, "utf8") > guardPolicy.maxCiphertextBytes) {
858
- if (rejectIfGlobalRateLimited()) return;
859
- rejectAndMarkSeen("event.rejected.oversized_ciphertext");
860
1236
  return;
861
1237
  }
862
- if (rejectIfGlobalRateLimited()) return;
863
- if (!verifyEvent(event)) {
864
- rejectAndMarkSeen("event.rejected.invalid_signature");
865
- onError?.(/* @__PURE__ */ new Error("Invalid signature"), `event ${event.id}`);
866
- return;
867
- }
868
- if (rejectIfVerifiedSenderRateLimited()) return;
869
- if (authorizeSender) {
870
- if (await authorizeSender({
871
- senderPubkey: event.pubkey,
872
- reply: replyTo
873
- }) !== "allow") {
874
- markSeen();
875
- return;
876
- }
877
- }
878
- let plaintext;
1238
+ if (error instanceof NostrIngressPermanentError) return;
1239
+ let cursorPersistenceFailed = false;
879
1240
  try {
880
- plaintext = decrypt(sk, event.pubkey, event.content);
881
- metrics.emit("decrypt.success");
882
- } catch (err) {
883
- markSeen();
884
- metrics.emit("decrypt.failure");
885
- metrics.emit("event.rejected.decrypt_failed");
886
- onError?.(err, `decrypt from ${event.pubkey}`);
887
- return;
888
- }
889
- if (Buffer.byteLength(plaintext, "utf8") > guardPolicy.maxPlaintextBytes) {
890
- markSeen();
891
- metrics.emit("event.rejected.oversized_plaintext");
892
- return;
1241
+ await persistTransientReplayCursor(event);
1242
+ } catch (cursorError) {
1243
+ onError?.(cursorError, "persist transient replay cursor");
1244
+ cursorPersistenceFailed = true;
893
1245
  }
894
- await onMessage(event.pubkey, plaintext, replyTo, {
895
- eventId: event.id,
896
- createdAt: event.created_at
897
- });
898
- markSeen();
899
- metrics.emit("event.processed");
900
- scheduleStatePersist(event.created_at, event.id);
901
- } catch (err) {
902
- onError?.(err, `event ${event.id}`);
903
- } finally {
904
- inflight.delete(event.id);
1246
+ await stopRelays("durable admission failed");
1247
+ if (cursorPersistenceFailed) await recoverCursorPersistence();
905
1248
  }
906
- }
907
- const dmFilter = {
908
- kinds: [4],
909
- "#p": [pk],
910
- since
911
1249
  };
912
- const relayAbort = new AbortController();
913
- const sub = pool.subscribeMany(relays, dmFilter, {
914
- onevent: (event) => {
915
- handleEvent(event);
916
- },
917
- oneose: () => {
918
- for (const relay of relays) metrics.emit("relay.message.eose", 1, { relay });
919
- onEose?.(relays.join(", "));
920
- },
921
- onclose: (reason) => {
922
- for (const relay of relays) {
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) => {
923
1277
  metrics.emit("relay.message.closed", 1, { relay });
924
1278
  options.onDisconnect?.(relay);
1279
+ onError?.(/* @__PURE__ */ new Error(`Subscription closed: ${reasons.join(", ")}`), "subscription");
925
1280
  }
926
- onError?.(/* @__PURE__ */ new Error(`Subscription closed: ${reason.join(", ")}`), "subscription");
927
- },
928
- abort: relayAbort.signal
929
- });
1281
+ });
1282
+ relaySubscriptions.start();
1283
+ } catch (error) {
1284
+ await Promise.allSettled([stopRelays("startup failed"), ingress.stop()]);
1285
+ throw error;
1286
+ }
930
1287
  const sendDm = async (toPubkey, text) => {
931
1288
  return await sendEncryptedDm(pool, sk, toPubkey, text, relays, metrics, circuitBreakers, healthTracker, onError);
932
1289
  };
@@ -952,25 +1309,20 @@ async function startNostrBus(options) {
952
1309
  lastPublishResults: stateLocal?.lastPublishResults ?? null
953
1310
  };
954
1311
  };
955
- return {
956
- close: () => {
957
- relayAbort.abort("closed by caller");
958
- Promise.resolve(sub.close("closed by caller")).catch((err) => onError?.(err, "close subscription")).finally(() => {
959
- pool.close(relays);
960
- });
961
- seen.stop();
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();
962
1319
  perSenderRateLimiter.clear();
963
1320
  globalRateLimiter.clear();
964
- if (pendingWrite) {
965
- clearTimeout(pendingWrite);
966
- writeNostrBusState({
967
- accountId,
968
- lastProcessedAt,
969
- gatewayStartedAt,
970
- recentEventIds
971
- }).catch((err) => onError?.(err, "persist state on close"));
972
- }
973
- },
1321
+ })();
1322
+ return closePromise;
1323
+ };
1324
+ return {
1325
+ close,
974
1326
  publicKey: pk,
975
1327
  sendDm,
976
1328
  getMetrics: () => metrics.getSnapshot(),
@@ -1103,7 +1455,7 @@ const startNostrGatewayAccount = async (ctx) => {
1103
1455
  ctx.log?.debug?.(`[${account.accountId}] blocked Nostr sender ${input.senderId} (${resolved.senderAccess.reasonCode})`);
1104
1456
  return "block";
1105
1457
  };
1106
- await runStoppablePassiveMonitor({
1458
+ await runPassiveAccountLifecycle({
1107
1459
  abortSignal: ctx.abortSignal,
1108
1460
  start: async () => {
1109
1461
  const bus = await startNostrBus({
@@ -1114,7 +1466,7 @@ const startNostrGatewayAccount = async (ctx) => {
1114
1466
  senderId: senderPubkey,
1115
1467
  reply
1116
1468
  }),
1117
- onMessage: async (senderPubkey, text, reply, meta) => {
1469
+ onMessage: async (senderPubkey, text, reply, meta, lifecycle) => {
1118
1470
  const resolvedAccess = await resolveInboundAccess(senderPubkey, text);
1119
1471
  if (resolvedAccess.senderAccess.decision !== "allow") {
1120
1472
  ctx.log?.warn?.(`[${account.accountId}] dropping Nostr DM after preflight drift (${senderPubkey}, ${resolvedAccess.senderAccess.reasonCode})`);
@@ -1138,6 +1490,7 @@ const startNostrGatewayAccount = async (ctx) => {
1138
1490
  messageId: meta.eventId,
1139
1491
  timestamp: meta.createdAt * 1e3,
1140
1492
  commandAuthorized: resolvedAccess.commandAccess.requested ? resolvedAccess.commandAccess.authorized : void 0,
1493
+ turnAdoptionLifecycle: bindIngressLifecycleToReplyOptions(lifecycle).turnAdoptionLifecycle,
1141
1494
  deliver: async (payload) => {
1142
1495
  const outboundText = payload && typeof payload === "object" && "text" in payload ? payload.text ?? "" : "";
1143
1496
  if (!outboundText.trim()) return;
@@ -1146,7 +1499,8 @@ const startNostrGatewayAccount = async (ctx) => {
1146
1499
  channel: "nostr",
1147
1500
  accountId: account.accountId
1148
1501
  });
1149
- await reply(runtime.channel.text.convertMarkdownTables(outboundText, tableMode));
1502
+ const message = stripMarkdown(runtime.channel.text.convertMarkdownTables(outboundText, tableMode));
1503
+ if (message) await reply(message);
1150
1504
  },
1151
1505
  onRecordError: (err) => {
1152
1506
  ctx.log?.error?.(`[${account.accountId}] failed recording Nostr inbound session: ${String(err)}`);
@@ -1176,19 +1530,19 @@ const startNostrGatewayAccount = async (ctx) => {
1176
1530
  if (busHandle) metricsSnapshots.set(account.accountId, busHandle.getMetrics());
1177
1531
  }
1178
1532
  });
1179
- let stopped = false;
1180
1533
  busHandle = bus;
1181
1534
  activeBuses.set(account.accountId, bus);
1182
- ctx.log?.info?.(`[${account.accountId}] Nostr provider started, connected to ${account.relays.length} relay(s)`);
1183
- return { stop: () => {
1184
- if (stopped) return;
1185
- stopped = true;
1186
- 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();
1187
1538
  if (busHandle === bus) busHandle = null;
1188
1539
  if (activeBuses.get(account.accountId) === bus) activeBuses.delete(account.accountId);
1189
1540
  metricsSnapshots.delete(account.accountId);
1190
1541
  ctx.log?.info?.(`[${account.accountId}] Nostr provider stopped`);
1191
1542
  } };
1543
+ },
1544
+ stop: async (monitor) => {
1545
+ await monitor.stop();
1192
1546
  }
1193
1547
  });
1194
1548
  };
@@ -1224,7 +1578,8 @@ const nostrOutboundAdapter = {
1224
1578
  channel: "nostr",
1225
1579
  accountId: aid
1226
1580
  });
1227
- 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.");
1228
1583
  const normalizedTo = normalizePubkey(to);
1229
1584
  return attachChannelToResult("nostr", {
1230
1585
  to: normalizedTo,
@@ -1262,6 +1617,18 @@ function resolveNostrOutboundSessionRoute(params) {
1262
1617
  }
1263
1618
  //#endregion
1264
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
+ }
1265
1632
  const resolveNostrDmPolicy = createScopedDmSecurityResolver({
1266
1633
  channelKey: "nostr",
1267
1634
  resolvePolicy: (account) => account.config.dmPolicy,
@@ -1269,13 +1636,7 @@ const resolveNostrDmPolicy = createScopedDmSecurityResolver({
1269
1636
  policyPathSuffix: "dmPolicy",
1270
1637
  defaultPolicy: "pairing",
1271
1638
  approveHint: formatPairingApproveHint("nostr"),
1272
- normalizeEntry: (raw) => {
1273
- try {
1274
- return normalizePubkey(raw.trim().replace(/^nostr:/i, ""));
1275
- } catch {
1276
- return raw.trim();
1277
- }
1278
- }
1639
+ normalizeEntry: normalizeNostrTarget
1279
1640
  });
1280
1641
  const nostrConfigAdapter = createTopLevelChannelConfigAdapter({
1281
1642
  sectionKey: "nostr",
@@ -1295,17 +1656,35 @@ const nostrConfigAdapter = createTopLevelChannelConfigAdapter({
1295
1656
  resolveAllowFrom: (account) => account.config.allowFrom,
1296
1657
  formatAllowFrom: (allowFrom) => normalizeStringEntries(allowFrom).map((entry) => {
1297
1658
  if (entry === "*") return "*";
1298
- try {
1299
- return normalizePubkey(entry);
1300
- } catch {
1301
- return entry;
1302
- }
1659
+ return normalizeNostrTarget(entry);
1303
1660
  }).filter(Boolean)
1304
1661
  });
1305
1662
  const nostrMessageAdapter = createChannelMessageAdapterFromOutbound({
1306
1663
  id: "nostr",
1307
1664
  outbound: nostrOutboundAdapter
1308
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
+ };
1309
1688
  const nostrPlugin = createChatChannelPlugin({
1310
1689
  base: {
1311
1690
  id: "nostr",
@@ -1325,6 +1704,7 @@ const nostrPlugin = createChatChannelPlugin({
1325
1704
  reload: { configPrefixes: ["channels.nostr"] },
1326
1705
  configSchema: buildChannelConfigSchema(NostrConfigSchema),
1327
1706
  setup: nostrSetupAdapter,
1707
+ setupContract: nostrSetupContract,
1328
1708
  setupWizard: nostrSetupWizard,
1329
1709
  config: {
1330
1710
  ...nostrConfigAdapter,
@@ -1337,20 +1717,13 @@ const nostrPlugin = createChatChannelPlugin({
1337
1717
  },
1338
1718
  messaging: {
1339
1719
  targetPrefixes: ["nostr"],
1340
- normalizeTarget: (target) => {
1341
- const cleaned = target.trim().replace(/^nostr:/i, "");
1342
- try {
1343
- return normalizePubkey(cleaned);
1344
- } catch {
1345
- return cleaned;
1346
- }
1347
- },
1720
+ normalizeTarget: normalizeNostrTarget,
1348
1721
  targetResolver: {
1349
- looksLikeId: (input) => {
1350
- const trimmed = input.trim();
1722
+ looksLikeId: (input, normalized) => {
1723
+ const trimmed = normalized?.trim() || stripNostrTargetPrefix(input);
1351
1724
  return trimmed.startsWith("npub1") || trimmed.startsWith("NPUB1") || /^[0-9a-fA-F]{64}$/.test(trimmed);
1352
1725
  },
1353
- hint: "<npub|hex pubkey|nostr:npub...>"
1726
+ hint: NOSTR_TARGET_HINT
1354
1727
  },
1355
1728
  resolveOutboundSessionRoute: (params) => resolveNostrOutboundSessionRoute(params)
1356
1729
  },
@@ -1375,7 +1748,7 @@ const nostrPlugin = createChatChannelPlugin({
1375
1748
  },
1376
1749
  pairing: { text: nostrPairingTextAdapter },
1377
1750
  security: { resolveDmPolicy: resolveNostrDmPolicy },
1378
- outbound: nostrOutboundAdapter
1751
+ outbound: nostrPluginOutboundAdapter
1379
1752
  });
1380
1753
  /**
1381
1754
  * Publish a profile (kind:0) for a Nostr account.
@@ -1401,4 +1774,4 @@ async function getNostrProfileState(accountId = DEFAULT_ACCOUNT_ID) {
1401
1774
  return bus.getProfileState();
1402
1775
  }
1403
1776
  //#endregion
1404
- export { setNostrRuntime as a, getNostrRuntime as i, nostrPlugin as n, contentToProfile as o, publishNostrProfile as r, getNostrProfileState as t };
1777
+ export { getNostrRuntime as a, contentToProfile as i, nostrPlugin as n, setNostrRuntime as o, publishNostrProfile as r, getNostrProfileState as t };