mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
Add rebroadcast cooldown guard and toast handling
This commit is contained in:
1 parent
79e592b4a6
commit
86be0284d9
5 files changed
+507
No files matched your search
@@ -238,3 +238,14 @@ export const VIEW_COUNT_BACKFILL_MAX_DAYS = 90;
|
||||
* fresher dashboards or raise it if your analytics traffic is heavy.
|
||||
*/
|
||||
export const VIEW_COUNT_CACHE_TTL_MS = 5 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* Cooldown window (in seconds) between manual "Ensure presence" rebroadcast
|
||||
* attempts.
|
||||
*
|
||||
* Rebroadcasting is an escape hatch for creators to nudge stale relays. A
|
||||
* short cooldown keeps this safeguard from turning into an accidental DDoS.
|
||||
* Five minutes provides enough breathing room for relays to catch up while
|
||||
* still letting operators retry if a publish genuinely failed.
|
||||
*/
|
||||
export const ENSURE_PRESENCE_REBROADCAST_COOLDOWN_SECONDS = 5 * 60;
|
||||
@@ -4959,6 +4959,10 @@ class Application {
|
||||
await this.handleRemoveHistoryAction(dataset);
|
||||
break;
|
||||
}
|
||||
case "ensure-presence": {
|
||||
await this.handleEnsurePresenceAction(dataset);
|
||||
break;
|
||||
}
|
||||
case "copy-npub": {
|
||||
const explicitNpub =
|
||||
typeof dataset.npub === "string" && dataset.npub.trim()
|
||||
@@ -5154,6 +5158,89 @@ class Application {
|
||||
}
|
||||
}
|
||||
|
||||
async handleEnsurePresenceAction(dataset = {}) {
|
||||
const context = typeof dataset.context === "string" ? dataset.context : "";
|
||||
const explicitEventId =
|
||||
typeof dataset.eventId === "string" && dataset.eventId.trim()
|
||||
? dataset.eventId.trim()
|
||||
: "";
|
||||
const fallbackEventId =
|
||||
context === "modal" && this.currentVideo?.id ? this.currentVideo.id : "";
|
||||
const targetEventId = explicitEventId || fallbackEventId;
|
||||
|
||||
if (!targetEventId) {
|
||||
this.showError("No event is available to rebroadcast.");
|
||||
return;
|
||||
}
|
||||
|
||||
const explicitPubkey =
|
||||
typeof dataset.pubkey === "string" && dataset.pubkey.trim()
|
||||
? dataset.pubkey.trim()
|
||||
: "";
|
||||
const datasetAuthor =
|
||||
typeof dataset.author === "string" && dataset.author.trim()
|
||||
? dataset.author.trim()
|
||||
: "";
|
||||
const fallbackPubkey =
|
||||
context === "modal" && typeof this.currentVideo?.pubkey === "string"
|
||||
? this.currentVideo.pubkey
|
||||
: datasetAuthor;
|
||||
const targetPubkey = explicitPubkey || fallbackPubkey || "";
|
||||
|
||||
try {
|
||||
const result = await nostrClient.rebroadcastEvent(targetEventId, {
|
||||
pubkey: targetPubkey,
|
||||
});
|
||||
|
||||
if (result?.throttled) {
|
||||
const remainingMs = Math.max(0, Number(result?.cooldown?.remainingMs) || 0);
|
||||
const remainingSeconds = Math.ceil(remainingMs / 1000);
|
||||
const message =
|
||||
remainingSeconds > 0
|
||||
? `Ensure presence is cooling down. Try again in ${remainingSeconds}s.`
|
||||
: "Ensure presence is cooling down. Try again soon.";
|
||||
this.showStatus(message);
|
||||
if (typeof window !== "undefined" && typeof window.setTimeout === "function") {
|
||||
window.setTimeout(() => {
|
||||
this.showStatus("");
|
||||
}, 5000);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!result?.ok) {
|
||||
const code = result?.error || "rebroadcast-failed";
|
||||
switch (code) {
|
||||
case "event-not-found":
|
||||
this.showError("Original event payload is unavailable. Reload and try again.");
|
||||
break;
|
||||
case "publish-rejected":
|
||||
this.showError("No relay accepted the rebroadcast attempt.");
|
||||
break;
|
||||
case "pool-unavailable":
|
||||
this.showError("Cannot reach relays right now. Please try again later.");
|
||||
break;
|
||||
default:
|
||||
this.showError("Failed to ensure presence. Please try again later.");
|
||||
break;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (result?.alreadyPresent) {
|
||||
this.showSuccess("Relays already have this revision.");
|
||||
return;
|
||||
}
|
||||
|
||||
this.showSuccess("Rebroadcast requested across relays.");
|
||||
} catch (error) {
|
||||
if (isDevMode) {
|
||||
console.warn("[app] Ensure presence action failed:", error);
|
||||
}
|
||||
this.showError("Failed to ensure presence. Please try again later.");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the modal to reflect current torrent stats.
|
||||
* We remove the unused torrent.status references,
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
VIEW_COUNT_DEDUPE_WINDOW_SECONDS,
|
||||
VIEW_COUNT_BACKFILL_MAX_DAYS,
|
||||
VIEW_COUNT_CACHE_TTL_MS,
|
||||
ENSURE_PRESENCE_REBROADCAST_COOLDOWN_SECONDS,
|
||||
} from "../config/instance-config.js";
|
||||
|
||||
export const isDevMode = true; // Set to false for production
|
||||
@@ -83,3 +84,4 @@ export { WATCH_HISTORY_CACHE_TTL_MS };
|
||||
export { VIEW_COUNT_DEDUPE_WINDOW_SECONDS };
|
||||
export { VIEW_COUNT_BACKFILL_MAX_DAYS };
|
||||
export { VIEW_COUNT_CACHE_TTL_MS };
|
||||
export { ENSURE_PRESENCE_REBROADCAST_COOLDOWN_SECONDS };
|
||||
+298
@@ -13,6 +13,7 @@ import {
|
||||
WATCH_HISTORY_PAYLOAD_MAX_BYTES,
|
||||
WATCH_HISTORY_FETCH_EVENT_LIMIT,
|
||||
WATCH_HISTORY_CACHE_TTL_MS,
|
||||
ENSURE_PRESENCE_REBROADCAST_COOLDOWN_SECONDS,
|
||||
} from "./config.js";
|
||||
import {
|
||||
ACCEPT_LEGACY_V1,
|
||||
@@ -61,6 +62,7 @@ const NIP07_LOGIN_TIMEOUT_ERROR_MESSAGE =
|
||||
"Timed out waiting for the NIP-07 extension. Check the extension prompt and try again.";
|
||||
const SESSION_ACTOR_STORAGE_KEY = "bitvid:sessionActor:v1";
|
||||
const VIEW_EVENT_GUARD_PREFIX = "bitvid:viewed";
|
||||
const REBROADCAST_GUARD_PREFIX = "bitvid:rebroadcast:v1";
|
||||
|
||||
const WATCH_HISTORY_STORAGE_KEY = "bitvid:watch-history:v2";
|
||||
const WATCH_HISTORY_STORAGE_VERSION = 2;
|
||||
@@ -70,6 +72,7 @@ const WATCH_HISTORY_REPUBLISH_MAX_ATTEMPTS = 8;
|
||||
const WATCH_HISTORY_REPUBLISH_JITTER = 0.25;
|
||||
|
||||
const viewEventPublishMemory = new Map();
|
||||
const rebroadcastAttemptMemory = new Map();
|
||||
|
||||
const VIEW_EVENT_SCHEMA = getNostrEventSchema(NOTE_TYPES.VIEW_EVENT);
|
||||
const VIEW_EVENT_KIND = Number.isFinite(VIEW_EVENT_SCHEMA?.kind)
|
||||
@@ -1679,6 +1682,172 @@ function rememberViewPublish(scope, bucketIndex) {
|
||||
}
|
||||
}
|
||||
|
||||
function deriveRebroadcastBucketIndex(referenceSeconds = null) {
|
||||
const windowSeconds = Math.max(
|
||||
1,
|
||||
Number(ENSURE_PRESENCE_REBROADCAST_COOLDOWN_SECONDS) || 0
|
||||
);
|
||||
const baseSeconds = Number.isFinite(referenceSeconds)
|
||||
? Math.max(0, Math.floor(referenceSeconds))
|
||||
: Math.floor(Date.now() / 1000);
|
||||
return Math.floor(baseSeconds / windowSeconds);
|
||||
}
|
||||
|
||||
function getRebroadcastCooldownWindowMs() {
|
||||
const windowSeconds = Math.max(
|
||||
1,
|
||||
Number(ENSURE_PRESENCE_REBROADCAST_COOLDOWN_SECONDS) || 0
|
||||
);
|
||||
return windowSeconds * 1000;
|
||||
}
|
||||
|
||||
function deriveRebroadcastScope(pubkey, eventId) {
|
||||
const normalizedPubkey =
|
||||
typeof pubkey === "string" && pubkey.trim()
|
||||
? pubkey.trim().toLowerCase()
|
||||
: "";
|
||||
const normalizedEventId =
|
||||
typeof eventId === "string" && eventId.trim()
|
||||
? eventId.trim().toLowerCase()
|
||||
: "";
|
||||
if (!normalizedPubkey || !normalizedEventId) {
|
||||
return "";
|
||||
}
|
||||
return `${normalizedPubkey}:${normalizedEventId}`;
|
||||
}
|
||||
|
||||
function readRebroadcastGuardEntry(scope) {
|
||||
if (!scope) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const windowMs = getRebroadcastCooldownWindowMs();
|
||||
const now = Date.now();
|
||||
const entry = rebroadcastAttemptMemory.get(scope);
|
||||
|
||||
if (entry) {
|
||||
const age = now - Number(entry.seenAt);
|
||||
if (!Number.isFinite(entry.seenAt) || age >= windowMs) {
|
||||
rebroadcastAttemptMemory.delete(scope);
|
||||
} else {
|
||||
return entry;
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof localStorage === "undefined") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const storageKey = `${REBROADCAST_GUARD_PREFIX}:${scope}`;
|
||||
let rawValue = null;
|
||||
try {
|
||||
rawValue = localStorage.getItem(storageKey);
|
||||
} catch (error) {
|
||||
if (isDevMode) {
|
||||
console.warn("[nostr] Failed to read rebroadcast guard entry:", error);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
if (typeof rawValue !== "string" || !rawValue) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [storedBucketRaw, storedSeenRaw] = rawValue.split(":", 2);
|
||||
const storedBucket = Number(storedBucketRaw);
|
||||
const storedSeenAt = Number(storedSeenRaw);
|
||||
|
||||
if (!Number.isFinite(storedBucket) || !Number.isFinite(storedSeenAt)) {
|
||||
try {
|
||||
localStorage.removeItem(storageKey);
|
||||
} catch (error) {
|
||||
if (isDevMode) {
|
||||
console.warn("[nostr] Failed to clear corrupt rebroadcast guard entry:", error);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
if (now - storedSeenAt >= windowMs) {
|
||||
try {
|
||||
localStorage.removeItem(storageKey);
|
||||
} catch (error) {
|
||||
if (isDevMode) {
|
||||
console.warn("[nostr] Failed to remove expired rebroadcast guard entry:", error);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalizedEntry = {
|
||||
bucket: storedBucket,
|
||||
seenAt: storedSeenAt,
|
||||
};
|
||||
rebroadcastAttemptMemory.set(scope, normalizedEntry);
|
||||
return normalizedEntry;
|
||||
}
|
||||
|
||||
function hasRecentRebroadcastAttempt(scope, bucketIndex) {
|
||||
if (!scope || !Number.isFinite(bucketIndex)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const entry = readRebroadcastGuardEntry(scope);
|
||||
return entry ? Number(entry.bucket) === bucketIndex : false;
|
||||
}
|
||||
|
||||
function rememberRebroadcastAttempt(scope, bucketIndex) {
|
||||
if (!scope || !Number.isFinite(bucketIndex)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const windowMs = getRebroadcastCooldownWindowMs();
|
||||
const entry = rebroadcastAttemptMemory.get(scope);
|
||||
if (entry && Number.isFinite(entry.seenAt) && now - entry.seenAt >= windowMs) {
|
||||
rebroadcastAttemptMemory.delete(scope);
|
||||
}
|
||||
|
||||
const normalizedEntry = { bucket: bucketIndex, seenAt: now };
|
||||
rebroadcastAttemptMemory.set(scope, normalizedEntry);
|
||||
|
||||
if (typeof localStorage === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
const storageKey = `${REBROADCAST_GUARD_PREFIX}:${scope}`;
|
||||
try {
|
||||
localStorage.setItem(storageKey, `${bucketIndex}:${now}`);
|
||||
} catch (error) {
|
||||
if (isDevMode) {
|
||||
console.warn("[nostr] Failed to persist rebroadcast guard entry:", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getRebroadcastCooldownState(scope) {
|
||||
if (!scope) {
|
||||
return null;
|
||||
}
|
||||
const entry = readRebroadcastGuardEntry(scope);
|
||||
if (!entry || !Number.isFinite(entry.seenAt)) {
|
||||
return null;
|
||||
}
|
||||
const windowMs = getRebroadcastCooldownWindowMs();
|
||||
const expiresAt = entry.seenAt + windowMs;
|
||||
const remainingMs = Math.max(0, expiresAt - Date.now());
|
||||
if (remainingMs <= 0) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
scope,
|
||||
seenAt: entry.seenAt,
|
||||
bucket: entry.bucket,
|
||||
expiresAt,
|
||||
remainingMs,
|
||||
};
|
||||
}
|
||||
|
||||
function isVideoViewEvent(event, pointer) {
|
||||
if (!event || typeof event !== "object") {
|
||||
return false;
|
||||
@@ -6911,6 +7080,135 @@ export class NostrClient {
|
||||
return video;
|
||||
}
|
||||
|
||||
async rebroadcastEvent(eventId, options = {}) {
|
||||
const normalizedId =
|
||||
typeof eventId === "string" && eventId.trim() ? eventId.trim() : "";
|
||||
if (!normalizedId) {
|
||||
return { ok: false, error: "invalid-event-id" };
|
||||
}
|
||||
|
||||
const candidatePubkeys = [];
|
||||
if (typeof options.pubkey === "string" && options.pubkey.trim()) {
|
||||
candidatePubkeys.push(options.pubkey.trim().toLowerCase());
|
||||
}
|
||||
const cachedVideo = this.allEvents.get(normalizedId);
|
||||
if (cachedVideo?.pubkey) {
|
||||
candidatePubkeys.push(cachedVideo.pubkey.toLowerCase());
|
||||
}
|
||||
const cachedRaw = this.rawEvents.get(normalizedId);
|
||||
if (cachedRaw?.pubkey) {
|
||||
candidatePubkeys.push(cachedRaw.pubkey.toLowerCase());
|
||||
}
|
||||
|
||||
let normalizedPubkey = "";
|
||||
for (const candidate of candidatePubkeys) {
|
||||
if (typeof candidate === "string" && candidate) {
|
||||
normalizedPubkey = candidate;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let guardScope = deriveRebroadcastScope(normalizedPubkey, normalizedId);
|
||||
const guardBucket = deriveRebroadcastBucketIndex();
|
||||
if (guardScope && hasRecentRebroadcastAttempt(guardScope, guardBucket)) {
|
||||
const cooldown = getRebroadcastCooldownState(guardScope);
|
||||
return { ok: false, error: "cooldown-active", throttled: true, cooldown };
|
||||
}
|
||||
|
||||
const relayCandidates = Array.isArray(options.relays) && options.relays.length
|
||||
? options.relays
|
||||
: Array.isArray(this.relays) && this.relays.length
|
||||
? this.relays
|
||||
: RELAY_URLS;
|
||||
const relays = relayCandidates
|
||||
.map((url) => (typeof url === "string" ? url.trim() : ""))
|
||||
.filter(Boolean);
|
||||
|
||||
if (!this.pool) {
|
||||
try {
|
||||
await this.ensurePool();
|
||||
} catch (error) {
|
||||
if (isDevMode) {
|
||||
console.warn("[nostr] Failed to ensure pool before rebroadcast:", error);
|
||||
}
|
||||
return { ok: false, error: "pool-unavailable", details: error };
|
||||
}
|
||||
}
|
||||
|
||||
let rawEvent =
|
||||
options.rawEvent || cachedRaw || (await this.fetchRawEventById(normalizedId, { relays }));
|
||||
|
||||
if (!rawEvent) {
|
||||
return { ok: false, error: "event-not-found" };
|
||||
}
|
||||
|
||||
if (!normalizedPubkey && typeof rawEvent.pubkey === "string") {
|
||||
normalizedPubkey = rawEvent.pubkey.trim().toLowerCase();
|
||||
}
|
||||
|
||||
const effectiveScope = deriveRebroadcastScope(normalizedPubkey, normalizedId);
|
||||
if (effectiveScope && !guardScope) {
|
||||
guardScope = effectiveScope;
|
||||
if (hasRecentRebroadcastAttempt(guardScope, guardBucket)) {
|
||||
const cooldown = getRebroadcastCooldownState(guardScope);
|
||||
return { ok: false, error: "cooldown-active", throttled: true, cooldown };
|
||||
}
|
||||
}
|
||||
|
||||
if (guardScope) {
|
||||
rememberRebroadcastAttempt(guardScope, guardBucket);
|
||||
}
|
||||
|
||||
let countResult = null;
|
||||
if (options.skipCount !== true) {
|
||||
try {
|
||||
countResult = await this.countEventsAcrossRelays([
|
||||
{ ids: [normalizedId] },
|
||||
], {
|
||||
relays,
|
||||
timeoutMs: options.timeoutMs,
|
||||
});
|
||||
} catch (error) {
|
||||
if (isDevMode) {
|
||||
console.warn("[nostr] COUNT request for rebroadcast failed:", error);
|
||||
}
|
||||
}
|
||||
|
||||
if (countResult?.total && Number(countResult.total) > 0) {
|
||||
return {
|
||||
ok: true,
|
||||
alreadyPresent: true,
|
||||
count: countResult,
|
||||
cooldown: guardScope ? getRebroadcastCooldownState(guardScope) : null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const publishResults = await publishEventToRelays(this.pool, relays, rawEvent);
|
||||
|
||||
try {
|
||||
const summary = assertAnyRelayAccepted(publishResults, { context: "rebroadcast" });
|
||||
return {
|
||||
ok: true,
|
||||
rebroadcast: true,
|
||||
summary,
|
||||
count: countResult,
|
||||
cooldown: guardScope ? getRebroadcastCooldownState(guardScope) : null,
|
||||
};
|
||||
} catch (error) {
|
||||
if (isDevMode) {
|
||||
console.warn("[nostr] Rebroadcast rejected by relays:", error);
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
error: "publish-rejected",
|
||||
details: error,
|
||||
results: publishResults,
|
||||
cooldown: guardScope ? getRebroadcastCooldownState(guardScope) : null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
getRequestTimeoutMs(timeoutMs) {
|
||||
const candidate = Number(timeoutMs);
|
||||
if (Number.isFinite(candidate) && candidate > 0) {
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
// Run with: node tests/nostr-rebroadcast-guard.test.mjs
|
||||
|
||||
import "./test-helpers/setup-localstorage.mjs";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
const { ENSURE_PRESENCE_REBROADCAST_COOLDOWN_SECONDS } = await import(
|
||||
"../js/config.js"
|
||||
);
|
||||
const { nostrClient } = await import("../js/nostr.js");
|
||||
|
||||
const NostrClient = nostrClient.constructor;
|
||||
|
||||
function createRebroadcastHarness() {
|
||||
const client = new NostrClient();
|
||||
client.relays = ["wss://relay.test"];
|
||||
|
||||
const publishCalls = [];
|
||||
const pool = {
|
||||
publish(urls, event) {
|
||||
publishCalls.push({ urls, event });
|
||||
return {
|
||||
on(eventName, handler) {
|
||||
if (eventName === "ok") {
|
||||
handler();
|
||||
}
|
||||
return true;
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
client.pool = pool;
|
||||
|
||||
const countCalls = [];
|
||||
client.countEventsAcrossRelays = async () => {
|
||||
countCalls.push(Date.now());
|
||||
return { total: 0, perRelay: [] };
|
||||
};
|
||||
|
||||
return { client, publishCalls, countCalls };
|
||||
}
|
||||
|
||||
async function testRebroadcastCooldownThrottlesUntilWindowExpires() {
|
||||
localStorage.clear();
|
||||
|
||||
const { client, publishCalls, countCalls } = createRebroadcastHarness();
|
||||
|
||||
const eventId = "rebroadcast-test-event";
|
||||
const pubkey = "f".repeat(64);
|
||||
const rawEvent = {
|
||||
id: eventId,
|
||||
pubkey,
|
||||
kind: 30078,
|
||||
created_at: 1_700_000_000,
|
||||
sig: "sig",
|
||||
tags: [],
|
||||
content: "{}",
|
||||
};
|
||||
|
||||
client.rawEvents.set(eventId, rawEvent);
|
||||
client.allEvents.set(eventId, { id: eventId, pubkey });
|
||||
|
||||
const originalNow = Date.now;
|
||||
let nowMs = 1_700_000_000_000;
|
||||
Date.now = () => nowMs;
|
||||
|
||||
const cooldownMs =
|
||||
Number(ENSURE_PRESENCE_REBROADCAST_COOLDOWN_SECONDS) * 1000;
|
||||
|
||||
try {
|
||||
const first = await client.rebroadcastEvent(eventId, { pubkey });
|
||||
assert.equal(first.ok, true, "first attempt should succeed");
|
||||
assert.equal(publishCalls.length, 1, "first attempt should publish once");
|
||||
assert.equal(countCalls.length, 1, "first attempt should issue a count");
|
||||
|
||||
const second = await client.rebroadcastEvent(eventId, { pubkey });
|
||||
assert.equal(
|
||||
second.throttled,
|
||||
true,
|
||||
"cooldown guard should throttle the immediate retry",
|
||||
);
|
||||
assert.equal(
|
||||
publishCalls.length,
|
||||
1,
|
||||
"throttled attempt must not publish again",
|
||||
);
|
||||
assert.equal(
|
||||
countCalls.length,
|
||||
1,
|
||||
"throttled attempt must not issue another COUNT",
|
||||
);
|
||||
assert.ok(
|
||||
Number(second?.cooldown?.remainingMs) > 0,
|
||||
"throttled response should include remaining cooldown",
|
||||
);
|
||||
|
||||
nowMs += cooldownMs + 1_000;
|
||||
|
||||
const third = await client.rebroadcastEvent(eventId, { pubkey });
|
||||
assert.equal(third.ok, true, "attempt after cooldown should succeed");
|
||||
assert.equal(publishCalls.length, 2, "rebroadcast should fire again after cooldown");
|
||||
assert.equal(countCalls.length, 2, "cooldown reset should allow another COUNT");
|
||||
} finally {
|
||||
Date.now = originalNow;
|
||||
}
|
||||
}
|
||||
|
||||
await testRebroadcastCooldownThrottlesUntilWindowExpires();
|
||||
|
||||
console.log("nostr rebroadcast guard tests passed");
|
||||
Reference in new issue
Block a user