diff --git a/js/nostr/client.js b/js/nostr/client.js index 95b32a4e..e20a169e 100644 --- a/js/nostr/client.js +++ b/js/nostr/client.js @@ -2964,6 +2964,9 @@ export class NostrClient { baseEvent = { id: fetched.id, pubkey: fetched.pubkey, + // Carry created_at so the tombstone can be made strictly newer than the + // version it replaces (see buildRevertVideoPayload monotonicity guard). + created_at: fetched.created_at, content: JSON.stringify({ version: fetched.version, deleted: fetched.deleted, @@ -3188,6 +3191,9 @@ export class NostrClient { { id: vid.id, pubkey: vid.pubkey, + // Preserve the original timestamp so the tombstone is published with + // a strictly-newer created_at and actually replaces the live version. + created_at: vid.created_at, content: JSON.stringify(contentPayload), tags: Array.isArray(vid.tags) ? vid.tags : [], }, diff --git a/js/nostr/publishHelpers.js b/js/nostr/publishHelpers.js index 9571e2a2..2b2e05f4 100644 --- a/js/nostr/publishHelpers.js +++ b/js/nostr/publishHelpers.js @@ -1402,9 +1402,18 @@ export function buildRevertVideoPayload({ }); 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: Math.floor(Date.now() / 1000), + created_at: createdAt, dTagValue: stableDTag, content: contentObject, additionalTags, diff --git a/js/nostr/utils.js b/js/nostr/utils.js index dd6d5479..63a5bbdf 100644 --- a/js/nostr/utils.js +++ b/js/nostr/utils.js @@ -1,7 +1,14 @@ // js/nostr/utils.js export function getActiveKey(video) { - if (video.videoRootId) { + // A "LEGACY::" videoRootId is synthesized by the deletion + // builder for legacy videos that have no real root. It must NOT be treated as + // a distinct root, otherwise a deletion tombstone keys as + // "ROOT:LEGACY::" while the original legacy event (which carries + // no videoRootId) keys as ":", and the tombstone never matches + // the zombie it is meant to suppress. Fall through to the pubkey:dTag key so + // both map to the same identity. + if (video.videoRootId && !String(video.videoRootId).startsWith("LEGACY:")) { return `ROOT:${video.videoRootId}`; } const dTag = video.tags?.find((t) => t[0] === "d"); diff --git a/js/nostr/videoPayloadBuilder.js b/js/nostr/videoPayloadBuilder.js index 2c5e2524..82361bca 100644 --- a/js/nostr/videoPayloadBuilder.js +++ b/js/nostr/videoPayloadBuilder.js @@ -457,7 +457,15 @@ export function prepareVideoEditPayload(params) { ? [["s", storagePointer], ...nip71Tags] : nip71Tags; - const createdAt = Math.floor(Date.now() / 1000); + // A replaceable event only wins if its created_at is strictly greater than + // the version it replaces (NIP-01 keeps the highest created_at; ties go to + // the lowest id). A device clock that's behind, or a second edit within the + // same second, would otherwise produce an edit that relays silently keep + // BEHIND the original. Guarantee monotonicity against the base event. + const baseCreatedAt = Number.isFinite(baseEvent?.created_at) + ? Math.floor(baseEvent.created_at) + : 0; + const createdAt = Math.max(Math.floor(Date.now() / 1000), baseCreatedAt + 1); const event = buildVideoPostEvent({ pubkey: userPubkeyLower, diff --git a/tests/nostr/utils.test.mjs b/tests/nostr/utils.test.mjs index ad7fcd21..c83d03e5 100644 --- a/tests/nostr/utils.test.mjs +++ b/tests/nostr/utils.test.mjs @@ -58,5 +58,27 @@ describe('js/nostr/utils.js', () => { const key = getActiveKey(video); assert.strictEqual(key, 'LEGACY:note-789'); }); + + test('a synthesized LEGACY: root keys the same as the bare legacy event', () => { + // The deletion builder synthesizes videoRootId = "LEGACY::" + // for legacy videos. Its tombstone must map to the SAME key as the + // original legacy event (which has no videoRootId), or the zombie is never + // suppressed. + const original = { id: 'note-1', pubkey: 'pk1', tags: [['d', 'd-val']] }; + const deletion = { + id: 'note-2', + pubkey: 'pk1', + tags: [['d', 'd-val']], + videoRootId: 'LEGACY:pk1:d-val', + }; + const originalKey = getActiveKey(original); + const deletionKey = getActiveKey(deletion); + assert.strictEqual(originalKey, 'pk1:d-val'); + assert.strictEqual( + deletionKey, + originalKey, + 'legacy deletion tombstone must share the original event key', + ); + }); }); }); diff --git a/tests/video-note-monotonic-created-at.test.mjs b/tests/video-note-monotonic-created-at.test.mjs new file mode 100644 index 00000000..7cb6499c --- /dev/null +++ b/tests/video-note-monotonic-created-at.test.mjs @@ -0,0 +1,113 @@ +// Video note edit/delete must produce a strictly-newer created_at than the +// version they replace, or a behind clock (or a same-second second edit) lets +// relays keep the OLD version under NIP-01's "highest created_at wins" rule — +// edits silently don't apply, and deletions don't actually remove the video for +// other clients. + +import "./test-helpers/setup-localstorage.mjs"; +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { prepareVideoEditPayload } from "../js/nostr/videoPayloadBuilder.js"; +import { buildRevertVideoPayload } from "../js/nostr/publishHelpers.js"; + +const PK = "a".repeat(64); +// Simulate clock skew: the existing version is timestamped in the future +// relative to the device performing the edit/delete. +const FUTURE_BASE = Math.floor(Date.now() / 1000) + 100000; + +const resolveEventDTag = (evt, stub) => + (evt?.tags?.find((t) => t[0] === "d") || [])[1] || + (stub?.tags?.find((t) => t[0] === "d") || [])[1] || + ""; + +test("edit created_at is bumped above a future-dated base (clock-skew safe)", () => { + const baseEvent = { + id: "b".repeat(64), + pubkey: PK, + created_at: FUTURE_BASE, + version: 3, + title: "Original", + url: "https://cdn.example.com/v.mp4", + magnet: "", + tags: [["d", "root-d"]], + videoRootId: "root-d", + }; + + const ctx = prepareVideoEditPayload({ + baseEvent, + originalEventStub: { id: baseEvent.id, tags: baseEvent.tags }, + updatedData: { title: "Edited" }, + userPubkey: PK, + resolveEventDTag, + }); + + assert.equal( + ctx.event.created_at, + FUTURE_BASE + 1, + "edit must be exactly base+1 when the wall clock is behind the base", + ); + // d-tag preserved so the edit actually replaces the original. + const dTag = ctx.event.tags.find((t) => t[0] === "d")?.[1]; + assert.equal(dTag, "root-d", "edit preserves the d-tag"); +}); + +test("delete/revert tombstone created_at is bumped above a future-dated base", () => { + const baseEvent = { + id: "c".repeat(64), + pubkey: PK, + created_at: FUTURE_BASE, + content: JSON.stringify({ + version: 3, + videoRootId: "root-d", + title: "Original", + url: "https://cdn.example.com/v.mp4", + }), + tags: [["d", "root-d"]], + }; + + const event = buildRevertVideoPayload({ + baseEvent, + originalEventId: baseEvent.id, + pubkey: PK, + existingD: "root-d", + stableDTag: "root-d", + }); + + assert.equal( + event.created_at, + FUTURE_BASE + 1, + "tombstone must be strictly newer than the version it deletes", + ); + const content = JSON.parse(event.content); + assert.equal(content.deleted, true, "tombstone marks the video deleted"); +}); + +test("a normal (past-dated base) edit uses wall-clock time", () => { + const now = Math.floor(Date.now() / 1000); + const baseEvent = { + id: "d".repeat(64), + pubkey: PK, + created_at: now - 10000, + version: 3, + title: "Original", + tags: [["d", "root-d"]], + videoRootId: "root-d", + }; + + const ctx = prepareVideoEditPayload({ + baseEvent, + originalEventStub: { id: baseEvent.id, tags: baseEvent.tags }, + updatedData: { title: "Edited" }, + userPubkey: PK, + resolveEventDTag, + }); + + assert.ok( + ctx.event.created_at >= now, + "with a past base, the edit uses current time", + ); + assert.ok( + ctx.event.created_at > baseEvent.created_at, + "and is still strictly newer than the base", + ); +});