refactor: centralize nostr event schemas

This commit is contained in:
thePR0M3TH3AN committed 2025-10-01 13:21:59 -04:00
1 parent b6e1219d09
commit 0e29b07d5b
6 files changed
+634 -91

No files matched your search

+10 -22
View File
@@ -8,12 +8,10 @@ import {
isDevMode,
} from "./config.js";
import { nostrClient } from "./nostr.js";
const LIST_IDENTIFIERS = {
editors: "editors",
whitelist: "whitelist",
blacklist: "blacklist",
};
import {
buildAdminListEvent,
ADMIN_LIST_IDENTIFIERS,
} from "./nostrEventSchemas.js";
const LEGACY_STORAGE_KEYS = {
editors: "bitvid_admin_editors",
@@ -319,18 +317,15 @@ async function loadNostrList(identifier) {
async function loadNostrState() {
const [editors, whitelist, blacklist] = await Promise.all([
loadNostrList(LIST_IDENTIFIERS.editors),
loadNostrList(LIST_IDENTIFIERS.whitelist),
loadNostrList(LIST_IDENTIFIERS.blacklist),
loadNostrList(ADMIN_LIST_IDENTIFIERS.editors),
loadNostrList(ADMIN_LIST_IDENTIFIERS.whitelist),
loadNostrList(ADMIN_LIST_IDENTIFIERS.blacklist),
]);
return sanitizeAdminState({ editors, whitelist, blacklist });
}
function buildListEvent(listKey, npubs, actorHex) {
const identifier = LIST_IDENTIFIERS[listKey];
const dTagValue = `${ADMIN_LIST_NAMESPACE}:${identifier}`;
const hexPubkeys = Array.from(
new Set(npubs.map((npub) => {
try {
@@ -360,18 +355,11 @@ function buildListEvent(listKey, npubs, actorHex) {
}
}
const tags = [["d", dTagValue]];
hexPubkeys.forEach((hex) => {
tags.push(["p", hex]);
});
return {
kind: 30000,
return buildAdminListEvent(listKey, {
pubkey: actorHex,
created_at: Math.floor(Date.now() / 1000),
tags,
content: "",
};
hexPubkeys,
});
}
function publishToRelay(url, signedEvent, listKey) {
+27 -58
View File
@@ -16,6 +16,12 @@ import { accessControl } from "./accessControl.js";
// 🔧 merged conflicting changes from codex/update-video-publishing-and-parsing-logic vs unstable
import { deriveTitleFromEvent, magnetFromText } from "./videoEventUtils.js";
import { extractMagnetHints } from "./magnet.js";
import {
buildVideoPostEvent,
buildVideoMirrorEvent,
buildViewEvent,
buildWatchHistoryChunkEvent,
} from "./nostrEventSchemas.js";
/**
* The usual relays
@@ -1935,34 +1941,19 @@ class NostrClient {
const chunkIdentifier = chunkIdentifiers[index] || WATCH_HISTORY_LIST_IDENTIFIER;
const tags = [
["d", chunkIdentifier],
["encrypted", "nip04"],
["snapshot", snapshotId],
["chunk", String(index), String(totalChunks)],
...pointerTags,
];
if (index === 0) {
tags.splice(2, 0, ["head", "1"]);
const pointerTagsForChunks = chunkAddresses
.map((address) =>
typeof address === "string" && address
? ["a", address]
: null
)
.filter(Boolean);
tags.push(...pointerTagsForChunks);
}
// Ensure created_at stays monotonic so chunk 0 always outranks prior snapshots
// even when multiple publishes land within the same wall-clock second.
const event = {
kind: WATCH_HISTORY_KIND,
const event = buildWatchHistoryChunkEvent({
pubkey: normalizedActor,
created_at: baseTimestamp + (totalChunks - index - 1),
tags,
chunkIdentifier,
snapshotId,
chunkIndex: index,
totalChunks,
pointerTags,
chunkAddresses,
content: encryptionResult.ciphertext,
};
});
let signedEvent;
if (useExtension) {
@@ -2234,41 +2225,24 @@ class NostrClient {
? ["e", pointer.value, pointer.relay]
: ["e", pointer.value];
const tags = [
["t", "view"],
["video", pointer.value],
pointerTag,
...additionalTags,
];
const dedupeTagValue = deriveViewEventDedupTag(
actorPubkey,
pointer,
createdAt
);
if (
dedupeTagValue &&
!tags.some((tag) => tag[0] === "d" && tag[1] === dedupeTagValue)
) {
tags.push(["d", dedupeTagValue]);
}
if (
usingSessionActor &&
!tags.some((tag) => tag[0] === "session" && tag[1] === "true")
) {
tags.push(["session", "true"]);
}
const content =
typeof options.content === "string" ? options.content : "";
const event = {
kind: WATCH_HISTORY_KIND,
const event = buildViewEvent({
pubkey: actorPubkey,
created_at: createdAt,
tags,
pointerValue: pointer.value,
pointerTag,
dedupeTag: dedupeTagValue,
includeSessionTag: usingSessionActor,
additionalTags,
content,
};
});
let signedEvent = null;
@@ -3918,16 +3892,12 @@ class NostrClient {
contentObject.xs = finalXs;
}
const event = {
kind: 30078,
const event = buildVideoPostEvent({
pubkey,
created_at: createdAt,
tags: [
["t", "video"],
["d", dTagValue],
],
content: JSON.stringify(contentObject),
};
dTagValue,
content: contentObject,
});
if (isDevMode) {
console.log("Publish event with brand-new root:", videoRootId);
@@ -3972,13 +3942,12 @@ class NostrClient {
mirrorTags.push(["magnet", finalMagnet]);
}
const mirrorEvent = {
kind: 1063,
const mirrorEvent = buildVideoMirrorEvent({
pubkey,
created_at: createdAt,
tags: mirrorTags,
content: altText,
};
});
if (isDevMode) {
console.log("Prepared NIP-94 mirror event:", mirrorEvent);
+531
View File
@@ -0,0 +1,531 @@
import {
ADMIN_LIST_NAMESPACE,
isDevMode,
WATCH_HISTORY_KIND,
WATCH_HISTORY_LIST_IDENTIFIER,
} from "./config.js";
export const NOTE_TYPES = Object.freeze({
VIDEO_POST: "videoPost",
VIDEO_MIRROR: "videoMirror",
VIEW_EVENT: "viewEvent",
WATCH_HISTORY_CHUNK: "watchHistoryChunk",
SUBSCRIPTION_LIST: "subscriptionList",
USER_BLOCK_LIST: "userBlockList",
ADMIN_MODERATION_LIST: "adminModerationList",
ADMIN_BLACKLIST: "adminBlacklist",
ADMIN_WHITELIST: "adminWhitelist",
});
export const SUBSCRIPTION_LIST_IDENTIFIER = "subscriptions";
export const BLOCK_LIST_IDENTIFIER = "user-blocks";
export const ADMIN_LIST_IDENTIFIERS = Object.freeze({
moderation: "editors",
editors: "editors",
whitelist: "whitelist",
blacklist: "blacklist",
});
const DEFAULT_APPEND_TAGS = [];
const BASE_SCHEMAS = {
[NOTE_TYPES.VIDEO_POST]: {
type: NOTE_TYPES.VIDEO_POST,
label: "Video post",
kind: 30078,
topicTag: { name: "t", value: "video" },
identifierTag: { name: "d" },
appendTags: DEFAULT_APPEND_TAGS,
content: {
format: "json",
schemaVersion: 3,
fields: [
{ key: "version", type: "number", required: true },
{ key: "title", type: "string", required: true },
{ key: "url", type: "string", required: false },
{ key: "magnet", type: "string", required: false },
{ key: "thumbnail", type: "string", required: false },
{ key: "description", type: "string", required: false },
{ key: "mode", type: "string", required: false },
{ key: "videoRootId", type: "string", required: true },
{ key: "deleted", type: "boolean", required: false },
{ key: "isPrivate", type: "boolean", required: false },
{ key: "enableComments", type: "boolean", required: false },
{ key: "ws", type: "string", required: false },
{ key: "xs", type: "string", required: false },
],
},
},
[NOTE_TYPES.VIDEO_MIRROR]: {
type: NOTE_TYPES.VIDEO_MIRROR,
label: "NIP-94 mirror",
kind: 1063,
appendTags: DEFAULT_APPEND_TAGS,
content: {
format: "text",
description: "Optional alt text carried alongside hosted URL metadata.",
},
},
[NOTE_TYPES.VIEW_EVENT]: {
type: NOTE_TYPES.VIEW_EVENT,
label: "View counter",
kind: WATCH_HISTORY_KIND,
topicTag: { name: "t", value: "view" },
pointerTagName: "video",
identifierTag: { name: "d" },
sessionTag: { name: "session", value: "true" },
appendTags: DEFAULT_APPEND_TAGS,
content: {
format: "text",
description: "Optional plaintext content used for diagnostics.",
},
},
[NOTE_TYPES.WATCH_HISTORY_CHUNK]: {
type: NOTE_TYPES.WATCH_HISTORY_CHUNK,
label: "Watch history snapshot",
kind: WATCH_HISTORY_KIND,
identifierTag: {
name: "d",
value: WATCH_HISTORY_LIST_IDENTIFIER,
},
encryptionTag: { name: "encrypted", value: "nip04" },
snapshotTagName: "snapshot",
chunkTagName: "chunk",
headTag: { name: "head", value: "1" },
headTagIndex: 2,
appendTags: DEFAULT_APPEND_TAGS,
content: {
format: "nip04-json",
description:
"Encrypted JSON payload containing chunked watch history entries.",
},
},
[NOTE_TYPES.SUBSCRIPTION_LIST]: {
type: NOTE_TYPES.SUBSCRIPTION_LIST,
label: "Subscription list",
kind: 30002,
identifierTag: {
name: "d",
value: SUBSCRIPTION_LIST_IDENTIFIER,
},
appendTags: DEFAULT_APPEND_TAGS,
content: {
format: "nip04-json",
description: "Encrypted JSON: { subPubkeys: string[] }.",
},
},
[NOTE_TYPES.USER_BLOCK_LIST]: {
type: NOTE_TYPES.USER_BLOCK_LIST,
label: "User block list",
kind: 30002,
identifierTag: {
name: "d",
value: BLOCK_LIST_IDENTIFIER,
},
appendTags: DEFAULT_APPEND_TAGS,
content: {
format: "nip04-json",
description: "Encrypted JSON: { blockedPubkeys: string[] }.",
},
},
[NOTE_TYPES.ADMIN_MODERATION_LIST]: {
type: NOTE_TYPES.ADMIN_MODERATION_LIST,
label: "Admin moderation list",
kind: 30000,
identifierTag: {
name: "d",
value: `${ADMIN_LIST_NAMESPACE}:${ADMIN_LIST_IDENTIFIERS.moderation}`,
},
participantTagName: "p",
appendTags: DEFAULT_APPEND_TAGS,
content: { format: "empty", description: "Content field unused." },
},
[NOTE_TYPES.ADMIN_BLACKLIST]: {
type: NOTE_TYPES.ADMIN_BLACKLIST,
label: "Admin blacklist",
kind: 30000,
identifierTag: {
name: "d",
value: `${ADMIN_LIST_NAMESPACE}:${ADMIN_LIST_IDENTIFIERS.blacklist}`,
},
participantTagName: "p",
appendTags: DEFAULT_APPEND_TAGS,
content: { format: "empty", description: "Content field unused." },
},
[NOTE_TYPES.ADMIN_WHITELIST]: {
type: NOTE_TYPES.ADMIN_WHITELIST,
label: "Admin whitelist",
kind: 30000,
identifierTag: {
name: "d",
value: `${ADMIN_LIST_NAMESPACE}:${ADMIN_LIST_IDENTIFIERS.whitelist}`,
},
participantTagName: "p",
appendTags: DEFAULT_APPEND_TAGS,
content: { format: "empty", description: "Content field unused." },
},
};
let schemaOverrides = {};
function clone(value) {
if (Array.isArray(value)) {
return value.map(clone);
}
if (value && typeof value === "object") {
const result = {};
for (const [key, nested] of Object.entries(value)) {
result[key] = clone(nested);
}
return result;
}
return value;
}
function mergeDeep(base, override) {
const result = clone(base);
if (!override || typeof override !== "object") {
return result;
}
for (const [key, value] of Object.entries(override)) {
if (value && typeof value === "object" && !Array.isArray(value)) {
result[key] = mergeDeep(base?.[key] ?? {}, value);
} else {
result[key] = clone(value);
}
}
return result;
}
function resolveOverride(type) {
const local = schemaOverrides?.[type];
if (local) {
return local;
}
if (typeof window !== "undefined") {
const runtimeOverrides = window.BitVidNostrEventOverrides;
if (runtimeOverrides && runtimeOverrides[type]) {
return runtimeOverrides[type];
}
}
return null;
}
export function setNostrEventSchemaOverrides(overrides = {}) {
if (overrides && typeof overrides === "object") {
schemaOverrides = overrides;
} else {
schemaOverrides = {};
}
if (typeof window !== "undefined") {
window.BitVidNostrEventOverrides = schemaOverrides;
}
}
export function getNostrEventSchema(type) {
const base = BASE_SCHEMAS[type];
if (!base) {
return null;
}
const override = resolveOverride(type);
return mergeDeep(base, override);
}
export function getAllNostrEventSchemas() {
const entries = {};
for (const type of Object.keys(BASE_SCHEMAS)) {
entries[type] = getNostrEventSchema(type);
}
return entries;
}
function appendSchemaTags(tags, schema) {
if (!Array.isArray(schema?.appendTags)) {
return tags;
}
schema.appendTags.forEach((tag) => {
if (Array.isArray(tag) && tag.length >= 2) {
tags.push(tag.map((value) => (typeof value === "string" ? value : String(value))));
}
});
return tags;
}
export function buildVideoPostEvent({
pubkey,
created_at,
dTagValue,
content,
additionalTags = [],
}) {
const schema = getNostrEventSchema(NOTE_TYPES.VIDEO_POST);
const tags = [];
if (schema?.topicTag?.name && schema?.topicTag?.value) {
tags.push([schema.topicTag.name, schema.topicTag.value]);
}
if (schema?.identifierTag?.name && dTagValue) {
tags.push([schema.identifierTag.name, dTagValue]);
}
appendSchemaTags(tags, schema);
if (Array.isArray(additionalTags)) {
additionalTags.forEach((tag) => {
if (Array.isArray(tag) && tag.length >= 2) {
tags.push(tag.map((value) => (typeof value === "string" ? value : String(value))));
}
});
}
return {
kind: schema?.kind ?? 30078,
pubkey,
created_at,
tags,
content: typeof content === "string" ? content : JSON.stringify(content ?? {}),
};
}
export function buildVideoMirrorEvent({
pubkey,
created_at,
tags = [],
content = "",
}) {
const schema = getNostrEventSchema(NOTE_TYPES.VIDEO_MIRROR);
const combinedTags = [];
appendSchemaTags(combinedTags, schema);
if (Array.isArray(tags)) {
tags.forEach((tag) => {
if (Array.isArray(tag) && tag.length >= 2) {
combinedTags.push(tag.map((value) => (typeof value === "string" ? value : String(value))));
}
});
}
return {
kind: schema?.kind ?? 1063,
pubkey,
created_at,
tags: combinedTags,
content: typeof content === "string" ? content : String(content ?? ""),
};
}
export function buildViewEvent({
pubkey,
created_at,
pointerValue,
pointerTag,
dedupeTag,
includeSessionTag = false,
additionalTags = [],
content = "",
}) {
const schema = getNostrEventSchema(NOTE_TYPES.VIEW_EVENT);
const tags = [];
if (schema?.topicTag?.name && schema?.topicTag?.value) {
tags.push([schema.topicTag.name, schema.topicTag.value]);
}
const pointerTagName = schema?.pointerTagName || "video";
if (pointerValue) {
tags.push([pointerTagName, pointerValue]);
}
if (Array.isArray(pointerTag) && pointerTag.length >= 2) {
tags.push(pointerTag.map((value) => (typeof value === "string" ? value : String(value))));
}
if (Array.isArray(additionalTags)) {
additionalTags.forEach((tag) => {
if (Array.isArray(tag) && tag.length >= 2) {
tags.push(tag.map((value) => (typeof value === "string" ? value : String(value))));
}
});
}
if (dedupeTag) {
const identifierName = schema?.identifierTag?.name || "d";
const hasDedupe = tags.some(
(tag) => tag[0] === identifierName && tag[1] === dedupeTag
);
if (!hasDedupe) {
tags.push([identifierName, dedupeTag]);
}
}
if (includeSessionTag && schema?.sessionTag?.name && schema?.sessionTag?.value) {
const hasSession = tags.some(
(tag) => tag[0] === schema.sessionTag.name && tag[1] === schema.sessionTag.value
);
if (!hasSession) {
tags.push([schema.sessionTag.name, schema.sessionTag.value]);
}
}
appendSchemaTags(tags, schema);
return {
kind: schema?.kind ?? WATCH_HISTORY_KIND,
pubkey,
created_at,
tags,
content: typeof content === "string" ? content : String(content ?? ""),
};
}
export function buildWatchHistoryChunkEvent({
pubkey,
created_at,
chunkIdentifier,
snapshotId,
chunkIndex,
totalChunks,
pointerTags = [],
chunkAddresses = [],
content,
}) {
const schema = getNostrEventSchema(NOTE_TYPES.WATCH_HISTORY_CHUNK);
const tags = [];
const identifierName = schema?.identifierTag?.name || "d";
const identifierValue = chunkIdentifier || schema?.identifierTag?.value;
if (identifierName && identifierValue) {
tags.push([identifierName, identifierValue]);
}
if (schema?.encryptionTag?.name && schema?.encryptionTag?.value) {
tags.push([schema.encryptionTag.name, schema.encryptionTag.value]);
}
const snapshotTagName = schema?.snapshotTagName;
if (snapshotTagName && snapshotId) {
tags.push([snapshotTagName, snapshotId]);
}
const chunkTagName = schema?.chunkTagName;
if (chunkTagName && typeof chunkIndex === "number" && typeof totalChunks === "number") {
tags.push([chunkTagName, String(chunkIndex), String(totalChunks)]);
}
pointerTags.forEach((tag) => {
if (Array.isArray(tag) && tag.length >= 2) {
tags.push(tag.map((value) => (typeof value === "string" ? value : String(value))));
}
});
if (chunkIndex === 0 && schema?.headTag?.name && schema?.headTag?.value) {
const insertionIndex = Number.isInteger(schema?.headTagIndex)
? Math.max(0, Math.min(tags.length, schema.headTagIndex))
: Math.min(tags.length, 2);
tags.splice(insertionIndex, 0, [schema.headTag.name, schema.headTag.value]);
chunkAddresses.forEach((address) => {
if (typeof address === "string" && address) {
tags.push(["a", address]);
}
});
}
appendSchemaTags(tags, schema);
return {
kind: schema?.kind ?? WATCH_HISTORY_KIND,
pubkey,
created_at,
tags,
content: typeof content === "string" ? content : String(content ?? ""),
};
}
export function buildSubscriptionListEvent({
pubkey,
created_at,
content,
}) {
const schema = getNostrEventSchema(NOTE_TYPES.SUBSCRIPTION_LIST);
const tags = [];
const identifierName = schema?.identifierTag?.name || "d";
const identifierValue = schema?.identifierTag?.value || SUBSCRIPTION_LIST_IDENTIFIER;
if (identifierName && identifierValue) {
tags.push([identifierName, identifierValue]);
}
appendSchemaTags(tags, schema);
return {
kind: schema?.kind ?? 30002,
pubkey,
created_at,
tags,
content: typeof content === "string" ? content : String(content ?? ""),
};
}
export function buildBlockListEvent({
pubkey,
created_at,
content,
}) {
const schema = getNostrEventSchema(NOTE_TYPES.USER_BLOCK_LIST);
const tags = [];
const identifierName = schema?.identifierTag?.name || "d";
const identifierValue = schema?.identifierTag?.value || BLOCK_LIST_IDENTIFIER;
if (identifierName && identifierValue) {
tags.push([identifierName, identifierValue]);
}
appendSchemaTags(tags, schema);
return {
kind: schema?.kind ?? 30002,
pubkey,
created_at,
tags,
content: typeof content === "string" ? content : String(content ?? ""),
};
}
function resolveAdminNoteType(listKey) {
switch (listKey) {
case "moderation":
case "editors":
return NOTE_TYPES.ADMIN_MODERATION_LIST;
case "whitelist":
return NOTE_TYPES.ADMIN_WHITELIST;
case "blacklist":
return NOTE_TYPES.ADMIN_BLACKLIST;
default:
return null;
}
}
export function buildAdminListEvent(listKey, { pubkey, created_at, hexPubkeys = [] }) {
const schema = getNostrEventSchema(resolveAdminNoteType(listKey));
if (!schema) {
if (isDevMode) {
console.warn(`[nostrEventSchemas] Unknown admin list key: ${listKey}`);
}
return {
kind: 30000,
pubkey,
created_at,
tags: [],
content: "",
};
}
const tags = [];
if (schema?.identifierTag?.name && schema?.identifierTag?.value) {
tags.push([schema.identifierTag.name, schema.identifierTag.value]);
}
const participantTagName = schema?.participantTagName || "p";
hexPubkeys.forEach((hex) => {
if (typeof hex === "string" && hex.trim()) {
tags.push([participantTagName, hex.trim()]);
}
});
appendSchemaTags(tags, schema);
return {
kind: schema?.kind ?? 30000,
pubkey,
created_at,
tags,
content: "",
};
}
if (typeof window !== "undefined") {
window.BitVidNostrEvents = {
NOTE_TYPES,
getSchema: getNostrEventSchema,
getAllSchemas: getAllNostrEventSchemas,
setOverrides: setNostrEventSchemaOverrides,
};
}
+7 -5
View File
@@ -5,6 +5,10 @@ import {
} from "./nostr.js";
import { attachHealthBadges } from "./gridHealth.js";
import { attachUrlHealthBadges } from "./urlHealthObserver.js";
import {
buildSubscriptionListEvent,
SUBSCRIPTION_LIST_IDENTIFIER,
} from "./nostrEventSchemas.js";
function getAbsoluteShareUrl(nevent) {
if (!nevent) {
@@ -58,7 +62,7 @@ class SubscriptionsManager {
const filter = {
kinds: [30002],
authors: [userPubkey],
"#d": ["subscriptions"],
"#d": [SUBSCRIPTION_LIST_IDENTIFIER],
limit: 1,
};
@@ -168,13 +172,11 @@ class SubscriptionsManager {
throw err;
}
const evt = {
kind: 30002,
const evt = buildSubscriptionListEvent({
pubkey: userPubkey,
created_at: Math.floor(Date.now() / 1000),
tags: [["d", "subscriptions"]],
content: cipherText,
};
});
try {
const signedEvent = await window.nostr.signEvent(evt);
+3 -6
View File
@@ -1,7 +1,6 @@
// js/userBlocks.js
import { nostrClient } from "./nostr.js";
const BLOCK_LIST_IDENTIFIER = "user-blocks";
import { buildBlockListEvent, BLOCK_LIST_IDENTIFIER } from "./nostrEventSchemas.js";
function normalizeHex(pubkey) {
if (typeof pubkey !== "string") {
@@ -250,13 +249,11 @@ class UserBlockListManager {
throw err;
}
const event = {
kind: 30002,
const event = buildBlockListEvent({
pubkey: normalized,
created_at: Math.floor(Date.now() / 1000),
tags: [["d", BLOCK_LIST_IDENTIFIER]],
content: cipherText,
};
});
const signedEvent = await window.nostr.signEvent(event);