@openclaw/nostr 2026.7.2-beta.3 → 2026.7.2-beta.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/api.js +12 -4
- package/dist/{channel-DgBHZ53s.js → channel-DqbdMXMg.js} +785 -412
- package/dist/channel-plugin-api.js +1 -1
- package/dist/{setup-adapter-DEU3o8MF.js → setup-adapter-BjKmSwdW.js} +31 -1
- package/dist/setup-api.js +1 -1
- package/dist/setup-plugin-api.js +4 -2
- package/dist/{setup-surface-BF7_7BEx.js → setup-surface-ChtM-WSY.js} +4 -2
- package/node_modules/nostr-tools/lib/cjs/abstract-pool.js +35 -3
- package/node_modules/nostr-tools/lib/cjs/abstract-pool.js.map +2 -2
- package/node_modules/nostr-tools/lib/cjs/abstract-relay.js +30 -2
- package/node_modules/nostr-tools/lib/cjs/abstract-relay.js.map +2 -2
- package/node_modules/nostr-tools/lib/cjs/index.js +35 -3
- package/node_modules/nostr-tools/lib/cjs/index.js.map +2 -2
- package/node_modules/nostr-tools/lib/cjs/nip46.js +35 -3
- package/node_modules/nostr-tools/lib/cjs/nip46.js.map +2 -2
- package/node_modules/nostr-tools/lib/cjs/nipb0.js +4 -1
- package/node_modules/nostr-tools/lib/cjs/nipb0.js.map +2 -2
- package/node_modules/nostr-tools/lib/cjs/pool.js +35 -3
- package/node_modules/nostr-tools/lib/cjs/pool.js.map +2 -2
- package/node_modules/nostr-tools/lib/cjs/relay.js +30 -2
- package/node_modules/nostr-tools/lib/cjs/relay.js.map +2 -2
- package/node_modules/nostr-tools/lib/esm/abstract-pool.js +35 -3
- package/node_modules/nostr-tools/lib/esm/abstract-pool.js.map +2 -2
- package/node_modules/nostr-tools/lib/esm/abstract-relay.js +30 -2
- package/node_modules/nostr-tools/lib/esm/abstract-relay.js.map +2 -2
- package/node_modules/nostr-tools/lib/esm/index.js +35 -3
- package/node_modules/nostr-tools/lib/esm/index.js.map +2 -2
- package/node_modules/nostr-tools/lib/esm/nip46.js +35 -3
- package/node_modules/nostr-tools/lib/esm/nip46.js.map +2 -2
- package/node_modules/nostr-tools/lib/esm/nipb0.js +4 -1
- package/node_modules/nostr-tools/lib/esm/nipb0.js.map +2 -2
- package/node_modules/nostr-tools/lib/esm/pool.js +35 -3
- package/node_modules/nostr-tools/lib/esm/pool.js.map +2 -2
- package/node_modules/nostr-tools/lib/esm/relay.js +30 -2
- package/node_modules/nostr-tools/lib/esm/relay.js.map +2 -2
- package/node_modules/nostr-tools/lib/nostr.bundle.js +35 -3
- package/node_modules/nostr-tools/lib/nostr.bundle.js.map +2 -2
- package/node_modules/nostr-tools/lib/types/abstract-pool.d.ts +1 -0
- package/node_modules/nostr-tools/lib/types/abstract-relay.d.ts +5 -0
- package/node_modules/nostr-tools/lib/types/relay.d.ts +2 -2
- package/node_modules/nostr-tools/package.json +1 -1
- package/npm-shrinkwrap.json +7 -7
- package/openclaw.plugin.json +0 -5
- package/package.json +41 -15
|
@@ -1,23 +1,24 @@
|
|
|
1
|
-
import { a as
|
|
1
|
+
import { a as listNostrAccountIds, c as normalizePubkey, l as validatePrivateKey, n as nostrSetupContract, o as resolveDefaultNostrAccountId, r as nostrSetupWizard, s as resolveNostrAccount, t as nostrSetupAdapter } from "./setup-surface-ChtM-WSY.js";
|
|
2
2
|
import { a as collectStatusIssuesFromLastError, i as buildChannelConfigSchema, n as NostrProfileSchema, o as createDefaultChannelRuntimeState, r as DEFAULT_ACCOUNT_ID, s as formatPairingApproveHint, t as NostrConfigSchema } from "./config-schema-CelPzg1N.js";
|
|
3
|
-
import {
|
|
3
|
+
import { a as DEFAULT_RELAYS } from "./setup-adapter-BjKmSwdW.js";
|
|
4
4
|
import { t as normalizeNostrStateAccountId } from "./state-account-id-CvBZ9s6P.js";
|
|
5
5
|
import { describeAccountSnapshot } from "openclaw/plugin-sdk/account-helpers";
|
|
6
6
|
import { createScopedDmSecurityResolver, createTopLevelChannelConfigAdapter } from "openclaw/plugin-sdk/channel-config-helpers";
|
|
7
7
|
import { createChatChannelPlugin } from "openclaw/plugin-sdk/channel-core";
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
8
|
+
import { missingTargetError } from "openclaw/plugin-sdk/channel-feedback";
|
|
9
|
+
import { DEFAULT_INGRESS_ADOPTION_STALL_MS, bindIngressLifecycleToReplyOptions, createChannelIngressMonitor, createChannelMessageAdapterFromOutbound, runPassiveAccountLifecycle } from "openclaw/plugin-sdk/channel-outbound";
|
|
10
|
+
import { buildPassiveChannelStatusSummary, buildTrafficStatusSummary } from "openclaw/plugin-sdk/extension-shared";
|
|
10
11
|
import { createComputedAccountStatusAdapter } from "openclaw/plugin-sdk/status-helpers";
|
|
11
12
|
import { normalizeStringEntries } from "openclaw/plugin-sdk/string-coerce-runtime";
|
|
12
13
|
import { resolveStableChannelMessageIngress } from "openclaw/plugin-sdk/channel-ingress-runtime";
|
|
13
14
|
import { createChannelPairingController } from "openclaw/plugin-sdk/channel-pairing";
|
|
14
15
|
import { attachChannelToResult } from "openclaw/plugin-sdk/channel-send-result";
|
|
16
|
+
import { stripMarkdown } from "openclaw/plugin-sdk/text-chunking";
|
|
15
17
|
import { SimplePool, finalizeEvent, getPublicKey, verifyEvent } from "nostr-tools";
|
|
16
18
|
import { decrypt, encrypt } from "nostr-tools/nip04";
|
|
17
19
|
import { createDirectDmPreCryptoGuardPolicy } from "openclaw/plugin-sdk/direct-dm-guard-policy";
|
|
18
20
|
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
|
19
21
|
import { createPluginRuntimeStore } from "openclaw/plugin-sdk/runtime-store";
|
|
20
|
-
import { resolveIntegerOption, resolvePositiveTimerTimeoutMs } from "openclaw/plugin-sdk/number-runtime";
|
|
21
22
|
import { buildChannelOutboundSessionRoute, stripChannelTargetPrefix } from "openclaw/plugin-sdk/core";
|
|
22
23
|
//#region extensions/nostr/src/metrics.ts
|
|
23
24
|
function createZeroMetricsState() {
|
|
@@ -240,6 +241,395 @@ function createNoopMetrics() {
|
|
|
240
241
|
};
|
|
241
242
|
}
|
|
242
243
|
//#endregion
|
|
244
|
+
//#region extensions/nostr/src/nostr-cursor.ts
|
|
245
|
+
const CURSOR_WRITE_RETRY_MS = [
|
|
246
|
+
0,
|
|
247
|
+
100,
|
|
248
|
+
300
|
|
249
|
+
];
|
|
250
|
+
const CURSOR_RECOVERY_RETRY_MS = 1e3;
|
|
251
|
+
/** Tracks the largest EOSE-safe Nostr timestamp without skipping undurable relay events. */
|
|
252
|
+
function createNostrDurableCursor(options) {
|
|
253
|
+
const nowSec = options.nowSec ?? (() => Math.floor(Date.now() / 1e3));
|
|
254
|
+
let durableCandidate;
|
|
255
|
+
let transientReplayCeiling;
|
|
256
|
+
let backfillComplete = false;
|
|
257
|
+
const safeCandidate = () => {
|
|
258
|
+
if (durableCandidate === void 0) return;
|
|
259
|
+
return Math.min(durableCandidate, transientReplayCeiling ?? Number.MAX_SAFE_INTEGER);
|
|
260
|
+
};
|
|
261
|
+
return {
|
|
262
|
+
recordDurableAppend: (event) => {
|
|
263
|
+
if (!Number.isSafeInteger(event.created_at)) return;
|
|
264
|
+
const previousSafeCandidate = safeCandidate();
|
|
265
|
+
durableCandidate = Math.max(durableCandidate ?? 0, Math.min(event.created_at, nowSec()));
|
|
266
|
+
const nextSafeCandidate = safeCandidate();
|
|
267
|
+
return backfillComplete && nextSafeCandidate !== previousSafeCandidate ? nextSafeCandidate : void 0;
|
|
268
|
+
},
|
|
269
|
+
recordTransientRejection: (event) => {
|
|
270
|
+
if (!Number.isSafeInteger(event.created_at) || event.created_at < options.since) return;
|
|
271
|
+
const previousSafeCandidate = safeCandidate();
|
|
272
|
+
transientReplayCeiling = Math.min(transientReplayCeiling ?? Number.MAX_SAFE_INTEGER, event.created_at + options.replayOverlapSec);
|
|
273
|
+
const nextSafeCandidate = safeCandidate();
|
|
274
|
+
return backfillComplete && nextSafeCandidate !== previousSafeCandidate ? nextSafeCandidate : void 0;
|
|
275
|
+
},
|
|
276
|
+
markBackfillComplete: () => {
|
|
277
|
+
backfillComplete = true;
|
|
278
|
+
return safeCandidate();
|
|
279
|
+
}
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
/** Serializes cursor writes so a safety rewind always lands after older progress writes. */
|
|
283
|
+
function createNostrCursorStateWriter(options) {
|
|
284
|
+
let desiredCursor = Math.max(options.minimumCursor, options.initialCursor);
|
|
285
|
+
let dirty = false;
|
|
286
|
+
let timer;
|
|
287
|
+
let writeTail = Promise.resolve();
|
|
288
|
+
let activeFlush;
|
|
289
|
+
let recoveryFlush;
|
|
290
|
+
const writeWithRetry = async (cursor) => {
|
|
291
|
+
let lastError;
|
|
292
|
+
for (const delayMs of CURSOR_WRITE_RETRY_MS) {
|
|
293
|
+
if (delayMs > 0) await new Promise((resolve) => {
|
|
294
|
+
setTimeout(resolve, delayMs);
|
|
295
|
+
});
|
|
296
|
+
try {
|
|
297
|
+
await options.write(cursor);
|
|
298
|
+
return;
|
|
299
|
+
} catch (error) {
|
|
300
|
+
lastError = error;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
throw new Error("Nostr cursor state write failed.", { cause: lastError });
|
|
304
|
+
};
|
|
305
|
+
const enqueueWrite = (cursor) => {
|
|
306
|
+
const write = writeTail.then(() => writeWithRetry(cursor));
|
|
307
|
+
writeTail = write.catch(() => void 0);
|
|
308
|
+
return write;
|
|
309
|
+
};
|
|
310
|
+
const clearTimer = () => {
|
|
311
|
+
if (timer) {
|
|
312
|
+
clearTimeout(timer);
|
|
313
|
+
timer = void 0;
|
|
314
|
+
}
|
|
315
|
+
};
|
|
316
|
+
const runFlush = async () => {
|
|
317
|
+
for (;;) {
|
|
318
|
+
if (!dirty) {
|
|
319
|
+
await writeTail;
|
|
320
|
+
if (!dirty) return;
|
|
321
|
+
}
|
|
322
|
+
dirty = false;
|
|
323
|
+
const cursor = desiredCursor;
|
|
324
|
+
try {
|
|
325
|
+
await enqueueWrite(cursor);
|
|
326
|
+
} catch (error) {
|
|
327
|
+
dirty = true;
|
|
328
|
+
throw error;
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
};
|
|
332
|
+
const flush = () => {
|
|
333
|
+
clearTimer();
|
|
334
|
+
if (activeFlush) return activeFlush;
|
|
335
|
+
const tracked = runFlush().finally(() => {
|
|
336
|
+
if (activeFlush === tracked) activeFlush = void 0;
|
|
337
|
+
});
|
|
338
|
+
activeFlush = tracked;
|
|
339
|
+
return tracked;
|
|
340
|
+
};
|
|
341
|
+
const setDesiredCursor = (cursor) => {
|
|
342
|
+
desiredCursor = Math.max(options.minimumCursor, cursor);
|
|
343
|
+
dirty = true;
|
|
344
|
+
};
|
|
345
|
+
return {
|
|
346
|
+
schedule: (cursor) => {
|
|
347
|
+
setDesiredCursor(cursor);
|
|
348
|
+
clearTimer();
|
|
349
|
+
timer = setTimeout(() => {
|
|
350
|
+
timer = void 0;
|
|
351
|
+
flush().catch((error) => options.onBackgroundError?.(error));
|
|
352
|
+
}, options.debounceMs);
|
|
353
|
+
timer.unref?.();
|
|
354
|
+
},
|
|
355
|
+
persistNow: async (cursor) => {
|
|
356
|
+
setDesiredCursor(cursor);
|
|
357
|
+
await flush();
|
|
358
|
+
},
|
|
359
|
+
flush,
|
|
360
|
+
flushUntilSuccess: () => {
|
|
361
|
+
recoveryFlush ??= (async () => {
|
|
362
|
+
for (;;) try {
|
|
363
|
+
await flush();
|
|
364
|
+
return;
|
|
365
|
+
} catch (error) {
|
|
366
|
+
options.onBackgroundError?.(error);
|
|
367
|
+
await new Promise((resolve) => {
|
|
368
|
+
setTimeout(resolve, options.recoveryRetryMs ?? CURSOR_RECOVERY_RETRY_MS);
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
})().finally(() => {
|
|
372
|
+
recoveryFlush = void 0;
|
|
373
|
+
});
|
|
374
|
+
return recoveryFlush;
|
|
375
|
+
}
|
|
376
|
+
};
|
|
377
|
+
}
|
|
378
|
+
var NostrIngressPermanentError = class extends Error {
|
|
379
|
+
constructor(reason, message, options) {
|
|
380
|
+
super(message, options);
|
|
381
|
+
this.name = "NostrIngressPermanentError";
|
|
382
|
+
this.reason = reason;
|
|
383
|
+
}
|
|
384
|
+
};
|
|
385
|
+
function isNostrIngressRecord(value) {
|
|
386
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
387
|
+
}
|
|
388
|
+
function requiredString(value, field) {
|
|
389
|
+
if (typeof value === "string" && value.trim()) return value;
|
|
390
|
+
throw new NostrIngressPermanentError("invalid-event", `Nostr event is missing ${field}.`);
|
|
391
|
+
}
|
|
392
|
+
function inspectNostrIngressEvent(event) {
|
|
393
|
+
if (!isNostrIngressRecord(event)) throw new NostrIngressPermanentError("invalid-event", "Nostr event must be an object.");
|
|
394
|
+
return {
|
|
395
|
+
eventId: requiredString(event.id, "id"),
|
|
396
|
+
laneKey: `direct:${requiredString(event.pubkey, "pubkey")}`
|
|
397
|
+
};
|
|
398
|
+
}
|
|
399
|
+
/** Convert the retired persisted LRU seed into durable completion tombstones. */
|
|
400
|
+
async function migrateNostrLegacyRecentEventIds(params) {
|
|
401
|
+
const migratedAt = params.migratedAt ?? Date.now();
|
|
402
|
+
let migrated = 0;
|
|
403
|
+
for (const eventId of new Set(params.eventIds)) {
|
|
404
|
+
if (!eventId.trim()) continue;
|
|
405
|
+
const result = await params.queue.enqueue(eventId, {
|
|
406
|
+
version: 1,
|
|
407
|
+
receivedAt: migratedAt,
|
|
408
|
+
rawEvent: ""
|
|
409
|
+
}, {
|
|
410
|
+
receivedAt: migratedAt,
|
|
411
|
+
laneKey: `legacy:${eventId}`
|
|
412
|
+
});
|
|
413
|
+
if (result.kind === "accepted" || result.kind === "pending" && result.record.payload.rawEvent === "") {
|
|
414
|
+
if (!await params.queue.complete(eventId, { completedAt: migratedAt })) throw new Error(`Failed to migrate Nostr replay event ${eventId}.`);
|
|
415
|
+
}
|
|
416
|
+
migrated += 1;
|
|
417
|
+
}
|
|
418
|
+
return migrated;
|
|
419
|
+
}
|
|
420
|
+
//#endregion
|
|
421
|
+
//#region extensions/nostr/src/runtime.ts
|
|
422
|
+
const { setRuntime: setNostrRuntime, getRuntime: getNostrRuntime } = createPluginRuntimeStore({
|
|
423
|
+
pluginId: "nostr",
|
|
424
|
+
errorMessage: "Nostr runtime not initialized"
|
|
425
|
+
});
|
|
426
|
+
//#endregion
|
|
427
|
+
//#region extensions/nostr/src/nostr-ingress.ts
|
|
428
|
+
const NOSTR_INGRESS_POLL_INTERVAL_MS = 500;
|
|
429
|
+
const NOSTR_INGRESS_PRUNE_INTERVAL_MS = 3600 * 1e3;
|
|
430
|
+
const NOSTR_INGRESS_COMPLETED_TTL_MS = 720 * 60 * 60 * 1e3;
|
|
431
|
+
const NOSTR_INGRESS_COMPLETED_MAX_ENTRIES = 1e5;
|
|
432
|
+
const NOSTR_INGRESS_FAILED_TTL_MS = 720 * 60 * 60 * 1e3;
|
|
433
|
+
const NOSTR_INGRESS_FAILED_MAX_ENTRIES = 1e5;
|
|
434
|
+
const NOSTR_INGRESS_APPEND_RETRY_MS = [
|
|
435
|
+
0,
|
|
436
|
+
100,
|
|
437
|
+
300
|
|
438
|
+
];
|
|
439
|
+
var NostrIngressAdmissionRejectedError = class extends Error {
|
|
440
|
+
constructor(reason, message) {
|
|
441
|
+
super(message);
|
|
442
|
+
this.name = "NostrIngressAdmissionRejectedError";
|
|
443
|
+
this.reason = reason;
|
|
444
|
+
}
|
|
445
|
+
};
|
|
446
|
+
function deserializeNostrIngressEvent(rawEvent, claimedId) {
|
|
447
|
+
let parsed;
|
|
448
|
+
try {
|
|
449
|
+
parsed = JSON.parse(rawEvent);
|
|
450
|
+
} catch (error) {
|
|
451
|
+
throw new NostrIngressPermanentError("invalid-event", `Nostr ingress row ${claimedId} contains invalid JSON.`, { cause: error });
|
|
452
|
+
}
|
|
453
|
+
if (!isNostrIngressRecord(parsed)) throw new NostrIngressPermanentError("invalid-event", `Nostr ingress row ${claimedId} has an invalid event shape.`);
|
|
454
|
+
if (typeof parsed.kind !== "number" || typeof parsed.created_at !== "number" || typeof parsed.content !== "string" || typeof parsed.sig !== "string" || !Array.isArray(parsed.tags)) throw new NostrIngressPermanentError("invalid-event", `Nostr ingress row ${claimedId} has an invalid event shape.`);
|
|
455
|
+
return parsed;
|
|
456
|
+
}
|
|
457
|
+
function createNostrIngress(options) {
|
|
458
|
+
let queue = options.queue;
|
|
459
|
+
let admissionFailure;
|
|
460
|
+
let admissionWindowStartedAt = Date.now();
|
|
461
|
+
let admissionWindowCount = 0;
|
|
462
|
+
let queuedAdmissions = 0;
|
|
463
|
+
let stopping = false;
|
|
464
|
+
let stopTask;
|
|
465
|
+
const createStoppedError = () => /* @__PURE__ */ new Error("Nostr ingress stopped");
|
|
466
|
+
const getQueue = () => {
|
|
467
|
+
queue ??= getNostrRuntime().state.openChannelIngressQueue({ accountId: options.accountId });
|
|
468
|
+
return queue;
|
|
469
|
+
};
|
|
470
|
+
const legacyMigration = migrateNostrLegacyRecentEventIds({
|
|
471
|
+
queue: getQueue(),
|
|
472
|
+
eventIds: options.legacyEventIds ?? []
|
|
473
|
+
});
|
|
474
|
+
const monitor = createChannelIngressMonitor({
|
|
475
|
+
queue: getQueue,
|
|
476
|
+
inspect: (event) => {
|
|
477
|
+
const facts = inspectNostrIngressEvent(event);
|
|
478
|
+
return {
|
|
479
|
+
eventId: facts.eventId,
|
|
480
|
+
laneKey: facts.laneKey
|
|
481
|
+
};
|
|
482
|
+
},
|
|
483
|
+
payload: {
|
|
484
|
+
version: 1,
|
|
485
|
+
serialize: (event, { receivedAt }) => ({
|
|
486
|
+
receivedAt,
|
|
487
|
+
rawEvent: JSON.stringify(event)
|
|
488
|
+
}),
|
|
489
|
+
deserialize: (body, { claim }) => deserializeNostrIngressEvent(body.rawEvent, claim.id),
|
|
490
|
+
encode: ({ body }) => ({
|
|
491
|
+
version: 1,
|
|
492
|
+
...body
|
|
493
|
+
}),
|
|
494
|
+
decode: (payload, { claim }) => {
|
|
495
|
+
if (typeof payload !== "object" || payload === null || typeof payload.rawEvent !== "string") throw new NostrIngressPermanentError("invalid-event", `Nostr ingress row ${claim.id} has an invalid payload.`);
|
|
496
|
+
return {
|
|
497
|
+
version: payload.version,
|
|
498
|
+
body: {
|
|
499
|
+
receivedAt: typeof payload.receivedAt === "number" ? payload.receivedAt : 0,
|
|
500
|
+
rawEvent: payload.rawEvent
|
|
501
|
+
}
|
|
502
|
+
};
|
|
503
|
+
},
|
|
504
|
+
createClaimError: (kind, claim) => new NostrIngressPermanentError("invalid-event", kind === "invalid-version" ? `Nostr ingress row ${claim.id} has an unsupported version.` : `Nostr ingress row ${claim.id} changed event identity.`)
|
|
505
|
+
},
|
|
506
|
+
deliver: (event, lifecycle) => options.deliver(event, lifecycle),
|
|
507
|
+
pollIntervalMs: options.pollIntervalMs ?? NOSTR_INGRESS_POLL_INTERVAL_MS,
|
|
508
|
+
retention: {
|
|
509
|
+
pruneIntervalMs: NOSTR_INGRESS_PRUNE_INTERVAL_MS,
|
|
510
|
+
completedTtlMs: NOSTR_INGRESS_COMPLETED_TTL_MS,
|
|
511
|
+
completedMaxEntries: NOSTR_INGRESS_COMPLETED_MAX_ENTRIES,
|
|
512
|
+
failedTtlMs: NOSTR_INGRESS_FAILED_TTL_MS,
|
|
513
|
+
failedMaxEntries: NOSTR_INGRESS_FAILED_MAX_ENTRIES
|
|
514
|
+
},
|
|
515
|
+
drain: {
|
|
516
|
+
adoptionStallTimeoutMs: options.adoptionStallTimeoutMs ?? DEFAULT_INGRESS_ADOPTION_STALL_MS,
|
|
517
|
+
resolveNonRetryableFailure: (error) => error instanceof NostrIngressPermanentError ? {
|
|
518
|
+
reason: error.reason,
|
|
519
|
+
message: error.message
|
|
520
|
+
} : null,
|
|
521
|
+
onLog: (message) => options.onError?.(new Error(message), "ingress drain")
|
|
522
|
+
},
|
|
523
|
+
createStoppedError,
|
|
524
|
+
onError: (error) => options.onError?.(error, "ingress drain")
|
|
525
|
+
});
|
|
526
|
+
const monitorStart = legacyMigration.then(() => {
|
|
527
|
+
if (!stopping) monitor.start();
|
|
528
|
+
});
|
|
529
|
+
monitorStart.catch((error) => options.onError?.(error, "ingress drain"));
|
|
530
|
+
let admissionTail = Promise.resolve();
|
|
531
|
+
const prepareAdmission = (event) => {
|
|
532
|
+
const facts = inspectNostrIngressEvent(event);
|
|
533
|
+
const receivedAt = Date.now();
|
|
534
|
+
if (receivedAt - admissionWindowStartedAt >= options.admissionRateLimit.windowMs) {
|
|
535
|
+
admissionWindowStartedAt = receivedAt;
|
|
536
|
+
admissionWindowCount = 0;
|
|
537
|
+
}
|
|
538
|
+
if (admissionWindowCount >= options.admissionRateLimit.maxEvents) throw new NostrIngressAdmissionRejectedError("rate-limited", "Nostr event exceeds the durable admission rate.");
|
|
539
|
+
admissionWindowCount += 1;
|
|
540
|
+
if (queuedAdmissions >= options.maxQueuedAdmissions) throw new NostrIngressAdmissionRejectedError("backpressure", "Nostr event exceeds the in-memory admission backlog.");
|
|
541
|
+
let payload;
|
|
542
|
+
let serializedPayload;
|
|
543
|
+
try {
|
|
544
|
+
payload = {
|
|
545
|
+
version: 1,
|
|
546
|
+
receivedAt,
|
|
547
|
+
rawEvent: JSON.stringify(event)
|
|
548
|
+
};
|
|
549
|
+
serializedPayload = JSON.stringify(payload);
|
|
550
|
+
} catch (error) {
|
|
551
|
+
throw new NostrIngressPermanentError("invalid-event", "Nostr event could not be serialized for durable ingress.", { cause: error });
|
|
552
|
+
}
|
|
553
|
+
if (Buffer.byteLength(serializedPayload, "utf8") > options.maxSerializedPayloadBytes) throw new NostrIngressAdmissionRejectedError("oversized-event", "Nostr event exceeds the durable ingress size limit.");
|
|
554
|
+
return {
|
|
555
|
+
event,
|
|
556
|
+
facts,
|
|
557
|
+
receivedAt,
|
|
558
|
+
payload
|
|
559
|
+
};
|
|
560
|
+
};
|
|
561
|
+
const admitOnce = async (prepared) => {
|
|
562
|
+
await legacyMigration;
|
|
563
|
+
const pending = await getQueue().listPending({ limit: options.maxPendingEvents });
|
|
564
|
+
const claims = await getQueue().listClaims();
|
|
565
|
+
if (pending.length + claims.length >= options.maxPendingEvents) throw new NostrIngressAdmissionRejectedError("backpressure", "Nostr event exceeds the durable ingress backlog.");
|
|
566
|
+
let lastError;
|
|
567
|
+
for (const delayMs of NOSTR_INGRESS_APPEND_RETRY_MS) {
|
|
568
|
+
if (delayMs > 0) await new Promise((resolve) => {
|
|
569
|
+
setTimeout(resolve, delayMs);
|
|
570
|
+
});
|
|
571
|
+
try {
|
|
572
|
+
const result = await getQueue().enqueue(prepared.facts.eventId, prepared.payload, {
|
|
573
|
+
receivedAt: prepared.receivedAt,
|
|
574
|
+
laneKey: prepared.facts.laneKey
|
|
575
|
+
});
|
|
576
|
+
options.afterDurableAppend(prepared.event);
|
|
577
|
+
monitor.requestDrain();
|
|
578
|
+
return result.kind === "accepted" ? "accepted" : "duplicate";
|
|
579
|
+
} catch (error) {
|
|
580
|
+
lastError = error;
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
throw new Error(`Nostr durable admission failed: ${formatErrorMessage(lastError)}`, { cause: lastError });
|
|
584
|
+
};
|
|
585
|
+
return {
|
|
586
|
+
ready: async () => {
|
|
587
|
+
await monitorStart;
|
|
588
|
+
},
|
|
589
|
+
receive: (event) => {
|
|
590
|
+
if (stopping) return Promise.reject(createStoppedError());
|
|
591
|
+
let prepared;
|
|
592
|
+
try {
|
|
593
|
+
prepared = prepareAdmission(event);
|
|
594
|
+
} catch (error) {
|
|
595
|
+
return Promise.reject(error);
|
|
596
|
+
}
|
|
597
|
+
queuedAdmissions += 1;
|
|
598
|
+
const settledAdmission = admissionTail.then(async () => {
|
|
599
|
+
if (admissionFailure) throw admissionFailure;
|
|
600
|
+
try {
|
|
601
|
+
return await admitOnce(prepared);
|
|
602
|
+
} catch (error) {
|
|
603
|
+
if (error instanceof NostrIngressAdmissionRejectedError || error instanceof NostrIngressPermanentError) throw error;
|
|
604
|
+
admissionFailure = error instanceof Error ? error : new Error(formatErrorMessage(error), { cause: error });
|
|
605
|
+
throw admissionFailure;
|
|
606
|
+
}
|
|
607
|
+
}).finally(() => {
|
|
608
|
+
queuedAdmissions -= 1;
|
|
609
|
+
});
|
|
610
|
+
admissionTail = settledAdmission.then(() => void 0, () => void 0);
|
|
611
|
+
return settledAdmission;
|
|
612
|
+
},
|
|
613
|
+
stop: () => {
|
|
614
|
+
if (stopTask) return stopTask;
|
|
615
|
+
stopping = true;
|
|
616
|
+
const pauseTask = monitor.pause();
|
|
617
|
+
stopTask = (async () => {
|
|
618
|
+
await admissionTail;
|
|
619
|
+
await monitorStart.catch(() => void 0);
|
|
620
|
+
await pauseTask;
|
|
621
|
+
await monitor.stop();
|
|
622
|
+
})();
|
|
623
|
+
return stopTask;
|
|
624
|
+
},
|
|
625
|
+
waitForIdle: async () => {
|
|
626
|
+
await admissionTail;
|
|
627
|
+
await monitorStart;
|
|
628
|
+
await monitor.waitForIdle();
|
|
629
|
+
}
|
|
630
|
+
};
|
|
631
|
+
}
|
|
632
|
+
//#endregion
|
|
243
633
|
//#region extensions/nostr/src/nostr-profile-core.ts
|
|
244
634
|
/**
|
|
245
635
|
* Convert our config profile schema to NIP-01 content format.
|
|
@@ -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/
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
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
|
|
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
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
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
|
-
})
|
|
775
|
-
},
|
|
776
|
-
|
|
777
|
-
|
|
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
|
-
|
|
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
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
}
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
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
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
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
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
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 (
|
|
863
|
-
|
|
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
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
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
|
|
895
|
-
|
|
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
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
}
|
|
921
|
-
|
|
922
|
-
|
|
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
|
-
|
|
927
|
-
|
|
928
|
-
|
|
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
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
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
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
1183
|
-
return { stop: () => {
|
|
1184
|
-
|
|
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:
|
|
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
|
-
|
|
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:
|
|
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 =
|
|
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:
|
|
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:
|
|
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 {
|
|
1777
|
+
export { getNostrRuntime as a, contentToProfile as i, nostrPlugin as n, setNostrRuntime as o, publishNostrProfile as r, getNostrProfileState as t };
|