[nostr] make video edit/delete reliably replace prior versions

Two replaceable-event (NIP-33/kind 30078) lifecycle fixes found auditing
edit/delete/tombstone:

1. created_at monotonicity. Edits and delete tombstones used bare
   Date.now(), with no guarantee of being newer than the version they replace.
   NIP-01 keeps the highest created_at (ties -> lowest id), so a device clock
   that's behind — or a second edit within the same second — left relays
   serving the OLD version: edits silently didn't apply, and (worse) deletions
   didn't actually remove the video for other clients (the local in-memory
   tombstone masked the failure locally). The edit and revert/delete builders
   now use max(now, baseEvent.created_at + 1). created_at is threaded through
   revertVideo and _softDeleteVersions so the bulk-delete path keeps it too.

2. Legacy tombstone key mismatch. The deletion builder synthesizes
   videoRootId = "LEGACY:<pubkey>:<dTag>" for legacy videos, so its tombstone
   keyed as "ROOT:LEGACY:..." while the original legacy event (no videoRootId)
   keyed as "<pubkey>:<dTag>" — the tombstone never matched the zombie it was
   meant to suppress. getActiveKey now treats a "LEGACY:"-prefixed root as
   non-distinct and falls through to the pubkey:dTag identity, so both match.

Verified sound and unchanged: d-tag + videoRootId preservation on edit,
ownership/version checks, dual delete (NIP-09 kind-5 + per-d-tag soft-delete),
and the tombstone guard wired across all ingestion paths.

Tests: edit/tombstone created_at is bumped above a future-dated base
(clock-skew) and uses wall-clock for a past base; a legacy deletion shares the
original event's key. Both fixes mutation-verified.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
thePR0M3TH3ANandClaude Opus 4.8 committed 2026-06-18 20:32:50 -04:00
1 parent 006403b7b4
commit 1b11cb1bc1
6 files changed
+168 -3

No files matched your search

+6
View File
@@ -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 : [],
},
+10 -1
View File
@@ -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,
+8 -1
View File
@@ -1,7 +1,14 @@
// js/nostr/utils.js
export function getActiveKey(video) {
if (video.videoRootId) {
// A "LEGACY:<pubkey>:<dTag>" 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:<pubkey>:<dTag>" while the original legacy event (which carries
// no videoRootId) keys as "<pubkey>:<dTag>", 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");
+9 -1
View File
@@ -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,
+22
View File
@@ -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:<pubkey>:<dTag>"
// 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',
);
});
});
});
@@ -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",
);
});