Files
bitvid/tests/video-note-monotonic-created-at.test.mjs
T
thePR0M3TH3ANandClaude Opus 4.8 1b11cb1bc1 [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]>
2026-06-18 20:32:50 -04:00

114 lines
3.3 KiB
JavaScript

// 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",
);
});