mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
[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:
1 parent
006403b7b4
commit
1b11cb1bc1
6 files changed
+168
-3
No files matched your search
@@ -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 : [],
|
||||
},
|
||||
|
||||
@@ -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
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
Reference in new issue
Block a user