mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
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]>
114 lines
3.3 KiB
JavaScript
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",
|
|
);
|
|
});
|