Files
bitvid/js/nostr/publishHelpers.js
T
thePR0M3TH3ANandClaude Fable 5 1934f9386a publish: all-timeout content publishes are unconfirmed soft successes (#49)
signAndPublishEvent (video publish/edit/repost/mirror), rebroadcastEvent, and
revertVideo now use assertAnyRelayAcceptedOrUnconfirmed: when every relay
times out without rejecting, the event was sent and almost always persisted,
so it no longer surfaces as "Failed to share video". Explicit rejections
still throw.

Messaging stays honest at every consumer:
- relay tally carries `unconfirmed` (now attached on edits too);
  describePublishOutcome maps 0-accepted+unconfirmed to a WARNING ("sent,
  no relay confirmed yet") — never a fake relay count, never a hard error
- edit/revert controllers say "sent, not confirmed yet" when unconfirmed
- deletes stay CONSERVATIVE: "deleted successfully" only when >=1 relay
  ACKed a tombstone or kind-5 (anyRelayAcceptedInSummaries); an unconfirmed
  revert no longer aborts deleteAllVersions midway, so the kind-5 always runs

Tests: tests/relay-ack-unconfirmed-content.test.mjs (6).

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-03 21:01:31 -04:00

1437 lines
40 KiB
JavaScript

import { ENSURE_PRESENCE_REBROADCAST_COOLDOWN_SECONDS } from "../config.js";
import {
publishEventToRelays,
assertAnyRelayAcceptedOrUnconfirmed,
summarizePublishResults,
} from "../nostrPublish.js";
import {
buildVideoPostEvent,
buildRepostEvent,
buildVideoMirrorEvent,
sanitizeAdditionalTags,
} from "../nostrEventSchemas.js";
import {
getStoragePointerFromTags,
resolveStoragePointerValue,
} from "../utils/storagePointer.js";
import { normalizePointerInput } from "./watchHistory.js";
import { sanitizeRelayList } from "./nip46Client.js";
import {
RELAY_URLS,
getCachedNostrTools,
readToolkitFromScope,
} from "./toolkit.js";
import { DEFAULT_NIP07_PERMISSION_METHODS } from "./nip07Permissions.js";
import { devLogger, userLogger } from "../utils/logger.js";
import { logRebroadcastCountFailure } from "./countDiagnostics.js";
import { queueSignEvent } from "./signRequestQueue.js";
import { HEX64_REGEX } from "../utils/hex.js";
const REBROADCAST_GUARD_PREFIX = "bitvid:rebroadcast:v1";
const rebroadcastAttemptMemory = new Map();
const HEX_PRIVATE_KEY_REGEX = /^[0-9a-f]{64}$/i;
function normalizeSha256TagValue(value) {
if (value === undefined || value === null) {
return "";
}
const stringValue = typeof value === "string" ? value : String(value);
const trimmed = stringValue.trim().toLowerCase();
if (!trimmed) {
return "";
}
if (!HEX64_REGEX.test(trimmed)) {
return null;
}
return trimmed;
}
export function signEventWithPrivateKey(event, privateKey) {
const tools = getCachedNostrTools();
const scopedTools = readToolkitFromScope();
const canonicalTools =
typeof globalThis !== "undefined" &&
globalThis.__BITVID_CANONICAL_NOSTR_TOOLS__
? globalThis.__BITVID_CANONICAL_NOSTR_TOOLS__
: null;
const signFn =
typeof canonicalTools?.signEvent === "function"
? canonicalTools.signEvent
: typeof tools?.signEvent === "function"
? tools.signEvent
: typeof scopedTools?.signEvent === "function"
? scopedTools.signEvent
: null;
// console.debug('signEventWithPrivateKey', {
// signFn: typeof signFn,
// scopedSign: typeof scopedTools?.signEvent,
// cachedSign: typeof tools?.signEvent,
// });
const finalizeFn =
typeof canonicalTools?.finalizeEvent === "function"
? canonicalTools.finalizeEvent
: typeof tools?.finalizeEvent === "function"
? tools.finalizeEvent
: typeof scopedTools?.finalizeEvent === "function"
? scopedTools.finalizeEvent
: null;
const hashFn =
typeof canonicalTools?.getEventHash === "function"
? canonicalTools.getEventHash
: typeof tools?.getEventHash === "function"
? tools.getEventHash
: typeof scopedTools?.getEventHash === "function"
? scopedTools.getEventHash
: null;
if (
!privateKey ||
typeof privateKey !== "string" ||
(!signFn && !finalizeFn)
) {
throw new Error("Missing signing primitives");
}
const tags = Array.isArray(event.tags)
? event.tags.map((tag) => (Array.isArray(tag) ? [...tag] : tag))
: [];
const prepared = {
kind: event.kind,
pubkey: event.pubkey,
created_at: event.created_at,
tags,
content: typeof event.content === "string" ? event.content : "",
};
if (signFn) {
const signature = signFn(prepared, privateKey);
if (signature && typeof signature === "object") {
const idValue =
typeof signature.id === "string"
? signature.id
: hashFn
? hashFn(prepared)
: null;
const sigValue =
typeof signature.sig === "string"
? signature.sig
: typeof signature.signature === "string"
? signature.signature
: null;
if (!idValue || !sigValue) {
throw new Error("Missing signing primitives");
}
return { ...prepared, id: idValue, sig: sigValue };
}
const sigValue = typeof signature === "string" ? signature : null;
if (!sigValue || !hashFn) {
throw new Error("Missing signing primitives");
}
const idValue = hashFn(prepared);
return { ...prepared, id: idValue, sig: sigValue };
}
const normalizedKey = privateKey.trim();
if (!HEX_PRIVATE_KEY_REGEX.test(normalizedKey)) {
throw new Error("Missing signing primitives");
}
let secretKey = normalizedKey;
if (typeof Uint8Array !== "undefined") {
try {
const len = normalizedKey.length;
const bytes = new Uint8Array(len / 2);
for (let i = 0; i < len; i += 2) {
bytes[i / 2] = parseInt(normalizedKey.substring(i, i + 2), 16);
}
secretKey = bytes;
} catch (error) {
// Ignore conversion error, secretKey remains string
}
}
const finalized = finalizeFn(prepared, secretKey);
if (
!finalized ||
typeof finalized.id !== "string" ||
typeof finalized.sig !== "string"
) {
throw new Error("Missing signing primitives");
}
return finalized;
}
export 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);
}
export function getRebroadcastCooldownWindowMs() {
const windowSeconds = Math.max(
1,
Number(ENSURE_PRESENCE_REBROADCAST_COOLDOWN_SECONDS) || 0,
);
return windowSeconds * 1000;
}
export 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) {
devLogger.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) {
devLogger.warn(
"[nostr] Failed to clear corrupt rebroadcast guard entry:",
error,
);
}
return null;
}
if (now - storedSeenAt >= windowMs) {
try {
localStorage.removeItem(storageKey);
} catch (error) {
devLogger.warn(
"[nostr] Failed to remove expired rebroadcast guard entry:",
error,
);
}
return null;
}
const normalizedEntry = {
bucket: storedBucket,
seenAt: storedSeenAt,
};
rebroadcastAttemptMemory.set(scope, normalizedEntry);
return normalizedEntry;
}
export function hasRecentRebroadcastAttempt(scope, bucketIndex) {
if (!scope || !Number.isFinite(bucketIndex)) {
return false;
}
const entry = readRebroadcastGuardEntry(scope);
return entry ? Number(entry.bucket) === bucketIndex : false;
}
export 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) {
devLogger.warn("[nostr] Failed to persist rebroadcast guard entry:", error);
}
}
export 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,
};
}
export async function signAndPublishEvent({
client,
event,
options = {},
resolveActiveSigner,
shouldRequestExtensionPermissions,
signEventWithPrivateKey: signWithPrivateKey,
}) {
const {
context = "event",
logName = context,
devLogLabel = logName,
rejectionLogLevel = "error",
relaysOverride = null,
} = options;
const normalizedEventPubkey =
event && typeof event.pubkey === "string"
? event.pubkey.toLowerCase()
: "";
const normalizedLogged =
typeof client?.pubkey === "string" ? client.pubkey.toLowerCase() : "";
const usingSessionActor =
normalizedEventPubkey &&
normalizedLogged &&
normalizedEventPubkey !== normalizedLogged;
const signer =
typeof resolveActiveSigner === "function"
? resolveActiveSigner(normalizedEventPubkey || client?.pubkey)
: null;
const canUseActiveSigner =
!usingSessionActor && signer && typeof signer.signEvent === "function";
let eventToSign = event;
let signedEvent = null;
let signerPubkey = null;
if (canUseActiveSigner) {
let permissionResult = { ok: true };
if (
typeof shouldRequestExtensionPermissions === "function" &&
shouldRequestExtensionPermissions(signer) &&
typeof client?.ensureExtensionPermissions === "function"
) {
permissionResult = await client.ensureExtensionPermissions(
DEFAULT_NIP07_PERMISSION_METHODS,
);
}
if (permissionResult.ok) {
try {
signedEvent = await queueSignEvent(signer, event, {
timeoutMs: options.timeoutMs,
});
} catch (error) {
userLogger.warn(
"[nostr] Failed to sign event with active signer:",
error,
);
}
} else {
userLogger.warn(
"[nostr] Active signer permissions missing; falling back to session signer.",
permissionResult.error,
);
}
}
if (!signedEvent) {
try {
const currentSessionPubkey =
typeof client?.sessionActor?.pubkey === "string"
? client.sessionActor.pubkey.toLowerCase()
: "";
if (
typeof client?.ensureSessionActor === "function" &&
usingSessionActor &&
normalizedEventPubkey &&
normalizedEventPubkey !== currentSessionPubkey
) {
await client.ensureSessionActor(true);
} else if (typeof client?.ensureSessionActor === "function") {
await client.ensureSessionActor();
}
const sessionActor = client?.sessionActor;
if (
!sessionActor ||
typeof sessionActor.pubkey !== "string" ||
!sessionActor.pubkey ||
typeof sessionActor.privateKey !== "string" ||
!sessionActor.privateKey
) {
throw new Error("session-actor-unavailable");
}
const normalizedSessionPubkey = sessionActor.pubkey.toLowerCase();
if (
!normalizedEventPubkey ||
normalizedEventPubkey !== normalizedSessionPubkey ||
event.pubkey !== sessionActor.pubkey
) {
eventToSign = { ...event, pubkey: sessionActor.pubkey };
}
signedEvent = signWithPrivateKey(eventToSign, sessionActor.privateKey);
signerPubkey = sessionActor.pubkey;
} catch (error) {
userLogger.warn("[nostr] Failed to sign event with session key:", error);
throw error;
}
}
if (!signerPubkey && signedEvent && typeof signedEvent.pubkey === "string") {
signerPubkey = signedEvent.pubkey;
}
devLogger.log(`Signed ${devLogLabel} event:`, signedEvent);
let targetRelays = sanitizeRelayList(
Array.isArray(relaysOverride) && relaysOverride.length
? relaysOverride
: Array.isArray(client?.writeRelays) && client.writeRelays.length
? client.writeRelays
: client?.relays,
);
if (!targetRelays.length) {
targetRelays = Array.from(RELAY_URLS);
}
const publishResults = await publishEventToRelays(
client?.pool,
targetRelays,
signedEvent,
);
let publishSummary;
try {
// #49: an all-timeout publish (sent, nothing rejected) is treated as an
// unconfirmed soft success — summary.unconfirmed=true — instead of a false
// "failed to publish". Explicit relay rejections still throw below.
publishSummary = assertAnyRelayAcceptedOrUnconfirmed(publishResults, { context });
} catch (publishError) {
if (publishError?.relayFailures?.length) {
const logLevel = rejectionLogLevel === "warn" ? "warn" : "error";
publishError.relayFailures.forEach(({ url, error: relayError, reason }) => {
const logFn = logLevel === "warn" ? userLogger.warn : userLogger.error;
logFn(`[nostr] ${logName} rejected by ${url}: ${reason}`, relayError || reason);
});
}
throw publishError;
}
publishSummary.accepted.forEach(({ url }) => {
devLogger.log(`${logName} published to ${url}`);
});
if (publishSummary.unconfirmed) {
userLogger.warn(
`[nostr] ${logName} was sent but no relay confirmed it within the timeout — treating as an unconfirmed publish.`,
);
}
if (publishSummary.failed.length) {
publishSummary.failed.forEach(({ url, error: relayError }) => {
const reason =
relayError instanceof Error
? relayError.message
: relayError
? String(relayError)
: "publish failed";
userLogger.warn(
`[nostr] ${logName} not accepted by ${url}: ${reason}`,
relayError,
);
});
}
return {
signedEvent,
summary: publishSummary,
signerPubkey,
relays: targetRelays,
};
}
export async function repostEvent({
client,
eventId,
options = {},
resolveActiveSigner,
shouldRequestExtensionPermissions,
signEventWithPrivateKey: signWithPrivateKey,
eventToAddressPointer,
}) {
const normalizedId =
typeof eventId === "string" && eventId.trim() ? eventId.trim() : "";
if (!normalizedId) {
return { ok: false, error: "invalid-event-id" };
}
let pointer = null;
if (options.pointer) {
pointer = normalizePointerInput(options.pointer);
}
if (!pointer) {
const type =
typeof options.pointerType === "string" ? options.pointerType.trim() : "";
const value =
typeof options.pointerValue === "string" ? options.pointerValue.trim() : "";
if (type && value) {
const candidate = [type, value];
const relay =
typeof options.pointerRelay === "string"
? options.pointerRelay.trim()
: "";
if (relay) {
candidate.push(relay);
}
pointer = normalizePointerInput(candidate);
}
}
const cachedVideo = client?.allEvents?.get(normalizedId) || null;
const cachedRaw = client?.rawEvents?.get(normalizedId) || null;
let resolvedRawEvent = cachedRaw;
const serializeEvent = (event) => {
if (!event || typeof event !== "object") {
return "";
}
try {
return JSON.stringify(event);
} catch (error) {
devLogger.warn(
`[nostr] Failed to serialize repost target ${normalizedId}`,
error,
);
return "";
}
};
let serializedSourceEvent = serializeEvent(resolvedRawEvent);
let authorPubkey =
typeof options.authorPubkey === "string" && options.authorPubkey.trim()
? options.authorPubkey.trim().toLowerCase()
: "";
if (!authorPubkey && cachedVideo?.pubkey) {
authorPubkey = cachedVideo.pubkey.trim().toLowerCase();
}
if (!authorPubkey && cachedRaw?.pubkey) {
authorPubkey = cachedRaw.pubkey.trim().toLowerCase();
}
let address = typeof options.address === "string" ? options.address.trim() : "";
if (!address && pointer?.type === "a") {
address = pointer.value;
}
let addressRelay =
typeof options.addressRelay === "string" ? options.addressRelay.trim() : "";
if (!addressRelay && pointer?.type === "a" && pointer.relay) {
addressRelay = pointer.relay;
}
const relayCandidates = new Set();
const rememberRelayCandidate = (value) => {
if (typeof value !== "string") {
return;
}
const normalized = value.trim();
if (!normalized) {
return;
}
relayCandidates.add(normalized);
};
const rememberPointerRelay = (pointerCandidate) => {
const normalizedPointer = normalizePointerInput(pointerCandidate);
if (normalizedPointer?.relay) {
rememberRelayCandidate(normalizedPointer.relay);
}
};
const rememberRelayHintsFromObject = (candidate, depth = 0) => {
if (!candidate || typeof candidate !== "object" || depth > 3) {
return;
}
const directKeys = [
"relay",
"relayUrl",
"eventRelay",
"pointerRelay",
"sourceRelay",
"originRelay",
"addressRelay",
];
directKeys.forEach((key) => {
if (typeof candidate[key] === "string") {
rememberRelayCandidate(candidate[key]);
}
});
if (Array.isArray(candidate.relays)) {
candidate.relays.forEach((value) => {
rememberRelayCandidate(value);
});
}
if (candidate.pointer) {
rememberPointerRelay(candidate.pointer);
}
if (candidate.eventPointer) {
rememberPointerRelay(candidate.eventPointer);
}
if (candidate.pointerInfo && depth <= 3) {
rememberRelayHintsFromObject(candidate.pointerInfo, depth + 1);
}
if (Array.isArray(candidate.pointers)) {
candidate.pointers.forEach((pointerCandidate) => {
rememberPointerRelay(pointerCandidate);
});
}
if (candidate.video && depth <= 3) {
rememberRelayHintsFromObject(candidate.video, depth + 1);
}
};
let eventRelay =
typeof options.eventRelay === "string" ? options.eventRelay.trim() : "";
if (eventRelay) {
rememberRelayCandidate(eventRelay);
}
if (pointer) {
rememberPointerRelay(pointer);
if (!eventRelay && pointer.type === "e" && pointer.relay) {
eventRelay = pointer.relay;
}
}
rememberRelayHintsFromObject(options);
rememberRelayHintsFromObject(pointer);
rememberRelayHintsFromObject(cachedVideo);
rememberRelayHintsFromObject(cachedVideo?.pointerInfo);
rememberRelayHintsFromObject(cachedRaw);
if (addressRelay) {
rememberRelayCandidate(addressRelay);
}
const deriveRelayFromRawEvent = (event) => {
if (!event || typeof event !== "object") {
return "";
}
const directRelay =
typeof event.relay === "string" ? event.relay.trim() : "";
if (directRelay) {
return directRelay;
}
const relayCollections = [event.relays, event.seenOn, event.seen_on];
for (const collection of relayCollections) {
if (!Array.isArray(collection)) {
continue;
}
for (const entry of collection) {
if (typeof entry === "string" && entry.trim()) {
return entry.trim();
}
}
}
return "";
};
const cachedRawRelay = deriveRelayFromRawEvent(cachedRaw);
if (cachedRawRelay) {
rememberRelayCandidate(cachedRawRelay);
if (!eventRelay) {
eventRelay = cachedRawRelay;
}
}
let targetKind = Number.isFinite(options.kind)
? Math.floor(options.kind)
: null;
const parseAddressMetadata = (candidate) => {
if (typeof candidate !== "string" || !candidate) {
return;
}
const parts = candidate.split(":");
if (parts.length >= 3) {
const maybeKind = Number.parseInt(parts[0], 10);
if (Number.isFinite(maybeKind) && !Number.isFinite(targetKind)) {
targetKind = maybeKind;
}
const maybePubkey = parts[1];
if (
maybePubkey &&
!authorPubkey &&
/^[0-9a-f]{64}$/i.test(maybePubkey)
) {
authorPubkey = maybePubkey.toLowerCase();
}
}
};
if (address) {
parseAddressMetadata(address);
}
if (!Number.isFinite(targetKind)) {
if (Number.isFinite(cachedRaw?.kind)) {
targetKind = Math.floor(cachedRaw.kind);
} else if (Number.isFinite(cachedVideo?.kind)) {
targetKind = Math.floor(cachedVideo.kind);
} else {
targetKind = 30078;
}
}
const deriveIdentifierFromVideo = () => {
if (!cachedVideo || typeof cachedVideo !== "object") {
return "";
}
if (
typeof cachedVideo.videoRootId === "string" &&
cachedVideo.videoRootId.trim()
) {
return cachedVideo.videoRootId.trim();
}
if (Array.isArray(cachedVideo.tags)) {
for (const tag of cachedVideo.tags) {
if (!Array.isArray(tag) || tag.length < 2) {
continue;
}
if (tag[0] === "d" && typeof tag[1] === "string" && tag[1].trim()) {
return tag[1].trim();
}
}
}
return "";
};
if (!address) {
const identifier = deriveIdentifierFromVideo();
const ownerPubkey =
authorPubkey ||
(cachedVideo?.pubkey ? cachedVideo.pubkey.trim().toLowerCase() : "") ||
(cachedRaw?.pubkey ? cachedRaw.pubkey.trim().toLowerCase() : "");
if (identifier && ownerPubkey) {
address = `${targetKind}:${ownerPubkey}:${identifier}`;
parseAddressMetadata(address);
} else if (cachedRaw && typeof eventToAddressPointer === "function") {
const fallbackAddress = eventToAddressPointer(cachedRaw);
if (fallbackAddress) {
address = fallbackAddress;
parseAddressMetadata(address);
}
}
}
const relaysOverride = sanitizeRelayList(
Array.isArray(options.relays) && options.relays.length
? options.relays
: Array.isArray(client?.writeRelays) && client.writeRelays.length
? client.writeRelays
: client?.relays,
);
const relays = relaysOverride.length ? relaysOverride : Array.from(RELAY_URLS);
const probeRelays = (() => {
const order = [];
if (eventRelay) {
order.push(eventRelay);
}
relayCandidates.forEach((relayUrl) => {
if (relayUrl && !order.includes(relayUrl)) {
order.push(relayUrl);
}
});
relays.forEach((relayUrl) => {
if (relayUrl && !order.includes(relayUrl)) {
order.push(relayUrl);
}
});
return order;
})();
if ((!eventRelay || !resolvedRawEvent) && typeof client?.fetchRawEventById === "function") {
for (const relayUrl of probeRelays) {
if (!relayUrl) {
continue;
}
if (!resolvedRawEvent) {
try {
const fetched = await client.fetchRawEventById(normalizedId, {
relays: [relayUrl],
});
if (fetched) {
resolvedRawEvent = fetched;
serializedSourceEvent = serializeEvent(fetched);
}
} catch (error) {
devLogger.warn(
`[nostr] Failed to fetch ${normalizedId} from ${relayUrl} while preparing repost`,
error,
);
}
}
if (!eventRelay && resolvedRawEvent) {
eventRelay = relayUrl;
rememberRelayCandidate(relayUrl);
}
if (eventRelay && resolvedRawEvent) {
break;
}
}
}
if (resolvedRawEvent && !serializedSourceEvent) {
serializedSourceEvent = serializeEvent(resolvedRawEvent);
}
if (resolvedRawEvent && !eventRelay) {
const rawRelay = deriveRelayFromRawEvent(resolvedRawEvent);
if (rawRelay) {
eventRelay = rawRelay;
rememberRelayCandidate(rawRelay);
}
}
if (!eventRelay) {
devLogger.warn(
`[nostr] Repost aborted: missing source relay for ${normalizedId}`,
{ candidates: probeRelays },
);
return {
ok: false,
error: "missing-event-relay",
details: { relays: probeRelays },
};
}
devLogger.log(
`[nostr] Resolved repost target relay ${eventRelay} for ${normalizedId}`,
);
let actorPubkey =
typeof options.actorPubkey === "string" && options.actorPubkey.trim()
? options.actorPubkey.trim()
: typeof client?.pubkey === "string" && client.pubkey.trim()
? client.pubkey.trim()
: "";
if (!actorPubkey) {
try {
const ensured =
typeof client?.ensureSessionActor === "function"
? await client.ensureSessionActor()
: null;
actorPubkey = ensured || "";
} catch (error) {
devLogger.warn("[nostr] Failed to ensure session actor before repost:", error);
return { ok: false, error: "missing-actor", details: error };
}
}
if (!actorPubkey) {
return { ok: false, error: "missing-actor" };
}
if (!client?.pool) {
try {
if (typeof client?.ensurePool === "function") {
await client.ensurePool();
}
} catch (error) {
devLogger.warn("[nostr] Failed to ensure pool before repost:", error);
return { ok: false, error: "pool-unavailable", details: error };
}
}
const createdAt =
typeof options.created_at === "number" && Number.isFinite(options.created_at)
? Math.max(0, Math.floor(options.created_at))
: Math.floor(Date.now() / 1000);
const additionalTags = sanitizeAdditionalTags(options.additionalTags);
const repostKind =
Number.isFinite(targetKind) && targetKind !== 1
? 16
: 6;
const repostEventPayload = buildRepostEvent({
pubkey: actorPubkey,
created_at: createdAt,
eventId: normalizedId,
eventRelay,
address,
addressRelay,
authorPubkey,
additionalTags,
repostKind,
targetKind,
targetEvent: resolvedRawEvent,
serializedEvent: serializedSourceEvent,
});
try {
const { signedEvent, summary, signerPubkey } = await signAndPublishEvent({
client,
event: repostEventPayload,
resolveActiveSigner,
shouldRequestExtensionPermissions,
signEventWithPrivateKey: signWithPrivateKey,
options: {
context: "repost",
logName: "Repost",
devLogLabel: "repost",
relaysOverride: relays,
},
});
const normalizedSigner =
typeof signerPubkey === "string" ? signerPubkey.toLowerCase() : "";
const normalizedLogged =
typeof client?.pubkey === "string" ? client.pubkey.toLowerCase() : "";
const sessionPubkey =
typeof client?.sessionActor?.pubkey === "string"
? client.sessionActor.pubkey.toLowerCase()
: "";
const usedSessionActor =
normalizedSigner &&
normalizedSigner !== normalizedLogged &&
normalizedSigner === sessionPubkey;
const sourceInfo = serializedSourceEvent
? { raw: resolvedRawEvent, serialized: serializedSourceEvent }
: null;
return {
ok: true,
event: signedEvent,
summary,
relays,
sessionActor: usedSessionActor,
signerPubkey,
source: sourceInfo,
};
} catch (error) {
devLogger.warn("[nostr] Repost publish failed:", error);
const relayFailure =
error && typeof error === "object" && Array.isArray(error.relayFailures);
return {
ok: false,
error: relayFailure ? "publish-rejected" : "signing-failed",
details: error,
};
}
}
export async function mirrorVideoEvent({
client,
eventId,
options = {},
resolveActiveSigner,
shouldRequestExtensionPermissions,
signEventWithPrivateKey: signWithPrivateKey,
inferMimeTypeFromUrl,
}) {
const normalizedId =
typeof eventId === "string" && eventId.trim() ? eventId.trim() : "";
if (!normalizedId) {
return { ok: false, error: "invalid-event-id" };
}
const cachedVideo = client?.allEvents?.get(normalizedId) || null;
const sanitize = (value) => (typeof value === "string" ? value.trim() : "");
let url = sanitize(options.url);
if (!url && cachedVideo?.url) {
url = sanitize(cachedVideo.url);
}
if (!url) {
return { ok: false, error: "missing-url" };
}
const isPrivate =
options.isPrivate === true ||
options.isPrivate === "true" ||
cachedVideo?.isPrivate === true;
let magnet = sanitize(options.magnet);
if (!magnet && cachedVideo?.magnet) {
magnet = sanitize(cachedVideo.magnet);
}
if (!magnet && cachedVideo?.rawMagnet) {
magnet = sanitize(cachedVideo.rawMagnet);
}
if (!magnet && cachedVideo?.originalMagnet) {
magnet = sanitize(cachedVideo.originalMagnet);
}
let thumbnail = sanitize(options.thumbnail);
if (!thumbnail && cachedVideo?.thumbnail) {
thumbnail = sanitize(cachedVideo.thumbnail);
}
let description = sanitize(options.description);
if (!description && cachedVideo?.description) {
description = sanitize(cachedVideo.description);
}
let title = sanitize(options.title);
if (!title && cachedVideo?.title) {
title = sanitize(cachedVideo.title);
}
const providedMimeType = sanitize(options.mimeType);
const normalizedProvidedMimeType = providedMimeType
? providedMimeType.toLowerCase()
: "";
const inferredMimeType = inferMimeTypeFromUrl
? inferMimeTypeFromUrl(url)
: "";
const normalizedInferredMimeType = inferredMimeType
? inferredMimeType.toLowerCase()
: "";
const mimeTypeSource =
normalizedProvidedMimeType ||
normalizedInferredMimeType ||
"application/octet-stream";
const mimeType = mimeTypeSource.toLowerCase();
const explicitAlt = sanitize(options.altText);
const altText = explicitAlt || description || title || "";
const fileSha256 = normalizeSha256TagValue(options.fileSha256);
if (fileSha256 === null) {
return { ok: false, error: "invalid-file-sha" };
}
const originalFileSha256 = normalizeSha256TagValue(
options.originalFileSha256,
);
if (originalFileSha256 === null) {
return { ok: false, error: "invalid-original-file-sha" };
}
const tags = [];
tags.push(["url", url]);
if (mimeType) {
tags.push(["m", mimeType]);
}
if (thumbnail) {
tags.push(["thumb", thumbnail]);
}
if (altText) {
tags.push(["alt", altText]);
}
if (!isPrivate && magnet) {
tags.push(["magnet", magnet]);
}
if (fileSha256) {
tags.push(["x", fileSha256]);
}
if (originalFileSha256) {
tags.push(["ox", originalFileSha256]);
}
const additionalTags = sanitizeAdditionalTags(options.additionalTags);
if (additionalTags.length) {
tags.push(...additionalTags.map((tag) => tag.slice()));
}
let actorPubkey =
typeof options.actorPubkey === "string" && options.actorPubkey.trim()
? options.actorPubkey.trim()
: typeof client?.pubkey === "string" && client.pubkey.trim()
? client.pubkey.trim()
: "";
if (!actorPubkey) {
try {
const ensured =
typeof client?.ensureSessionActor === "function"
? await client.ensureSessionActor()
: null;
actorPubkey = ensured || "";
} catch (error) {
devLogger.warn("[nostr] Failed to ensure session actor before mirror:", error);
return { ok: false, error: "missing-actor", details: error };
}
}
if (!actorPubkey) {
return { ok: false, error: "missing-actor" };
}
if (!client?.pool) {
try {
if (typeof client?.ensurePool === "function") {
await client.ensurePool();
}
} catch (error) {
devLogger.warn("[nostr] Failed to ensure pool before mirror:", error);
return { ok: false, error: "pool-unavailable", details: error };
}
}
const createdAt =
typeof options.created_at === "number" && Number.isFinite(options.created_at)
? Math.max(0, Math.floor(options.created_at))
: Math.floor(Date.now() / 1000);
const relaysOverride = sanitizeRelayList(
Array.isArray(options.relays) && options.relays.length
? options.relays
: Array.isArray(client?.writeRelays) && client.writeRelays.length
? client.writeRelays
: client?.relays,
);
const relays = relaysOverride.length ? relaysOverride : Array.from(RELAY_URLS);
const mirrorEventPayload = buildVideoMirrorEvent({
pubkey: actorPubkey,
created_at: createdAt,
tags,
content: altText,
});
try {
const { signedEvent, summary, signerPubkey } = await signAndPublishEvent({
client,
event: mirrorEventPayload,
resolveActiveSigner,
shouldRequestExtensionPermissions,
signEventWithPrivateKey: signWithPrivateKey,
options: {
context: "mirror",
logName: "NIP-94 mirror",
devLogLabel: "NIP-94 mirror",
rejectionLogLevel: "warn",
relaysOverride: relays,
},
});
const normalizedSigner =
typeof signerPubkey === "string" ? signerPubkey.toLowerCase() : "";
const normalizedLogged =
typeof client?.pubkey === "string" ? client.pubkey.toLowerCase() : "";
const sessionPubkey =
typeof client?.sessionActor?.pubkey === "string"
? client.sessionActor.pubkey.toLowerCase()
: "";
const usedSessionActor =
normalizedSigner &&
normalizedSigner !== normalizedLogged &&
normalizedSigner === sessionPubkey;
return {
ok: true,
event: signedEvent,
summary,
relays,
sessionActor: usedSessionActor,
signerPubkey,
};
} catch (error) {
devLogger.warn("[nostr] Mirror publish failed:", error);
const relayFailure =
error && typeof error === "object" && Array.isArray(error.relayFailures);
return {
ok: false,
error: relayFailure ? "publish-rejected" : "signing-failed",
details: error,
};
}
}
export async function rebroadcastEvent({ client, 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 = client?.allEvents?.get(normalizedId);
if (cachedVideo?.pubkey) {
candidatePubkeys.push(cachedVideo.pubkey.toLowerCase());
}
const cachedRaw = client?.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(client?.relays) && client.relays.length
? client.relays
: RELAY_URLS;
const relays = relayCandidates
.map((url) => (typeof url === "string" ? url.trim() : ""))
.filter(Boolean);
if (!client?.pool) {
try {
if (typeof client?.ensurePool === "function") {
await client.ensurePool();
}
} catch (error) {
devLogger.warn("[nostr] Failed to ensure pool before rebroadcast:", error);
return { ok: false, error: "pool-unavailable", details: error };
}
}
let rawEvent =
options.rawEvent ||
cachedRaw ||
(typeof client?.fetchRawEventById === "function"
? await client.fetchRawEventById(normalizedId, { relays })
: null);
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 =
typeof client?.countEventsAcrossRelays === "function"
? await client.countEventsAcrossRelays([
{ ids: [normalizedId] },
], {
relays,
timeoutMs: options.timeoutMs,
})
: null;
} catch (error) {
logRebroadcastCountFailure(error);
}
if (countResult?.total && Number(countResult.total) > 0) {
return {
ok: true,
alreadyPresent: true,
count: countResult,
cooldown: guardScope ? getRebroadcastCooldownState(guardScope) : null,
};
}
}
const publishResults = await publishEventToRelays(client?.pool, relays, rawEvent);
try {
// #49: rebroadcast re-publishes an existing event, so an all-timeout is a
// safe unconfirmed success; only explicit rejections fall to the catch.
const summary = assertAnyRelayAcceptedOrUnconfirmed(publishResults, {
context: "rebroadcast",
});
return {
ok: true,
rebroadcast: true,
summary,
count: countResult,
cooldown: guardScope ? getRebroadcastCooldownState(guardScope) : null,
};
} catch (error) {
devLogger.warn("[nostr] Rebroadcast rejected by relays:", error);
return {
ok: false,
error: "publish-rejected",
details: error,
results: publishResults,
cooldown: guardScope ? getRebroadcastCooldownState(guardScope) : null,
};
}
}
export function buildRevertVideoPayload({
baseEvent,
originalEventId,
pubkey,
existingD,
stableDTag,
}) {
let oldContent = {};
try {
oldContent = JSON.parse(baseEvent.content || "{}");
} catch (err) {
devLogger.warn(
"[nostr] Failed to parse baseEvent.content while reverting:",
err,
);
oldContent = {};
}
const oldVersion = oldContent.version ?? 1;
const finalRootId =
oldContent.videoRootId ||
(existingD
? `LEGACY:${baseEvent.pubkey}:${existingD}`
: stableDTag || baseEvent.id || originalEventId || "");
const oldIsNsfw = oldContent.isNsfw === true;
const oldIsForKids = oldContent.isForKids === true && !oldIsNsfw;
const contentObject = {
videoRootId: finalRootId,
version: oldVersion,
deleted: true,
isPrivate: oldContent.isPrivate ?? false,
isNsfw: oldIsNsfw,
isForKids: oldIsForKids,
title: oldContent.title || "",
url: "",
magnet: "",
thumbnail: "",
description: "This version was reverted by the creator.",
mode: oldContent.mode || "live",
};
const storagePointer = resolveStoragePointerValue({
storagePointer: getStoragePointerFromTags(baseEvent.tags),
url: oldContent.url,
infoHash: oldContent.infoHash,
fallbackId: finalRootId,
});
const additionalTags = storagePointer ? [["s", storagePointer]] : [];
// The deletion tombstone must REPLACE the version it deletes, so its
// created_at has to be strictly greater (NIP-01 keeps the highest created_at).
// With a behind clock this would otherwise be older than the original and the
// relay would keep serving the live video to everyone else. Guarantee it.
const baseCreatedAt = Number.isFinite(baseEvent?.created_at)
? Math.floor(baseEvent.created_at)
: 0;
const createdAt = Math.max(Math.floor(Date.now() / 1000), baseCreatedAt + 1);
return buildVideoPostEvent({
pubkey,
created_at: createdAt,
dTagValue: stableDTag,
content: contentObject,
additionalTags,
});
}
export { summarizePublishResults };