@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.
Files changed (46) hide show
  1. package/dist/api.js +20 -6
  2. package/dist/{channel-B51rExHD.js → channel-DqbdMXMg.js} +810 -428
  3. package/dist/channel-plugin-api.js +1 -1
  4. package/dist/inbound-direct-dm-runtime-DHn9NoYN.js +2 -0
  5. package/dist/{setup-adapter-DEU3o8MF.js → setup-adapter-BjKmSwdW.js} +31 -1
  6. package/dist/setup-api.js +1 -1
  7. package/dist/setup-plugin-api.js +5 -3
  8. package/dist/{setup-surface-B52P4uo2.js → setup-surface-ChtM-WSY.js} +6 -4
  9. package/node_modules/nostr-tools/lib/cjs/abstract-pool.js +35 -3
  10. package/node_modules/nostr-tools/lib/cjs/abstract-pool.js.map +2 -2
  11. package/node_modules/nostr-tools/lib/cjs/abstract-relay.js +30 -2
  12. package/node_modules/nostr-tools/lib/cjs/abstract-relay.js.map +2 -2
  13. package/node_modules/nostr-tools/lib/cjs/index.js +35 -3
  14. package/node_modules/nostr-tools/lib/cjs/index.js.map +2 -2
  15. package/node_modules/nostr-tools/lib/cjs/nip46.js +35 -3
  16. package/node_modules/nostr-tools/lib/cjs/nip46.js.map +2 -2
  17. package/node_modules/nostr-tools/lib/cjs/nipb0.js +4 -1
  18. package/node_modules/nostr-tools/lib/cjs/nipb0.js.map +2 -2
  19. package/node_modules/nostr-tools/lib/cjs/pool.js +35 -3
  20. package/node_modules/nostr-tools/lib/cjs/pool.js.map +2 -2
  21. package/node_modules/nostr-tools/lib/cjs/relay.js +30 -2
  22. package/node_modules/nostr-tools/lib/cjs/relay.js.map +2 -2
  23. package/node_modules/nostr-tools/lib/esm/abstract-pool.js +35 -3
  24. package/node_modules/nostr-tools/lib/esm/abstract-pool.js.map +2 -2
  25. package/node_modules/nostr-tools/lib/esm/abstract-relay.js +30 -2
  26. package/node_modules/nostr-tools/lib/esm/abstract-relay.js.map +2 -2
  27. package/node_modules/nostr-tools/lib/esm/index.js +35 -3
  28. package/node_modules/nostr-tools/lib/esm/index.js.map +2 -2
  29. package/node_modules/nostr-tools/lib/esm/nip46.js +35 -3
  30. package/node_modules/nostr-tools/lib/esm/nip46.js.map +2 -2
  31. package/node_modules/nostr-tools/lib/esm/nipb0.js +4 -1
  32. package/node_modules/nostr-tools/lib/esm/nipb0.js.map +2 -2
  33. package/node_modules/nostr-tools/lib/esm/pool.js +35 -3
  34. package/node_modules/nostr-tools/lib/esm/pool.js.map +2 -2
  35. package/node_modules/nostr-tools/lib/esm/relay.js +30 -2
  36. package/node_modules/nostr-tools/lib/esm/relay.js.map +2 -2
  37. package/node_modules/nostr-tools/lib/nostr.bundle.js +35 -3
  38. package/node_modules/nostr-tools/lib/nostr.bundle.js.map +2 -2
  39. package/node_modules/nostr-tools/lib/types/abstract-pool.d.ts +1 -0
  40. package/node_modules/nostr-tools/lib/types/abstract-relay.d.ts +5 -0
  41. package/node_modules/nostr-tools/lib/types/relay.d.ts +2 -2
  42. package/node_modules/nostr-tools/package.json +1 -1
  43. package/npm-shrinkwrap.json +7 -7
  44. package/openclaw.plugin.json +0 -5
  45. package/package.json +41 -15
  46. package/dist/inbound-direct-dm-runtime-EdLLVtkh.js +0 -2
@@ -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-B52P4uo2.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-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 { 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.
@@ -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([...pool.publish([relay], event), timeoutPromise]);
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/runtime.ts
361
- const { setRuntime: setNostrRuntime, getRuntime: getNostrRuntime } = createPluginRuntimeStore({
362
- pluginId: "nostr",
363
- errorMessage: "Nostr runtime not initialized"
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, maxSeenEntries = 1e5, seenTtlMs = 3600 * 1e3 } = options;
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
- if (state?.recentEventIds?.length) seen.seed(state.recentEventIds);
744
- await writeNostrBusState({
745
- accountId,
746
- lastProcessedAt: state?.lastProcessedAt ?? gatewayStartedAt,
747
- gatewayStartedAt,
748
- recentEventIds: state?.recentEventIds ?? []
749
- });
750
- let pendingWrite;
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
- }).catch((err) => onError?.(err, "persist state"));
765
- }, STATE_PERSIST_DEBOUNCE_MS);
766
- }
767
- 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
+ });
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
- 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;
782
1145
  try {
783
- metrics.emit("event.received");
784
- if (seen.peek(event.id) || inflight.has(event.id)) {
785
- metrics.emit("event.duplicate");
786
- return;
787
- }
788
- inflight.add(event.id);
789
- const markSeen = () => {
790
- seen.add(event.id);
791
- metrics.emit("memory.seen_tracker_size", seen.size());
792
- };
793
- const rejectAndMarkSeen = (metric) => {
794
- markSeen();
795
- metrics.emit(metric);
796
- };
797
- if (event.pubkey === pk) {
798
- rejectAndMarkSeen("event.rejected.self_message");
799
- return;
800
- }
801
- if (event.created_at < since) {
802
- rejectAndMarkSeen("event.rejected.stale");
803
- return;
804
- }
805
- if (event.created_at > Math.floor(Date.now() / 1e3) + guardPolicy.maxFutureSkewSec) {
806
- metrics.emit("event.rejected.future");
807
- return;
808
- }
809
- if (!guardPolicy.allowedKinds.includes(event.kind)) {
810
- rejectAndMarkSeen("event.rejected.wrong_kind");
811
- return;
812
- }
813
- let targetsUs = false;
814
- for (const t of event.tags) if (t[0] === "p" && t[1] === pk) {
815
- targetsUs = true;
816
- break;
817
- }
818
- if (!targetsUs) {
819
- rejectAndMarkSeen("event.rejected.wrong_kind");
820
- 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
+ }
821
1186
  }
822
- const replyTo = async (text) => {
823
- await sendEncryptedDm(pool, sk, event.pubkey, text, relays, metrics, circuitBreakers, healthTracker, onError);
824
- };
825
- const rejectIfGlobalRateLimited = () => {
826
- updateRateLimiterSizeMetric();
827
- 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") {
828
1226
  metrics.emit("rate_limit.global");
829
1227
  metrics.emit("event.rejected.rate_limited");
830
- updateRateLimiterSizeMetric();
831
- return true;
832
1228
  }
833
- updateRateLimiterSizeMetric();
834
- return false;
835
- };
836
- const rejectIfVerifiedSenderRateLimited = () => {
837
- updateRateLimiterSizeMetric();
838
- if (perSenderRateLimiter.isRateLimited(event.pubkey)) {
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 (rejectIfGlobalRateLimited()) return;
853
- if (!verifyEvent(event)) {
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
- plaintext = decrypt(sk, event.pubkey, event.content);
871
- metrics.emit("decrypt.success");
872
- } catch (err) {
873
- markSeen();
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 onMessage(event.pubkey, plaintext, replyTo, {
885
- eventId: event.id,
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
- const relayAbort = new AbortController();
903
- const sub = pool.subscribeMany(relays, dmFilter, {
904
- onevent: (event) => {
905
- handleEvent(event);
906
- },
907
- oneose: () => {
908
- for (const relay of relays) metrics.emit("relay.message.eose", 1, { relay });
909
- onEose?.(relays.join(", "));
910
- },
911
- onclose: (reason) => {
912
- 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) => {
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
- onError?.(/* @__PURE__ */ new Error(`Subscription closed: ${reason.join(", ")}`), "subscription");
917
- },
918
- abort: relayAbort.signal
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
- return {
946
- close: () => {
947
- relayAbort.abort("closed by caller");
948
- Promise.resolve(sub.close("closed by caller")).catch((err) => onError?.(err, "close subscription")).finally(() => {
949
- pool.close(relays);
950
- });
951
- 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();
952
1319
  perSenderRateLimiter.clear();
953
1320
  globalRateLimiter.clear();
954
- if (pendingWrite) {
955
- clearTimeout(pendingWrite);
956
- writeNostrBusState({
957
- accountId,
958
- lastProcessedAt,
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: encrypt(sk, toPubkey, text),
978
- tags: [["p", toPubkey]],
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
- const publishPromises = pool.publish([relay], reply);
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 runStoppablePassiveMonitor({
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 { dispatchInboundDirectDmWithRuntime } = await import("./inbound-direct-dm-runtime-EdLLVtkh.js");
1113
- await dispatchInboundDirectDmWithRuntime({
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
- await reply(runtime.channel.text.convertMarkdownTables(outboundText, tableMode));
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, connected to ${account.relays.length} relay(s)`);
1173
- return { stop: () => {
1174
- if (stopped) return;
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: `nostr-${Date.now()}`
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: (raw) => {
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
- try {
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: (target) => {
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 = input.trim();
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: "<npub|hex pubkey|nostr:npub...>"
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: nostrOutboundAdapter
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 { 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 };