From e226446e8f3dfe107f6826b564d51ad4049669de Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Mon, 20 Oct 2025 11:21:45 -0400 Subject: [PATCH] Add structured delete summaries and relay failure reporting --- js/nostr.js | 181 +++++++++++++++++++++++++- js/services/nostrService.js | 60 ++++++++- tests/nostr-delete-flow.test.mjs | 212 ++++++++++++++++++++++++++++++- 3 files changed, 439 insertions(+), 14 deletions(-) diff --git a/js/nostr.js b/js/nostr.js index b39d08ed..04514e72 100644 --- a/js/nostr.js +++ b/js/nostr.js @@ -38,6 +38,7 @@ import { publishEventToRelay, publishEventToRelays, assertAnyRelayAccepted, + summarizePublishResults, } from "./nostrPublish.js"; import { nostrToolsReady } from "./nostrToolsBootstrap.js"; import { devLogger, userLogger } from "./utils/logger.js"; @@ -7131,7 +7132,11 @@ export class NostrClient { }); } - return signedEvent; + return { + event: signedEvent, + publishResults, + summary: publishSummary, + }; } /** @@ -7226,6 +7231,9 @@ export class NostrClient { throw new Error("No existing events found for that root."); } + const revertSummaries = []; + const revertEvents = []; + for (const vid of matchingEvents.values()) { const baseRoot = (typeof vid.videoRootId === "string" && vid.videoRootId) || @@ -7251,7 +7259,7 @@ export class NostrClient { videoRootId: baseRoot, }; - const signedRevert = await this.revertVideo( + const revertResult = await this.revertVideo( { id: vid.id, pubkey: vid.pubkey, @@ -7261,6 +7269,26 @@ export class NostrClient { pubkey ); + const revertEvent = revertResult?.event || null; + const revertSummary = + revertResult?.summary || + summarizePublishResults(revertResult?.publishResults || []); + const revertPublishResults = Array.isArray(revertResult?.publishResults) + ? revertResult.publishResults + : []; + + revertSummaries.push({ + targetId: vid.id || "", + event: revertEvent, + publishResults: revertPublishResults, + summary: revertSummary, + }); + + if (revertEvent?.id) { + revertEvents.push(revertEvent); + this.rawEvents.set(revertEvent.id, revertEvent); + } + const cached = this.allEvents.get(vid.id) || vid; cached.deleted = true; cached.url = ""; @@ -7273,16 +7301,159 @@ export class NostrClient { const activeKey = getActiveKey(cached); if (activeKey) { this.activeMap.delete(activeKey); - const revertCreatedAt = Number.isFinite(signedRevert?.created_at) - ? Math.floor(signedRevert.created_at) + const revertCreatedAt = Number.isFinite(revertEvent?.created_at) + ? Math.floor(revertEvent.created_at) : Math.floor(Date.now() / 1000); this.recordTombstone(activeKey, revertCreatedAt); } } + const eventIdSet = new Set(); + const addressPointerSet = new Set(); + + const collectIdentifiersFromEvent = (eventLike) => { + if (!eventLike || typeof eventLike !== "object") { + return; + } + + const eventId = typeof eventLike.id === "string" ? eventLike.id : ""; + if (eventId) { + eventIdSet.add(eventId); + } + + const pointerSources = []; + if (eventLike.kind && Array.isArray(eventLike.tags)) { + pointerSources.push(eventLike); + } + if (eventId) { + const raw = this.rawEvents.get(eventId); + if (raw && raw !== eventLike) { + pointerSources.push(raw); + } + } + + for (const source of pointerSources) { + const pointer = eventToAddressPointer(source); + if (pointer) { + addressPointerSet.add(pointer); + } + } + }; + + matchingEvents.forEach((event) => collectIdentifiersFromEvent(event)); + revertEvents.forEach((event) => collectIdentifiersFromEvent(event)); + if (targetVideo) { + collectIdentifiersFromEvent(targetVideo); + } + + const identifierRecords = []; + eventIdSet.forEach((value) => { + identifierRecords.push({ type: "e", value }); + }); + addressPointerSet.forEach((value) => { + identifierRecords.push({ type: "a", value }); + }); + + const deleteSummaries = []; + if (identifierRecords.length) { + const extension = window?.nostr; + const extensionSigner = + extension && typeof extension.signEvent === "function" + ? extension.signEvent.bind(extension) + : null; + + if (typeof extensionSigner !== "function") { + const error = new Error( + "A NIP-07 extension with signEvent support is required to delete videos.", + ); + error.code = "nostr-extension-missing"; + throw error; + } + + const permissionResult = await this.ensureExtensionPermissions( + DEFAULT_NIP07_PERMISSION_METHODS, + ); + if (!permissionResult.ok) { + userLogger.warn( + "[nostr] Extension permissions denied while deleting videos.", + permissionResult.error, + ); + const error = new Error( + "The NIP-07 extension must grant decrypt and sign permissions before deleting a video.", + ); + error.code = "extension-permission-denied"; + error.cause = permissionResult.error; + throw error; + } + + const chunkSize = 100; + for (let index = 0; index < identifierRecords.length; index += chunkSize) { + const chunk = identifierRecords.slice(index, index + chunkSize); + const deleteTags = chunk.map((record) => [record.type, record.value]); + + const deleteEvent = { + kind: 5, + pubkey, + created_at: Math.floor(Date.now() / 1000), + tags: deleteTags, + content: inferredRoot + ? `Delete video root ${inferredRoot}` + : "Delete published video events", + }; + + const signedDelete = await extensionSigner(deleteEvent); + const publishResults = await publishEventToRelays( + this.pool, + this.relays, + signedDelete, + ); + const publishSummary = summarizePublishResults(publishResults); + + publishSummary.accepted.forEach(({ url }) => + devLogger.log(`Delete event published to ${url}`), + ); + + 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] Delete event not accepted by ${url}: ${reason}`, + relayError, + ); + }); + } + + if (signedDelete?.id) { + this.rawEvents.set(signedDelete.id, signedDelete); + } + + deleteSummaries.push({ + event: signedDelete, + publishResults, + summary: publishSummary, + identifiers: { + events: chunk + .filter((record) => record.type === "e") + .map((record) => record.value), + addresses: chunk + .filter((record) => record.type === "a") + .map((record) => record.value), + }, + }); + } + } + this.saveLocalData(); - return true; + return { + reverts: revertSummaries, + deletes: deleteSummaries, + }; } /** diff --git a/js/services/nostrService.js b/js/services/nostrService.js index 8163b7e9..10a72fc1 100644 --- a/js/services/nostrService.js +++ b/js/services/nostrService.js @@ -644,10 +644,64 @@ export class NostrService { confirm, video, }); - if (result) { - this.emit("videos:deleted", { videoRootId, video, pubkey }); + + if (!result) { + return result; } - return result; + + const revertFailures = []; + const deleteFailures = []; + + if (Array.isArray(result.reverts)) { + for (const entry of result.reverts) { + if (!entry || typeof entry !== "object") { + continue; + } + + const failed = Array.isArray(entry?.summary?.failed) + ? entry.summary.failed.filter(Boolean) + : []; + if (failed.length) { + revertFailures.push({ + targetId: entry.targetId || "", + eventId: entry.event?.id || "", + failed, + }); + } + } + } + + if (Array.isArray(result.deletes)) { + for (const entry of result.deletes) { + if (!entry || typeof entry !== "object") { + continue; + } + + const failed = Array.isArray(entry?.summary?.failed) + ? entry.summary.failed.filter(Boolean) + : []; + if (failed.length) { + deleteFailures.push({ + eventId: entry.event?.id || "", + identifiers: entry.identifiers || { events: [], addresses: [] }, + failed, + }); + } + } + } + + const detail = { + videoRootId, + video, + pubkey, + result, + revertFailures, + deleteFailures, + }; + + this.emit("videos:deleted", detail); + + return detail; } async getOldEventById(eventId) { diff --git a/tests/nostr-delete-flow.test.mjs b/tests/nostr-delete-flow.test.mjs index ed6660d8..4b5d7a4a 100644 --- a/tests/nostr-delete-flow.test.mjs +++ b/tests/nostr-delete-flow.test.mjs @@ -1,6 +1,7 @@ import "./test-helpers/setup-localstorage.mjs"; import assert from "node:assert/strict"; import { NostrClient } from "../js/nostr.js"; +import { NostrService } from "../js/services/nostrService.js"; await (async function testDeleteFlowPublishesDeletionFlag() { localStorage.clear(); @@ -29,14 +30,67 @@ await (async function testDeleteFlowPublishesDeletionFlag() { client.allEvents.set(baseVideo.id, { ...baseVideo }); + const signedEvents = []; + window.nostr = { + signEvent: async (event) => { + signedEvents.push(event); + return { ...event, id: `signed-${signedEvents.length}` }; + }, + }; + + client.ensureExtensionPermissions = async () => ({ ok: true }); + client.relays = ["wss://relay.ok", "wss://relay.fail"]; + client.rawEvents.set(baseVideo.id, { + id: baseVideo.id, + kind: 30078, + pubkey, + tags: baseVideo.tags, + }); + + client.pool = { + publish([url], event) { + const shouldFail = event.kind === 5 && url.includes("fail"); + return { + on(type, handler) { + if (type === "ok") { + if (!shouldFail) { + handler(); + } + return true; + } + if (type === "failed") { + if (shouldFail) { + handler(new Error(`reject-${url}`)); + } + return true; + } + return false; + }, + }; + }, + }; + const revertCalls = []; const revertEvents = []; client.revertVideo = async (event) => { revertCalls.push(event); const created_at = baseVideo.created_at + 500 + revertCalls.length; - const revertEvent = { id: `revert-${revertCalls.length}`, created_at }; + const revertEvent = { + id: `revert-${revertCalls.length}`, + kind: 30078, + pubkey, + created_at, + tags: baseVideo.tags, + }; revertEvents.push(revertEvent); - return revertEvent; + return { + event: revertEvent, + publishResults: client.relays.map((url) => ({ url, success: true })), + summary: { + accepted: client.relays.map((url) => ({ url, success: true })), + failed: [], + }, + }; }; const result = await client.deleteAllVersions(videoRootId, pubkey, { @@ -44,9 +98,66 @@ await (async function testDeleteFlowPublishesDeletionFlag() { video: baseVideo, }); - assert.equal(result, true, "deleteAllVersions should resolve true on success"); + assert.equal( + Array.isArray(result.deletes), + true, + "deleteAllVersions should return delete summaries", + ); assert.equal(revertCalls.length, 1, "deleteAllVersions should call revertVideo once"); + const deleteSummary = result.deletes[0]; + assert.equal( + deleteSummary.summary.failed.length, + 1, + "deleteAllVersions should surface relay failures for delete events", + ); + assert.equal( + deleteSummary.summary.failed[0].url, + "wss://relay.fail", + "delete summary should identify the failing relay", + ); + assert.equal( + deleteSummary.identifiers.events.includes(baseVideo.id), + true, + "delete summary should include the original event id", + ); + assert.equal( + deleteSummary.identifiers.events.includes(revertEvents[0].id), + true, + "delete summary should include the revert event id", + ); + assert.equal( + deleteSummary.identifiers.addresses.includes( + `30078:${pubkey}:${videoRootId}`, + ), + true, + "delete summary should include the address pointer for the root", + ); + + const deleteRequest = signedEvents.find((event) => event.kind === 5); + assert(deleteRequest, "deleteAllVersions should sign a kind 5 delete event"); + const eTags = deleteRequest.tags + .filter((tag) => Array.isArray(tag) && tag[0] === "e") + .map(([, value]) => value); + assert.equal( + eTags.includes(baseVideo.id), + true, + "delete event should include original event id as an e-tag", + ); + assert.equal( + eTags.includes(revertEvents[0].id), + true, + "delete event should include revert event id as an e-tag", + ); + const aTags = deleteRequest.tags + .filter((tag) => Array.isArray(tag) && tag[0] === "a") + .map(([, value]) => value); + assert.equal( + aTags.includes(`30078:${pubkey}:${videoRootId}`), + true, + "delete event should include the address pointer as an a-tag", + ); + const parsedPayload = JSON.parse(revertCalls[0].content); assert.equal(parsedPayload.deleted, true, "revert payload should mark deleted true"); assert.equal(parsedPayload.videoRootId, videoRootId); @@ -89,7 +200,7 @@ await (async function testDeleteFlowPublishesDeletionFlag() { const deleteEvent = { id: "delete-event-1", pubkey, - created_at: baseVideo.created_at + 100, + created_at: revertEvents[0].created_at + 100, content: revertCalls[0].content, tags: baseVideo.tags, }; @@ -117,6 +228,7 @@ await (async function testDeleteFlowPublishesDeletionFlag() { deleteEvent.created_at, "subscription delete event should update tombstone", ); + delete window.nostr; })(); await (async function testTombstonePersistenceAcrossSaveRestore() { @@ -203,8 +315,8 @@ await (async function testSubscribeVideosSkipsOlderThanTombstone() { content: JSON.stringify({ version: 3, deleted: true, - title: "", - url: "", + title: "Deleted", + url: "https://example.com/video.mp4", magnet: "", thumbnail: "", description: "", @@ -269,4 +381,92 @@ await (async function testSubscribeVideosSkipsOlderThanTombstone() { ); })(); +await (async function testNostrServiceBubblesDeleteFailures() { + const service = new NostrService(); + const pubkey = "PUBKEY123"; + const videoRootId = "root-service"; + const sampleVideo = { id: "evt-service", pubkey, videoRootId }; + + const revertFailure = { + url: "wss://relay.fail", + error: new Error("revert failed"), + success: false, + }; + const deleteFailure = { + url: "wss://relay.fail", + error: new Error("delete failed"), + success: false, + }; + + const summary = { + reverts: [ + { + targetId: sampleVideo.id, + event: { id: "revert-event" }, + publishResults: [ + { url: "wss://relay.ok", success: true }, + revertFailure, + ], + summary: { + accepted: [{ url: "wss://relay.ok", success: true }], + failed: [revertFailure], + }, + }, + ], + deletes: [ + { + event: { id: "delete-event" }, + identifiers: { + events: [sampleVideo.id], + addresses: ["30078:pubkey:root-service"], + }, + publishResults: [ + { url: "wss://relay.ok", success: true }, + deleteFailure, + ], + summary: { + accepted: [{ url: "wss://relay.ok", success: true }], + failed: [deleteFailure], + }, + }, + ], + }; + + service.nostrClient = { + deleteAllVersions: async () => summary, + }; + + const emitted = []; + service.on("videos:deleted", (detail) => emitted.push(detail)); + + const detail = await service.handleFullDeleteVideo({ + videoRootId, + video: sampleVideo, + pubkey, + confirm: false, + }); + + assert.equal(emitted.length, 1, "service should emit videos:deleted event"); + assert.equal( + emitted[0].result, + summary, + "emitted detail should include the delete summary", + ); + assert.equal( + detail.deleteFailures.length, + 1, + "service should surface delete relay failures", + ); + assert.equal( + detail.revertFailures.length, + 1, + "service should surface revert relay failures", + ); + assert.equal( + detail.deleteFailures[0].failed[0].url, + "wss://relay.fail", + "delete failures should include the failing relay url", + ); +})(); + console.log("nostr delete flow tests passed");