Files
bitvid/js/nostrEventSchemas.js
T
thePR0M3TH3ANandClaude Opus 4.8 a9cb8a94b4 views: deterministic d-tag so relays dedupe a viewer's repeat views (#4)
View counts inflated. viewCounter.js dedupes locally by (viewer, time-window)
correctly, but the published events couldn't be deduped by relays:
generateViewEventDedupeTag built the kind-30079 `d` tag with random entropy +
exact timestamp, so every view was a UNIQUE event. And exactCountForPointer runs
a NIP-45 COUNT (raw event count, no dedupe) and bumped the displayed total up to
it (max(deduped, raw)). Net: reloads / multi-device / re-watches each added a
view.

Make the `d` tag deterministic and window-bucketed (scope:viewer:bucket, no
entropy) so kind-30079 view events are genuinely parameterized-replaceable:
relays keep one event per (viewer, video, window), so the cheap NIP-45 COUNT
becomes accurate AND scalable and the raw count stops inflating. buildViewEvent
no longer invents a random `d` tag (the only production caller, publishViewEvent,
always supplies the deterministic one). Pre-existing entropy events age out over
the backfill horizon.

Tests: tests/view-event-dedupe.test.mjs.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-24 12:01:25 -04:00

3192 lines
85 KiB
JavaScript

/**
* @fileoverview Central definition for Nostr event schemas, Kinds, and builder functions.
* This module manages the construction and validation of all application-specific Nostr events,
* ensuring they conform to defined schemas (NIP-01, NIP-33, etc.).
*
* It exports:
* - NOTE_TYPES: Enum of supported event types.
* - Builder functions: Pure functions to create unsigned event objects (e.g., buildVideoPostEvent).
* - Validation helpers: Functions to check event structure against schemas.
*/
import { devLogger } from "./utils/logger.js";
import { decodeNpubToHex } from "./nostr/nip46Client.js";
import {
ADMIN_LIST_NAMESPACE,
ADMIN_COMMUNITY_BLACKLIST_SOURCES,
ADMIN_COMMUNITY_BLACKLIST_PREFIX,
isDevMode,
WATCH_HISTORY_KIND,
WATCH_HISTORY_VERSION_TAG_VALUE,
} from "./config.js";
import {
buildStoragePointerValue,
deriveStoragePointerFromUrl,
} from "./utils/storagePointer.js";
/**
* Enumeration of all supported Nostr event types in the application.
* Maps logical names (e.g., VIDEO_POST) to schema definitions.
* @readonly
* @enum {string}
*/
export const NOTE_TYPES = Object.freeze({
VIDEO_POST: "videoPost",
VIDEO_MIRROR: "videoMirror",
NIP71_VIDEO: "nip71Video",
NIP71_SHORT_VIDEO: "nip71ShortVideo",
REPOST: "repost",
GENERIC_REPOST: "genericRepost",
SHARE: "share",
RELAY_LIST: "relayList",
VIEW_EVENT: "viewEvent",
VIDEO_REACTION: "videoReaction",
VIDEO_COMMENT: "videoComment",
DM_ATTACHMENT: "dmAttachment",
DM_READ_RECEIPT: "dmReadReceipt",
DM_TYPING: "dmTypingIndicator",
ZAP_REQUEST: "zapRequest",
ZAP_RECEIPT: "zapReceipt",
WATCH_HISTORY: "watchHistory",
SUBSCRIPTION_LIST: "subscriptionList",
SUBSCRIPTION_BACKUP: "subscriptionBackup",
USER_BLOCK_LIST: "userBlockList",
HASHTAG_PREFERENCES: "hashtagPreferences",
DM_RELAY_LIST: "dmRelayList",
ADMIN_MODERATION_LIST: "adminModerationList",
ADMIN_BLACKLIST: "adminBlacklist",
ADMIN_WHITELIST: "adminWhitelist",
PROFILE_METADATA: "profileMetadata",
MUTE_LIST: "muteList",
DELETION: "deletion",
LEGACY_DM: "legacyDm",
HTTP_AUTH: "httpAuth",
REPORT: "report",
GIFT_WRAP: "giftWrap",
SEAL: "seal",
CHAT_MESSAGE: "chatMessage",
});
/** Kind for NIP-51 Mute List. */
export const KIND_MUTE_LIST = 10000;
export const SUBSCRIPTION_LIST_IDENTIFIER = "subscriptions";
export const BLOCK_LIST_IDENTIFIER = "user-blocks";
/**
* Identifiers for admin-related NIP-33 lists.
* @readonly
*/
export const ADMIN_LIST_IDENTIFIERS = Object.freeze({
moderation: "editors",
editors: "editors",
whitelist: "whitelist",
blacklist: "blacklist",
communityBlacklistSources: ADMIN_COMMUNITY_BLACKLIST_SOURCES,
});
export { ADMIN_COMMUNITY_BLACKLIST_SOURCES };
export { ADMIN_COMMUNITY_BLACKLIST_PREFIX };
const DEFAULT_APPEND_TAGS = [];
let cachedUtf8Encoder = null;
const ADDITIONAL_POINTER_TAGS = new Set(["p", "e"]);
const HEX_32_BYTE_REGEX = /^[0-9a-f]{64}$/i;
function safeStringify(value) {
if (typeof value === "string") {
return value;
}
if (value === null || value === undefined) {
return "";
}
if (typeof value === "object") {
try {
return JSON.stringify(value);
} catch (e) {
return "";
}
}
try {
return String(value);
} catch (err) {
return "";
}
}
function normalizePointerIdentifier(value) {
if (typeof value !== "string") {
return "";
}
const trimmed = value.trim();
if (!trimmed) {
return "";
}
if (HEX_32_BYTE_REGEX.test(trimmed)) {
return trimmed.toLowerCase();
}
const decoded = decodeNpubToHex(trimmed);
if (decoded && HEX_32_BYTE_REGEX.test(decoded)) {
return decoded.toLowerCase();
}
return "";
}
function normalizeTagSlotValue(value) {
if (typeof value === "string") {
const trimmed = value.trim();
return trimmed;
}
if (typeof value === "number") {
if (!Number.isFinite(value)) {
return "";
}
return String(value);
}
if (value === null || value === undefined) {
return "";
}
return "";
}
/**
* Normalizes and sanitizes an array of additional user-provided tags.
* Ensures that tags are arrays of strings and handles hex/npub conversion for 'p'/'e' tags.
*
* @param {Array<Array<string>>} additionalTags - Raw tags to sanitize.
* @returns {Array<Array<string>>} Cleaned tags.
*/
export function sanitizeAdditionalTags(additionalTags) {
if (!Array.isArray(additionalTags) || !additionalTags.length) {
return [];
}
const sanitized = [];
additionalTags.forEach((tag) => {
if (!Array.isArray(tag) || tag.length < 2) {
return;
}
const rawName = tag[0];
if (typeof rawName !== "string") {
return;
}
const tagName = rawName.trim();
if (!tagName) {
return;
}
const lowerName = tagName.toLowerCase();
let primaryValue = "";
if (ADDITIONAL_POINTER_TAGS.has(lowerName)) {
primaryValue = normalizePointerIdentifier(tag[1]);
} else {
primaryValue = normalizeTagSlotValue(tag[1]);
}
if (!primaryValue) {
return;
}
const sanitizedTag = [tagName, primaryValue];
for (let index = 2; index < tag.length; index += 1) {
const extraValue = normalizeTagSlotValue(tag[index]);
if (extraValue) {
sanitizedTag.push(extraValue);
}
}
sanitized.push(sanitizedTag);
});
return sanitized;
}
function ensureValidUtf8Content(value) {
let normalized = "";
if (typeof value === "string") {
normalized = value;
} else if (value === undefined || value === null) {
normalized = "";
} else if (typeof value === "object") {
try {
const stringified = JSON.stringify(value);
normalized = typeof stringified === "string" ? stringified : "";
} catch (error) {
devLogger.warn(
"[nostrEventSchemas] Failed to serialize view event content:",
error
);
normalized = "";
}
} else {
normalized = String(value);
}
if (normalized) {
const builder = [];
let mutated = false;
for (let index = 0; index < normalized.length; index += 1) {
const code = normalized.charCodeAt(index);
if (code >= 0xd800 && code <= 0xdbff) {
const nextCode = normalized.charCodeAt(index + 1);
if (nextCode >= 0xdc00 && nextCode <= 0xdfff) {
builder.push(normalized[index], normalized[index + 1]);
index += 1;
} else {
mutated = true;
}
} else if (code >= 0xdc00 && code <= 0xdfff) {
mutated = true;
} else {
builder.push(normalized[index]);
}
}
if (mutated) {
devLogger.warn(
"[nostrEventSchemas] Dropping unmatched surrogate characters from event content"
);
normalized = builder.join("");
}
}
if (!cachedUtf8Encoder && typeof TextEncoder !== "undefined") {
cachedUtf8Encoder = new TextEncoder();
}
if (cachedUtf8Encoder) {
try {
cachedUtf8Encoder.encode(normalized);
} catch (error) {
devLogger.warn(
"[nostrEventSchemas] Dropping invalid UTF-8 characters from event content",
error
);
const sanitized = Array.from(normalized)
.filter((char) => {
const code = char.charCodeAt(0);
return code < 0xd800 || code > 0xdfff;
})
.join("");
try {
cachedUtf8Encoder.encode(sanitized);
normalized = sanitized;
} catch (encodeError) {
devLogger.warn(
"[nostrEventSchemas] Failed to normalize view event content; defaulting to empty string",
encodeError
);
normalized = "";
}
}
}
return normalized;
}
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" },
requiredTagNames: ["s"],
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: "isNsfw", type: "boolean", required: false },
{ key: "isForKids", type: "boolean", required: false },
{ key: "enableComments", type: "boolean", required: false },
{ key: "infoHash", type: "string", required: false },
{ key: "fileSha256", type: "string", required: false },
{ key: "originalFileSha256", type: "string", 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.REPOST]: {
type: NOTE_TYPES.REPOST,
label: "NIP-18 repost",
kind: 6,
appendTags: DEFAULT_APPEND_TAGS,
content: {
description: "Content field contains the JSON-serialized event being reposted (or empty if unavailable).",
},
},
[NOTE_TYPES.GENERIC_REPOST]: {
type: NOTE_TYPES.GENERIC_REPOST,
label: "Generic repost",
kind: 16,
appendTags: DEFAULT_APPEND_TAGS,
content: {
description: "Content field contains the JSON-serialized event being reposted (or empty if unavailable).",
},
},
[NOTE_TYPES.SHARE]: {
type: NOTE_TYPES.SHARE,
label: "Share note",
kind: 1,
relayTagName: "r",
readMarker: "read",
writeMarker: "write",
appendTags: DEFAULT_APPEND_TAGS,
content: {
format: "text",
description: "Plaintext share note referencing a video.",
},
},
[NOTE_TYPES.NIP71_VIDEO]: {
type: NOTE_TYPES.NIP71_VIDEO,
label: "NIP-71 video (normal)",
kind: 21,
featureFlag: "FEATURE_PUBLISH_NIP71",
appendTags: DEFAULT_APPEND_TAGS,
content: {
format: "text",
description:
"Summary or description for the video body; tags carry structured metadata.",
},
},
[NOTE_TYPES.NIP71_SHORT_VIDEO]: {
type: NOTE_TYPES.NIP71_SHORT_VIDEO,
label: "NIP-71 video (short)",
kind: 22,
featureFlag: "FEATURE_PUBLISH_NIP71",
appendTags: DEFAULT_APPEND_TAGS,
content: {
format: "text",
description:
"Summary or description for the short-form video; structured fields live in tags.",
},
},
[NOTE_TYPES.RELAY_LIST]: {
type: NOTE_TYPES.RELAY_LIST,
label: "Relay list metadata",
kind: 10002,
relayTagName: "r",
readMarker: "read",
writeMarker: "write",
appendTags: DEFAULT_APPEND_TAGS,
content: { format: "empty", description: "Content field unused." },
},
[NOTE_TYPES.DM_RELAY_LIST]: {
type: NOTE_TYPES.DM_RELAY_LIST,
label: "DM relay hints",
kind: 10050,
relayTagName: "relay",
appendTags: DEFAULT_APPEND_TAGS,
content: { format: "empty", description: "Content field unused." },
},
[NOTE_TYPES.VIEW_EVENT]: {
type: NOTE_TYPES.VIEW_EVENT,
label: "View counter",
kind: WATCH_HISTORY_KIND,
topicTag: { name: "t", value: "view" },
identifierTag: { name: "d" },
sessionTag: { name: "session", value: "true" },
appendTags: DEFAULT_APPEND_TAGS,
content: {
format: "text",
description: "Optional plaintext content used for diagnostics.",
},
},
[NOTE_TYPES.VIDEO_REACTION]: {
type: NOTE_TYPES.VIDEO_REACTION,
label: "Reaction event",
kind: 7,
appendTags: DEFAULT_APPEND_TAGS,
content: {
format: "text",
description:
"Reaction payload for the referenced event (e.g., '+', '-', or emoji).",
},
},
[NOTE_TYPES.VIDEO_COMMENT]: {
type: NOTE_TYPES.VIDEO_COMMENT,
label: "Video comment",
kind: 1111,
videoEventTagName: "E",
videoDefinitionTagName: "A",
parentCommentTagName: "E",
parentAuthorTagName: "P",
rootEventPointerTagName: "E",
rootDefinitionPointerTagName: "A",
rootIdentifierPointerTagName: "I",
rootKindTagName: "K",
rootAuthorTagName: "P",
parentKindTagName: "K",
appendTags: DEFAULT_APPEND_TAGS,
content: {
format: "text",
description: "Plain text comment body sanitized for UTF-8 compatibility.",
},
},
[NOTE_TYPES.DM_ATTACHMENT]: {
type: NOTE_TYPES.DM_ATTACHMENT,
label: "DM attachment (NIP-17 file rumor)",
kind: 15,
participantTagName: "p",
hashTagName: "x",
urlTagName: "url",
nameTagName: "name",
typeTagName: "type",
sizeTagName: "size",
keyTagName: "k",
appendTags: DEFAULT_APPEND_TAGS,
content: {
format: "empty",
description: "Attachment metadata is carried in tags; content is empty.",
},
},
[NOTE_TYPES.DM_READ_RECEIPT]: {
type: NOTE_TYPES.DM_READ_RECEIPT,
label: "DM read receipt",
kind: 20001,
recipientTagName: "p",
eventTagName: "e",
kindTagName: "k",
appendTags: DEFAULT_APPEND_TAGS,
content: {
format: "empty",
description: "Ephemeral read receipt pointer for a DM event.",
},
},
[NOTE_TYPES.DM_TYPING]: {
type: NOTE_TYPES.DM_TYPING,
label: "DM typing indicator",
kind: 20002,
recipientTagName: "p",
eventTagName: "e",
statusTagName: "t",
statusTagValue: "typing",
expirationTagName: "expiration",
appendTags: DEFAULT_APPEND_TAGS,
content: {
format: "empty",
description: "Ephemeral typing indicator with an expiration timestamp.",
},
},
[NOTE_TYPES.ZAP_REQUEST]: {
type: NOTE_TYPES.ZAP_REQUEST,
label: "Zap request",
kind: 9734,
appendTags: DEFAULT_APPEND_TAGS,
content: {
format: "text",
description:
"Optional zap message included in the zap request sent to LNURL pay callbacks.",
},
},
[NOTE_TYPES.ZAP_RECEIPT]: {
type: NOTE_TYPES.ZAP_RECEIPT,
label: "Zap receipt",
kind: 9735,
appendTags: DEFAULT_APPEND_TAGS,
content: {
format: "text",
description:
"Zap receipts are published by lightning wallets to confirm zap requests.",
},
},
[NOTE_TYPES.WATCH_HISTORY]: {
type: NOTE_TYPES.WATCH_HISTORY,
label: "Watch history month",
kind: WATCH_HISTORY_KIND,
identifierTag: {
name: "d",
},
monthTagName: "month",
appendTags: [["v", WATCH_HISTORY_VERSION_TAG_VALUE]],
content: {
format: "json",
description:
"JSON payload containing a month's watched event identifiers with optional watchedAt metadata.",
},
},
[NOTE_TYPES.SUBSCRIPTION_LIST]: {
type: NOTE_TYPES.SUBSCRIPTION_LIST,
label: "Subscription list",
kind: 30000,
identifierTag: {
name: "d",
value: SUBSCRIPTION_LIST_IDENTIFIER,
},
appendTags: [["encrypted", "nip44_v2"]],
content: {
format: "encrypted-tag-list",
description:
"NIP-04/NIP-44 encrypted JSON array of NIP-51 tag tuples (e.g., [['p', <hex>], …]).",
},
},
[NOTE_TYPES.SUBSCRIPTION_BACKUP]: {
type: NOTE_TYPES.SUBSCRIPTION_BACKUP,
label: "Subscription list backup",
kind: 30000,
identifierTag: {
name: "d",
},
appendTags: [["encrypted", "nip44_v2"]],
content: {
format: "encrypted-tag-list",
description:
"NIP-04/NIP-44 encrypted JSON array of NIP-51 tag tuples (e.g., [['p', <hex>], …]).",
},
},
[NOTE_TYPES.USER_BLOCK_LIST]: {
type: NOTE_TYPES.USER_BLOCK_LIST,
label: "User block list (legacy)",
kind: KIND_MUTE_LIST,
identifierTag: {
name: "d",
value: BLOCK_LIST_IDENTIFIER,
},
appendTags: DEFAULT_APPEND_TAGS,
content: {
format: "encrypted-tag-list",
description:
"Legacy NIP-51 block list using custom d tag. Migrated to standard Mute List (kind 10000).",
},
},
[NOTE_TYPES.HASHTAG_PREFERENCES]: {
type: NOTE_TYPES.HASHTAG_PREFERENCES,
label: "Hashtag preferences",
kind: 30015,
identifierTag: {
name: "d",
value: "bitvid:tag-preferences",
},
appendTags: [["encrypted", "nip44_v2"]],
content: {
format: "nip44-json",
description:
"NIP-44 encrypted JSON: { version, interests: string[], disinterests: 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." },
},
[NOTE_TYPES.PROFILE_METADATA]: {
type: NOTE_TYPES.PROFILE_METADATA,
label: "Profile metadata",
kind: 0,
appendTags: DEFAULT_APPEND_TAGS,
content: {
format: "json",
description: "Standard NIP-01 profile metadata (name, about, picture, etc.).",
},
},
[NOTE_TYPES.MUTE_LIST]: {
type: NOTE_TYPES.MUTE_LIST,
label: "Mute list",
kind: KIND_MUTE_LIST,
participantTagName: "p",
appendTags: DEFAULT_APPEND_TAGS,
content: {
format: "text",
description: "Standard NIP-51 Mute List. Public mutes in 'p' tags, private blocks in encrypted content.",
},
},
[NOTE_TYPES.DELETION]: {
type: NOTE_TYPES.DELETION,
label: "Deletion",
kind: 5,
appendTags: DEFAULT_APPEND_TAGS,
content: {
format: "text",
description: "Reason for deletion.",
},
},
[NOTE_TYPES.LEGACY_DM]: {
type: NOTE_TYPES.LEGACY_DM,
label: "Legacy Direct Message",
kind: 4,
appendTags: DEFAULT_APPEND_TAGS,
recipientTagName: "p",
content: {
format: "text",
description: "NIP-04 encrypted ciphertext.",
},
},
[NOTE_TYPES.HTTP_AUTH]: {
type: NOTE_TYPES.HTTP_AUTH,
label: "HTTP Auth",
kind: 27235,
domainTagName: "u",
methodTagName: "method",
payloadTagName: "payload",
appendTags: DEFAULT_APPEND_TAGS,
content: {
format: "text",
description: "Optional content (NIP-98 recommends empty, but some implementations use it).",
},
},
[NOTE_TYPES.REPORT]: {
type: NOTE_TYPES.REPORT,
label: "Report",
kind: 1984,
appendTags: DEFAULT_APPEND_TAGS,
content: {
format: "text",
description: "Report reason.",
},
},
[NOTE_TYPES.GIFT_WRAP]: {
type: NOTE_TYPES.GIFT_WRAP,
label: "Gift Wrap",
kind: 1059,
recipientTagName: "p",
appendTags: DEFAULT_APPEND_TAGS,
content: {
format: "text",
description: "NIP-44 encrypted seal.",
},
},
[NOTE_TYPES.SEAL]: {
type: NOTE_TYPES.SEAL,
label: "Seal",
kind: 13,
appendTags: DEFAULT_APPEND_TAGS,
content: {
format: "text",
description: "NIP-44 encrypted rumor.",
},
},
[NOTE_TYPES.CHAT_MESSAGE]: {
type: NOTE_TYPES.CHAT_MESSAGE,
label: "Chat Message (Rumor)",
kind: 14,
participantTagName: "p",
appendTags: DEFAULT_APPEND_TAGS,
content: {
format: "text",
description: "Plain text chat message.",
},
},
};
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;
}
/**
* Injects schema overrides for runtime configuration (e.g., feature flags).
* @param {Object} overrides - A deep object to merge onto BASE_SCHEMAS.
*/
export function setNostrEventSchemaOverrides(overrides = {}) {
if (overrides && typeof overrides === "object") {
try {
JSON.stringify(overrides);
schemaOverrides = overrides;
} catch (error) {
devLogger.warn(
"[nostrEventSchemas] Invalid overrides provided (circular structure?):",
error
);
schemaOverrides = {};
}
} else {
schemaOverrides = {};
}
if (typeof window !== "undefined") {
window.bitvidNostrEventOverrides = schemaOverrides;
}
}
/**
* Retrieves the schema configuration for a specific note type.
* Applies any active runtime overrides.
*
* @param {string} type - The NOTE_TYPES key.
* @returns {Object|null} Schema object or null if not found.
*/
export function getNostrEventSchema(type) {
if (typeof type !== "string") {
return null;
}
const base = BASE_SCHEMAS[type];
if (!base) {
return null;
}
const override = resolveOverride(type);
return mergeDeep(base, override);
}
/**
* Returns all schema definitions with overrides applied.
* @returns {Object.<string, Object>} Map of schema configurations by type.
*/
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;
}
function collectPointerTags(schema, {
pointerValue,
pointerTag,
pointerTags = [],
} = {}) {
const normalized = [];
const pointerTagName = schema?.pointerTagName;
if (pointerValue && pointerTagName) {
const normalizedValue =
typeof pointerValue === "string" ? pointerValue : String(pointerValue);
if (normalizedValue) {
normalized.push([pointerTagName, normalizedValue]);
}
}
if (Array.isArray(pointerTag) && pointerTag.length >= 2) {
normalized.push(
pointerTag.map((value) => (typeof value === "string" ? value : String(value)))
);
}
if (Array.isArray(pointerTags)) {
pointerTags.forEach((tag) => {
if (Array.isArray(tag) && tag.length >= 2) {
normalized.push(
tag.map((value) => (typeof value === "string" ? value : String(value)))
);
}
});
}
return normalized;
}
function mergePointerTags(pointerTags = []) {
if (!Array.isArray(pointerTags) || pointerTags.length === 0) {
return [];
}
const merged = [];
const pointerIndexByKey = new Map();
const toTrimmedString = (value) => {
if (typeof value === "string") {
return value.trim();
}
if (value === undefined || value === null) {
return "";
}
return String(value).trim();
};
pointerTags.forEach((tag) => {
if (!Array.isArray(tag) || tag.length < 2) {
return;
}
const normalizedType = toTrimmedString(tag[0]);
if (!normalizedType) {
return;
}
const canonicalType =
normalizedType === "a" ? "a" : normalizedType === "e" ? "e" : normalizedType;
const normalizedValue = toTrimmedString(tag[1]);
if (!normalizedValue) {
return;
}
const sanitizedTag = [canonicalType, normalizedValue];
for (let index = 2; index < tag.length; index += 1) {
const extra = toTrimmedString(tag[index]);
if (extra) {
sanitizedTag.push(extra);
}
}
if (canonicalType !== "a" && canonicalType !== "e") {
merged.push(sanitizedTag);
return;
}
const dedupeKey = `${canonicalType}:${normalizedValue.toLowerCase()}`;
const relayHint =
sanitizedTag.length > 2 && typeof sanitizedTag[2] === "string"
? sanitizedTag[2].trim()
: "";
if (!pointerIndexByKey.has(dedupeKey)) {
if (relayHint && sanitizedTag.length > 2) {
sanitizedTag[2] = relayHint;
}
const index = merged.length;
merged.push(sanitizedTag);
pointerIndexByKey.set(dedupeKey, { index, relay: relayHint });
return;
}
const existingEntry = pointerIndexByKey.get(dedupeKey);
const existingTag = merged[existingEntry.index];
const existingRelay =
existingTag.length > 2 && typeof existingTag[2] === "string"
? existingTag[2].trim()
: "";
const hasExistingRelay = Boolean(existingRelay);
const hasNewRelay = Boolean(relayHint);
if (
(!hasExistingRelay && hasNewRelay) ||
(hasExistingRelay && hasNewRelay && existingRelay !== relayHint)
) {
const replacement = sanitizedTag.slice();
if (relayHint && replacement.length > 2) {
replacement[2] = relayHint;
}
merged[existingEntry.index] = replacement;
pointerIndexByKey.set(dedupeKey, {
index: existingEntry.index,
relay: relayHint,
});
}
});
return merged;
}
/**
* Builds a Video Post event (Kind 30078).
* Ensures required tags ('d', 's', 't') are present and content is serialized.
*
* @param {Object} params - Event parameters.
* @param {string} params.pubkey - Author hex pubkey.
* @param {number} params.created_at - Timestamp in seconds.
* @param {string} params.dTagValue - NIP-33 identifier (video slug).
* @param {Object|string} params.content - Video metadata payload.
* @param {Array<Array<string>>} [params.additionalTags] - Extra tags.
* @returns {Object} Unsigned Nostr event.
*/
export function buildVideoPostEvent(params) {
const {
pubkey,
created_at,
dTagValue,
content,
additionalTags = [],
} = params || {};
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);
const sanitizedAdditionalTags = sanitizeAdditionalTags(additionalTags);
if (sanitizedAdditionalTags.length) {
tags.push(...sanitizedAdditionalTags.map((tag) => tag.slice()));
}
const hasSTag = tags.some((t) => t[0] === "s");
if (!hasSTag) {
let contentObj = content;
if (typeof content === "string") {
try {
contentObj = JSON.parse(content);
} catch (e) {
contentObj = {};
}
}
contentObj = contentObj || {};
let pointer = "";
if (contentObj.infoHash) {
pointer = buildStoragePointerValue({
provider: "btih",
prefix: contentObj.infoHash,
});
} else if (contentObj.url) {
pointer = deriveStoragePointerFromUrl(contentObj.url);
}
if (!pointer && (dTagValue || contentObj.videoRootId)) {
const prefix = dTagValue || contentObj.videoRootId;
pointer = buildStoragePointerValue({ provider: "nostr", prefix });
}
if (pointer) {
tags.push(["s", pointer]);
}
}
let serializedContent = "";
if (typeof content === "string") {
serializedContent = content;
} else {
try {
serializedContent = JSON.stringify(content ?? {});
} catch (error) {
serializedContent = "{}";
}
}
const event = {
kind: schema?.kind ?? 30078,
pubkey,
created_at,
tags,
content: serializedContent,
};
if (isDevMode) {
validateEventAgainstSchema(NOTE_TYPES.VIDEO_POST, event);
}
return event;
}
/**
* Builds an HTTP Auth event (Kind 27235).
* Used for NIP-98 authentication.
*
* @param {Object} params - Auth params.
* @param {string} params.url - Target URL.
* @param {string} params.method - HTTP method.
* @returns {Object} Unsigned event.
*/
export function buildHttpAuthEvent(params) {
const {
pubkey,
created_at,
url,
method,
payload,
content,
additionalTags = [],
} = params || {};
const schema = getNostrEventSchema(NOTE_TYPES.HTTP_AUTH);
const tags = [];
const domainTagName = schema?.domainTagName || "u";
if (url) {
tags.push([domainTagName, url]);
}
const methodTagName = schema?.methodTagName || "method";
if (method) {
tags.push([methodTagName, method]);
}
const payloadTagName = schema?.payloadTagName || "payload";
if (payload) {
tags.push([payloadTagName, payload]);
}
appendSchemaTags(tags, schema);
const sanitizedAdditionalTags = sanitizeAdditionalTags(additionalTags);
if (sanitizedAdditionalTags.length) {
tags.push(...sanitizedAdditionalTags.map((tag) => tag.slice()));
}
const event = {
kind: schema?.kind ?? 27235,
pubkey,
created_at,
tags,
content: typeof content === "string" ? content : "",
};
if (isDevMode) {
validateEventAgainstSchema(NOTE_TYPES.HTTP_AUTH, event);
}
return event;
}
/**
* Builds a Report event (Kind 1984).
* Used for flagging content or users.
*
* @param {Object} params - Report params.
* @param {string} [params.eventId] - Reported event ID.
* @param {string} [params.userId] - Reported user pubkey.
* @param {string} [params.reportType] - Type of report (e.g., 'spam').
* @returns {Object} Unsigned event.
*/
export function buildReportEvent(params) {
const {
pubkey,
created_at,
eventId,
userId,
reportType,
relayHint,
content,
additionalTags = [],
} = params || {};
const schema = getNostrEventSchema(NOTE_TYPES.REPORT);
const tags = [];
const normalizedReportType = typeof reportType === "string" ? reportType.trim() : "";
const normalizedRelayHint = typeof relayHint === "string" ? relayHint.trim() : "";
if (eventId) {
const eTag = ["e", eventId];
if (normalizedReportType || normalizedRelayHint) {
eTag.push(normalizedReportType);
}
if (normalizedRelayHint) {
eTag.push(normalizedRelayHint);
}
tags.push(eTag);
}
if (userId) {
const pTag = ["p", userId];
if (normalizedReportType) {
pTag.push(normalizedReportType);
}
tags.push(pTag);
}
// Some implementations use 't' tag for report type too?
// ModerationService logic:
// tags.push(["t", normalizedType]);
// So we support it.
if (normalizedReportType) {
// Check if we should add it as 't' tag or if it's already in e/p
// ModerationService adds it explicitly
const hasTTag = tags.some(t => t[0] === "t" && t[1] === normalizedReportType);
if (!hasTTag) {
// ModerationService does: ["t", normalizedType]
// But let's check what NIP-56 says. It relies on e/p markers.
// But we should match what ModerationService expects/does to be safe.
// It adds ["t", normalizedType]
tags.push(["t", normalizedReportType]);
}
}
appendSchemaTags(tags, schema);
const sanitizedAdditionalTags = sanitizeAdditionalTags(additionalTags);
if (sanitizedAdditionalTags.length) {
tags.push(...sanitizedAdditionalTags.map((tag) => tag.slice()));
}
const event = {
kind: schema?.kind ?? 1984,
pubkey,
created_at,
tags,
content: typeof content === "string" ? content : "",
};
if (isDevMode) {
validateEventAgainstSchema(NOTE_TYPES.REPORT, event);
}
return event;
}
/**
* Builds a NIP-59 Gift Wrap event (Kind 1059).
* Contains an encrypted Seal event.
*
* @param {Object} params - Params.
* @param {string} params.recipientPubkey - Intended recipient.
* @param {string} params.ciphertext - Encrypted seal.
* @returns {Object} Unsigned event.
*/
export function buildGiftWrapEvent(params) {
const {
pubkey,
created_at,
recipientPubkey,
ciphertext = "",
relayHint,
additionalTags = [],
} = params || {};
const schema = getNostrEventSchema(NOTE_TYPES.GIFT_WRAP);
const tags = [];
const recipient = normalizePointerIdentifier(recipientPubkey);
if (recipient) {
const pTag = [schema?.recipientTagName || "p", recipient];
if (typeof relayHint === "string" && relayHint.trim()) {
pTag.push(relayHint.trim());
}
tags.push(pTag);
}
appendSchemaTags(tags, schema);
const sanitizedAdditionalTags = sanitizeAdditionalTags(additionalTags);
if (sanitizedAdditionalTags.length) {
tags.push(...sanitizedAdditionalTags.map((tag) => tag.slice()));
}
const event = {
kind: schema?.kind ?? 1059,
pubkey,
created_at,
tags,
content: typeof ciphertext === "string" ? ciphertext : "",
};
if (isDevMode) {
validateEventAgainstSchema(NOTE_TYPES.GIFT_WRAP, event);
}
return event;
}
/**
* Builds a NIP-59 Seal event (Kind 13).
* Wraps the actual rumor event.
*
* @param {Object} params - Params.
* @param {string} params.ciphertext - Encrypted rumor.
* @returns {Object} Unsigned event.
*/
export function buildSealEvent(params) {
const {
pubkey,
created_at,
ciphertext = "",
additionalTags = [],
} = params || {};
const schema = getNostrEventSchema(NOTE_TYPES.SEAL);
const tags = [];
appendSchemaTags(tags, schema);
const sanitizedAdditionalTags = sanitizeAdditionalTags(additionalTags);
if (sanitizedAdditionalTags.length) {
tags.push(...sanitizedAdditionalTags.map((tag) => tag.slice()));
}
const event = {
kind: schema?.kind ?? 13,
pubkey,
created_at,
tags,
content: typeof ciphertext === "string" ? ciphertext : "",
};
if (isDevMode) {
validateEventAgainstSchema(NOTE_TYPES.SEAL, event);
}
return event;
}
/**
* Builds a Chat Message rumor (Kind 14).
* Intended to be wrapped in a Seal.
*
* @param {Object} params - Params.
* @param {string} params.content - Chat message text.
* @returns {Object} Unsigned event.
*/
export function buildChatMessageEvent(params) {
const {
pubkey,
created_at,
recipientPubkey,
content = "",
additionalTags = [],
} = params || {};
const schema = getNostrEventSchema(NOTE_TYPES.CHAT_MESSAGE);
const tags = [];
const participant = normalizePointerIdentifier(recipientPubkey);
if (participant) {
tags.push([schema?.participantTagName || "p", participant]);
}
appendSchemaTags(tags, schema);
const sanitizedAdditionalTags = sanitizeAdditionalTags(additionalTags);
if (sanitizedAdditionalTags.length) {
tags.push(...sanitizedAdditionalTags.map((tag) => tag.slice()));
}
const event = {
kind: schema?.kind ?? 14,
pubkey,
created_at,
tags,
content: typeof content === "string" ? content : "",
};
if (isDevMode) {
validateEventAgainstSchema(NOTE_TYPES.CHAT_MESSAGE, event);
}
return event;
}
/**
* Builds a NIP-94 Video Mirror event (Kind 1063).
*
* @param {Object} params - Params.
* @returns {Object} Unsigned event.
*/
export function buildVideoMirrorEvent(params) {
const {
pubkey,
created_at,
tags = [],
content = "",
} = params || {};
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))));
}
});
}
const event = {
kind: schema?.kind ?? 1063,
pubkey,
created_at,
tags: combinedTags,
content: safeStringify(content),
};
if (isDevMode) {
validateEventAgainstSchema(NOTE_TYPES.VIDEO_MIRROR, event);
}
return event;
}
/**
* Builds a Repost (Kind 6) or Generic Repost (Kind 16) event.
*
* @param {Object} params - Repost params.
* @param {string} [params.eventId] - ID of event to repost.
* @param {string} [params.address] - NIP-33 address to repost.
* @param {Object} [params.targetEvent] - Full event object being reposted (for Kind 16).
* @returns {Object} Unsigned event.
*/
export function buildRepostEvent(params) {
const {
pubkey,
created_at,
eventId = "",
eventRelay = "",
address = "",
addressRelay = "",
authorPubkey = "",
additionalTags = [],
repostKind,
targetKind,
targetEvent = null,
serializedEvent = "",
} = params || {};
const schema = getNostrEventSchema(NOTE_TYPES.REPOST);
const tags = [];
const normalizedEventId = typeof eventId === "string" ? eventId.trim() : "";
const normalizedEventRelay =
typeof eventRelay === "string" ? eventRelay.trim() : "";
if (normalizedEventId) {
if (normalizedEventRelay) {
tags.push(["e", normalizedEventId, normalizedEventRelay]);
} else {
tags.push(["e", normalizedEventId]);
}
}
const normalizedAddress = typeof address === "string" ? address.trim() : "";
const normalizedAddressRelay = typeof addressRelay === "string" ? addressRelay.trim() : "";
if (normalizedAddress) {
if (normalizedAddressRelay) {
tags.push(["a", normalizedAddress, normalizedAddressRelay]);
} else {
tags.push(["a", normalizedAddress]);
}
}
const normalizedAuthorPubkey =
typeof authorPubkey === "string" ? authorPubkey.trim().toLowerCase() : "";
if (normalizedAuthorPubkey) {
tags.push(["p", normalizedAuthorPubkey]);
}
appendSchemaTags(tags, schema);
const sanitizedAdditionalTags = sanitizeAdditionalTags(additionalTags);
if (sanitizedAdditionalTags.length) {
tags.push(...sanitizedAdditionalTags.map((tag) => tag.slice()));
}
const normalizedRepostKind =
Number.isFinite(repostKind) && repostKind !== null
? Math.floor(repostKind)
: null;
const normalizedTargetKind =
Number.isFinite(targetKind) && targetKind !== null
? Math.floor(targetKind)
: null;
const resolvedKind = (() => {
if (normalizedRepostKind) {
return normalizedRepostKind;
}
if (normalizedTargetKind !== null) {
return normalizedTargetKind === 1 ? schema?.kind ?? 6 : 16;
}
return schema?.kind ?? 6;
})();
if (resolvedKind === 16 && normalizedTargetKind !== null) {
tags.push(["k", String(normalizedTargetKind)]);
}
const normalizedSerializedEvent =
typeof serializedEvent === "string" ? serializedEvent : "";
let normalizedTargetEvent =
targetEvent && typeof targetEvent === "object" ? targetEvent : null;
if (!normalizedTargetEvent && normalizedSerializedEvent) {
try {
normalizedTargetEvent = JSON.parse(normalizedSerializedEvent);
} catch (error) {
void error;
}
}
const hasProtectedTag = Array.isArray(normalizedTargetEvent?.tags)
? normalizedTargetEvent.tags.some(
(tag) => Array.isArray(tag) && tag.length && tag[0] === "-",
)
: false;
let content = "";
if (!hasProtectedTag) {
if (normalizedSerializedEvent) {
content = normalizedSerializedEvent;
} else if (normalizedTargetEvent) {
try {
content = JSON.stringify(normalizedTargetEvent);
} catch (error) {
void error;
}
}
}
const event = {
kind: resolvedKind,
pubkey,
created_at,
tags,
content,
};
if (isDevMode) {
const type = event.kind === 16 ? NOTE_TYPES.GENERIC_REPOST : NOTE_TYPES.REPOST;
validateEventAgainstSchema(type, event);
}
return event;
}
/**
* Builds a Share event (Kind 1).
* Typically used to share a video URL with text.
*
* @param {Object} params - Share params.
* @param {Object} [params.video] - Video metadata {id, pubkey} for tags.
* @returns {Object} Unsigned event.
*/
export function buildShareEvent(params) {
const {
pubkey,
created_at,
content = "",
video = null,
relays = [],
additionalTags = [],
} = params || {};
const schema = getNostrEventSchema(NOTE_TYPES.SHARE);
const tags = [];
const relayTagName = schema?.relayTagName || "r";
const readMarker = schema?.readMarker || "read";
const writeMarker = schema?.writeMarker || "write";
const normalizedVideoId = normalizePointerIdentifier(video?.id);
if (normalizedVideoId) {
tags.push(["e", normalizedVideoId, "", "mention"]);
}
const normalizedVideoPubkey = normalizePointerIdentifier(video?.pubkey);
if (normalizedVideoPubkey) {
tags.push(["p", normalizedVideoPubkey, "", "mention"]);
}
const seenRelayTags = new Set();
const pushRelayTag = (url, marker) => {
const normalizedUrl = typeof url === "string" ? url.trim() : "";
if (!normalizedUrl) {
return;
}
const normalizedMarker =
typeof marker === "string" ? marker.trim().toLowerCase() : "";
const resolvedMarker =
normalizedMarker === "read" || normalizedMarker === "write"
? normalizedMarker
: "";
const dedupeKey = `${normalizedUrl}|${resolvedMarker}`;
if (seenRelayTags.has(dedupeKey)) {
return;
}
seenRelayTags.add(dedupeKey);
if (resolvedMarker) {
const markerValue = resolvedMarker === "read" ? readMarker : writeMarker;
tags.push([relayTagName, normalizedUrl, markerValue]);
} else {
tags.push([relayTagName, normalizedUrl]);
}
};
if (Array.isArray(relays)) {
relays.forEach((entry) => {
if (typeof entry === "string") {
pushRelayTag(entry, "");
return;
}
if (Array.isArray(entry) && entry.length) {
if (entry[0] === relayTagName) {
pushRelayTag(entry[1], entry[2]);
return;
}
pushRelayTag(entry[0], entry[1]);
return;
}
if (entry && typeof entry === "object") {
const url =
typeof entry.url === "string"
? entry.url
: typeof entry.relay === "string"
? entry.relay
: "";
let marker = "";
if (typeof entry.mode === "string") {
marker = entry.mode;
} else if (typeof entry.marker === "string") {
marker = entry.marker;
} else if (entry.read === true && entry.write === false) {
marker = "read";
} else if (entry.write === true && entry.read === false) {
marker = "write";
}
pushRelayTag(url, marker);
}
});
}
appendSchemaTags(tags, schema);
const sanitizedAdditionalTags = sanitizeAdditionalTags(additionalTags);
if (sanitizedAdditionalTags.length) {
tags.push(...sanitizedAdditionalTags.map((tag) => tag.slice()));
}
const resolvedContent = ensureValidUtf8Content(content);
const event = {
kind: schema?.kind ?? 1,
pubkey,
created_at,
tags,
content: resolvedContent,
};
if (isDevMode) {
validateEventAgainstSchema(NOTE_TYPES.SHARE, event);
}
return event;
}
/**
* Builds a Relay List event (Kind 10002).
* NIP-65: Defines read/write relays for the user.
*
* @param {Object} params - Params.
* @param {Array<string|Object>} params.relays - List of relay URLs or objects with markers.
* @returns {Object} Unsigned event.
*/
export function buildRelayListEvent(params) {
const {
pubkey,
created_at,
relays = [],
additionalTags = [],
} = params || {};
const schema = getNostrEventSchema(NOTE_TYPES.RELAY_LIST);
const tags = [];
const relayTagName = schema?.relayTagName || "r";
const readMarker = schema?.readMarker || "read";
const writeMarker = schema?.writeMarker || "write";
if (Array.isArray(relays)) {
relays.forEach((entry) => {
let url = "";
let mode = "";
if (typeof entry === "string") {
url = entry;
} else if (entry && typeof entry === "object") {
if (typeof entry.url === "string") {
url = entry.url;
}
if (typeof entry.mode === "string") {
mode = entry.mode;
} else if (typeof entry.marker === "string") {
mode = entry.marker;
} else if (entry.read === true && entry.write === false) {
mode = "read";
} else if (entry.write === true && entry.read === false) {
mode = "write";
}
} else if (Array.isArray(entry) && entry.length >= 1) {
url = typeof entry[0] === "string" ? entry[0] : String(entry[0]);
if (entry.length > 1 && typeof entry[1] === "string") {
mode = entry[1];
}
}
const normalizedUrl = typeof url === "string" ? url.trim() : "";
if (!normalizedUrl) {
return;
}
const normalizedMode = typeof mode === "string" ? mode.trim().toLowerCase() : "";
if (normalizedMode === "read") {
tags.push([relayTagName, normalizedUrl, readMarker]);
} else if (normalizedMode === "write") {
tags.push([relayTagName, normalizedUrl, writeMarker]);
} else {
tags.push([relayTagName, normalizedUrl]);
}
});
}
appendSchemaTags(tags, schema);
const sanitizedAdditionalTags = sanitizeAdditionalTags(additionalTags);
if (sanitizedAdditionalTags.length) {
tags.push(...sanitizedAdditionalTags.map((tag) => tag.slice()));
}
const event = {
kind: schema?.kind ?? 10002,
pubkey,
created_at,
tags,
content: "",
};
if (isDevMode) {
validateEventAgainstSchema(NOTE_TYPES.RELAY_LIST, event);
}
return event;
}
/**
* Builds a DM Relay List event (Kind 10050).
* NIP-17: Hints for where to find DMs.
*
* @param {Object} params - Params.
* @param {Array<string>} params.relays - List of relay URLs.
* @returns {Object} Unsigned event.
*/
export function buildDmRelayListEvent(params) {
const {
pubkey,
created_at,
relays = [],
additionalTags = [],
} = params || {};
const schema = getNostrEventSchema(NOTE_TYPES.DM_RELAY_LIST);
const tags = [];
const relayTagName = schema?.relayTagName || "relay";
if (Array.isArray(relays)) {
relays.forEach((relay) => {
const normalized = typeof relay === "string" ? relay.trim() : "";
if (normalized) {
tags.push([relayTagName, normalized]);
}
});
}
appendSchemaTags(tags, schema);
const sanitizedAdditionalTags = sanitizeAdditionalTags(additionalTags);
if (sanitizedAdditionalTags.length) {
tags.push(...sanitizedAdditionalTags.map((tag) => tag.slice()));
}
const event = {
kind: schema?.kind ?? 10050,
pubkey,
created_at,
tags,
content: "",
};
if (isDevMode) {
validateEventAgainstSchema(NOTE_TYPES.DM_RELAY_LIST, event);
}
return event;
}
/**
* Builds a Profile Metadata event (Kind 0).
* NIP-01: User profile information.
*
* @param {Object} params - Params.
* @param {Object} params.metadata - Profile object {name, about, picture, ...}.
* @returns {Object} Unsigned event.
*/
export function buildProfileMetadataEvent(params) {
const {
pubkey,
created_at,
metadata = {},
content,
additionalTags = [],
} = params || {};
const schema = getNostrEventSchema(NOTE_TYPES.PROFILE_METADATA);
const tags = [];
appendSchemaTags(tags, schema);
const sanitizedAdditionalTags = sanitizeAdditionalTags(additionalTags);
if (sanitizedAdditionalTags.length) {
tags.push(...sanitizedAdditionalTags.map((tag) => tag.slice()));
}
let serializedContent = "";
if (typeof content === "string") {
serializedContent = content;
} else {
try {
serializedContent = JSON.stringify(metadata || {});
} catch (error) {
serializedContent = "{}";
}
}
const event = {
kind: schema?.kind ?? 0,
pubkey,
created_at,
tags,
content: serializedContent,
};
if (isDevMode) {
validateEventAgainstSchema(NOTE_TYPES.PROFILE_METADATA, event);
}
return event;
}
/**
* Builds a Mute List event (Kind 10000).
* NIP-51: Public mutes (p tags) and private blocks (encrypted content).
*
* @param {Object} params - Params.
* @param {Array<string>} params.pTags - Publicly muted pubkeys.
* @param {boolean} [params.encrypted] - If true, adds 'encrypted' tag.
* @returns {Object} Unsigned event.
*/
export function buildMuteListEvent(params) {
const {
pubkey,
created_at,
pTags = [],
content = "",
encrypted = false,
encryptionTag = "",
additionalTags = [],
} = params || {};
const schema = getNostrEventSchema(NOTE_TYPES.MUTE_LIST);
const tags = [];
const participantTagName = schema?.participantTagName || "p";
if (Array.isArray(pTags)) {
pTags.forEach((hex) => {
if (typeof hex === "string" && hex.trim()) {
tags.push([participantTagName, hex.trim()]);
}
});
}
if (encrypted && encryptionTag) {
tags.push(["encrypted", encryptionTag]);
}
appendSchemaTags(tags, schema);
const sanitizedAdditionalTags = sanitizeAdditionalTags(additionalTags);
if (sanitizedAdditionalTags.length) {
tags.push(...sanitizedAdditionalTags.map((tag) => tag.slice()));
}
const event = {
kind: schema?.kind ?? KIND_MUTE_LIST,
pubkey,
created_at,
tags,
content: typeof content === "string" ? content : "",
};
if (isDevMode) {
validateEventAgainstSchema(NOTE_TYPES.MUTE_LIST, event);
}
return event;
}
/**
* Builds a Deletion event (Kind 5).
* NIP-09: Request to delete past events.
*
* @param {Object} params - Params.
* @param {Array<string>} [params.eventIds] - IDs to delete.
* @param {Array<string>} [params.addresses] - NIP-33 addresses to delete.
* @returns {Object} Unsigned event.
*/
export function buildDeletionEvent(params) {
const {
pubkey,
created_at,
eventIds = [],
addresses = [],
reason = "",
additionalTags = [],
} = params || {};
const schema = getNostrEventSchema(NOTE_TYPES.DELETION);
const tags = [];
if (Array.isArray(eventIds)) {
eventIds.forEach((id) => {
const normalized = normalizePointerIdentifier(id);
if (normalized) {
tags.push(["e", normalized]);
}
});
}
if (Array.isArray(addresses)) {
addresses.forEach((addr) => {
const normalized = typeof addr === "string" ? addr.trim() : "";
if (normalized) {
tags.push(["a", normalized]);
}
});
}
appendSchemaTags(tags, schema);
const sanitizedAdditionalTags = sanitizeAdditionalTags(additionalTags);
if (sanitizedAdditionalTags.length) {
tags.push(...sanitizedAdditionalTags.map((tag) => tag.slice()));
}
const event = {
kind: schema?.kind ?? 5,
pubkey,
created_at,
tags,
content: typeof reason === "string" ? reason : "",
};
if (isDevMode) {
validateEventAgainstSchema(NOTE_TYPES.DELETION, event);
}
return event;
}
/**
* Builds a Legacy Direct Message event (Kind 4).
* NIP-04: Encrypted text message.
*
* @param {Object} params - Params.
* @param {string} params.recipientPubkey - Recipient hex key.
* @param {string} params.ciphertext - Encrypted content.
* @returns {Object} Unsigned event.
*/
export function buildLegacyDirectMessageEvent(params) {
const {
pubkey,
created_at,
recipientPubkey,
ciphertext = "",
additionalTags = [],
} = params || {};
const schema = getNostrEventSchema(NOTE_TYPES.LEGACY_DM);
const tags = [];
const recipient = normalizePointerIdentifier(recipientPubkey);
if (recipient) {
tags.push([schema?.recipientTagName || "p", recipient]);
}
appendSchemaTags(tags, schema);
const sanitizedAdditionalTags = sanitizeAdditionalTags(additionalTags);
if (sanitizedAdditionalTags.length) {
tags.push(...sanitizedAdditionalTags.map((tag) => tag.slice()));
}
const event = {
kind: schema?.kind ?? 4,
pubkey,
created_at,
tags,
content: typeof ciphertext === "string" ? ciphertext : "",
};
if (isDevMode) {
validateEventAgainstSchema(NOTE_TYPES.LEGACY_DM, event);
}
return event;
}
/**
* Builds a DM Attachment event (Kind 15).
* NIP-17: File rumor with metadata tags (x, url, etc.).
*
* @param {Object} params - Params.
* @param {Object} params.attachment - {url, x (hash), name, type, size, key}.
* @returns {Object} Unsigned event.
*/
export function buildDmAttachmentEvent(params) {
const {
pubkey,
created_at,
recipientPubkey,
attachment = {},
additionalTags = [],
} = params || {};
const schema = getNostrEventSchema(NOTE_TYPES.DM_ATTACHMENT);
const tags = [];
const participantTagName = schema?.participantTagName || "p";
if (recipientPubkey && typeof recipientPubkey === "string") {
tags.push([participantTagName, recipientPubkey.trim()]);
}
if (attachment?.x) {
tags.push([schema?.hashTagName || "x", attachment.x]);
}
if (attachment?.url) {
tags.push([schema?.urlTagName || "url", attachment.url]);
}
if (attachment?.name) {
tags.push([schema?.nameTagName || "name", attachment.name]);
}
if (attachment?.type) {
tags.push([schema?.typeTagName || "type", attachment.type]);
}
if (Number.isFinite(attachment?.size)) {
tags.push([schema?.sizeTagName || "size", String(attachment.size)]);
}
if (attachment?.key) {
tags.push([schema?.keyTagName || "k", attachment.key]);
}
appendSchemaTags(tags, schema);
const sanitizedAdditionalTags = sanitizeAdditionalTags(additionalTags);
if (sanitizedAdditionalTags.length) {
tags.push(...sanitizedAdditionalTags.map((tag) => tag.slice()));
}
const event = {
kind: schema?.kind ?? 15,
pubkey,
created_at,
tags,
content: "",
};
if (isDevMode) {
validateEventAgainstSchema(NOTE_TYPES.DM_ATTACHMENT, event);
}
return event;
}
/**
* Builds a DM Read Receipt event (Kind 20001).
*
* @param {Object} params - Params.
* @param {string} params.eventId - ID of message read.
* @returns {Object} Unsigned event.
*/
export function buildDmReadReceiptEvent(params) {
const {
pubkey,
created_at,
recipientPubkey,
eventId,
messageKind,
additionalTags = [],
} = params || {};
const schema = getNostrEventSchema(NOTE_TYPES.DM_READ_RECEIPT);
const tags = [];
const recipient = normalizePointerIdentifier(recipientPubkey);
if (recipient) {
tags.push([schema?.recipientTagName || "p", recipient]);
}
const normalizedEventId = normalizePointerIdentifier(eventId);
if (normalizedEventId) {
tags.push([schema?.eventTagName || "e", normalizedEventId]);
}
if (Number.isFinite(messageKind)) {
tags.push([schema?.kindTagName || "k", String(Math.floor(messageKind))]);
}
appendSchemaTags(tags, schema);
const sanitizedAdditionalTags = sanitizeAdditionalTags(additionalTags);
if (sanitizedAdditionalTags.length) {
tags.push(...sanitizedAdditionalTags.map((tag) => tag.slice()));
}
const event = {
kind: schema?.kind ?? 20001,
pubkey,
created_at,
tags,
content: "",
};
if (isDevMode) {
validateEventAgainstSchema(NOTE_TYPES.DM_READ_RECEIPT, event);
}
return event;
}
/**
* Builds a DM Typing Indicator event (Kind 20002).
* Ephemeral event to show typing status.
*
* @param {Object} params - Params.
* @param {number} params.expiresAt - Expiration timestamp.
* @returns {Object} Unsigned event.
*/
export function buildDmTypingIndicatorEvent(params) {
const {
pubkey,
created_at,
recipientPubkey,
eventId,
expiresAt,
additionalTags = [],
} = params || {};
const schema = getNostrEventSchema(NOTE_TYPES.DM_TYPING);
const tags = [];
const recipient = normalizePointerIdentifier(recipientPubkey);
if (recipient) {
tags.push([schema?.recipientTagName || "p", recipient]);
}
const normalizedEventId = normalizePointerIdentifier(eventId);
if (normalizedEventId) {
tags.push([schema?.eventTagName || "e", normalizedEventId]);
}
if (schema?.statusTagName && schema?.statusTagValue) {
tags.push([schema.statusTagName, schema.statusTagValue]);
}
if (Number.isFinite(expiresAt)) {
tags.push([schema?.expirationTagName || "expiration", String(Math.floor(expiresAt))]);
}
appendSchemaTags(tags, schema);
const sanitizedAdditionalTags = sanitizeAdditionalTags(additionalTags);
if (sanitizedAdditionalTags.length) {
tags.push(...sanitizedAdditionalTags.map((tag) => tag.slice()));
}
const event = {
kind: schema?.kind ?? 20002,
pubkey,
created_at,
tags,
content: "",
};
if (isDevMode) {
validateEventAgainstSchema(NOTE_TYPES.DM_TYPING, event);
}
return event;
}
/**
* Builds a View event (Kind 30078 or WATCH_HISTORY_KIND).
* Tracks analytics for video views.
*
* @param {Object} params - Params.
* @param {boolean} [params.includeSessionTag] - Whether to include a session marker.
* @returns {Object} Unsigned event.
*/
export function buildViewEvent(params) {
const {
pubkey,
created_at,
pointerValue,
pointerTag,
pointerTags = [],
dedupeTag,
includeSessionTag = false,
additionalTags = [],
content = "",
} = params || {};
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 normalizedPointerTags = collectPointerTags(schema, {
pointerValue,
pointerTag,
pointerTags,
});
normalizedPointerTags.forEach((tag) => {
tags.push(tag);
});
const sanitizedAdditionalTags = sanitizeAdditionalTags(additionalTags);
if (sanitizedAdditionalTags.length) {
tags.push(...sanitizedAdditionalTags.map((tag) => tag.slice()));
}
// No random fallback: view events must be parameterized-replaceable on the
// exact d-tag the caller supplies (publishViewEvent computes a deterministic,
// window-bucketed one). A random d-tag here would make every view a unique
// event and defeat relay-side dedupe, inflating view counts (item #4 audit).
const resolvedDedupeTag = typeof dedupeTag === "string" ? dedupeTag : "";
if (resolvedDedupeTag && schema?.identifierTag?.name) {
const identifierName = schema.identifierTag.name;
const hasDedupe = tags.some(
(tag) => tag[0] === identifierName && tag[1] === resolvedDedupeTag
);
if (!hasDedupe) {
tags.push([identifierName, resolvedDedupeTag]);
}
}
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);
const resolvedContent = ensureValidUtf8Content(content);
const event = {
kind: schema?.kind ?? WATCH_HISTORY_KIND,
pubkey,
created_at,
tags,
content: resolvedContent,
};
if (isDevMode) {
validateEventAgainstSchema(NOTE_TYPES.VIEW_EVENT, event);
}
return event;
}
/**
* Builds a Zap Request event (Kind 9734).
* Used to request an invoice from a Lightning Service Provider (LSP).
*
* @param {Object} params - Params.
* @param {number} params.amountSats - Amount in satoshis.
* @param {string} params.lnurl - LNURL of the recipient.
* @returns {Object} Unsigned event.
*/
export function buildZapRequestEvent(params) {
const {
pubkey,
created_at,
recipientPubkey,
relays = [],
amountSats,
lnurl,
eventId,
coordinate,
additionalTags = [],
content = "",
} = params || {};
const schema = getNostrEventSchema(NOTE_TYPES.ZAP_REQUEST);
const tags = [];
const normalizedRecipient = normalizePointerIdentifier(recipientPubkey);
if (normalizedRecipient) {
tags.push(["p", normalizedRecipient]);
}
const normalizedEventId = normalizePointerIdentifier(eventId);
if (normalizedEventId) {
tags.push(["e", normalizedEventId]);
}
if (typeof coordinate === "string" && coordinate.trim()) {
tags.push(["a", coordinate.trim()]);
}
if (typeof lnurl === "string" && lnurl.trim()) {
tags.push(["lnurl", lnurl.trim()]);
}
if (Number.isFinite(amountSats)) {
tags.push(["amount", String(Math.max(0, Math.round(amountSats)) * 1000)]);
}
const relayList = Array.isArray(relays) ? relays : [];
const relaySeen = new Set();
const normalizedRelays = [];
relayList.forEach((relay) => {
if (typeof relay !== "string") {
return;
}
const trimmed = relay.trim();
if (!trimmed || relaySeen.has(trimmed)) {
return;
}
relaySeen.add(trimmed);
normalizedRelays.push(trimmed);
});
if (normalizedRelays.length) {
tags.push(["relays", ...normalizedRelays]);
}
const sanitizedAdditionalTags = sanitizeAdditionalTags(additionalTags);
if (sanitizedAdditionalTags.length) {
tags.push(...sanitizedAdditionalTags.map((tag) => tag.slice()));
}
appendSchemaTags(tags, schema);
const resolvedContent = ensureValidUtf8Content(content);
const event = {
kind: schema?.kind ?? 9734,
pubkey,
created_at,
tags,
content: resolvedContent,
};
if (isDevMode) {
validateEventAgainstSchema(NOTE_TYPES.ZAP_REQUEST, event);
}
return event;
}
/**
* Builds a Reaction event (Kind 7).
* E.g., Like ('+'), Dislike ('-'), or emoji.
*
* @param {Object} params - Params.
* @param {string} params.content - Reaction content (+, -, emoji).
* @param {Object} [params.targetPointer] - Target event details.
* @returns {Object} Unsigned event.
*/
export function buildReactionEvent(params) {
const {
pubkey,
created_at,
pointerValue,
pointerTag,
pointerTags = [],
targetPointer = null,
targetAuthorPubkey = "",
additionalTags = [],
content = "",
} = params || {};
const schema = getNostrEventSchema(NOTE_TYPES.VIDEO_REACTION);
const tags = [];
const normalizedPointerTags = collectPointerTags(schema, {
pointerValue,
pointerTag,
pointerTags,
});
const mergedPointerTags = mergePointerTags(normalizedPointerTags);
mergedPointerTags.forEach((tag) => {
if (Array.isArray(tag) && tag.length >= 2) {
tags.push(tag);
}
});
const resolvePointerDetails = (pointerCandidate) => {
if (!pointerCandidate) {
return null;
}
if (Array.isArray(pointerCandidate) && pointerCandidate.length >= 2) {
const [type, value, relay] = pointerCandidate;
const normalizedType = type === "a" ? "a" : type === "e" ? "e" : "";
const normalizedValue = typeof value === "string" ? value.trim() : "";
if (!normalizedType || !normalizedValue) {
return null;
}
const normalizedRelay =
typeof relay === "string" && relay.trim() ? relay.trim() : "";
return { type: normalizedType, value: normalizedValue, relay: normalizedRelay };
}
if (pointerCandidate && typeof pointerCandidate === "object") {
const { type, value, relay } = pointerCandidate;
const normalizedType = type === "a" ? "a" : type === "e" ? "e" : "";
const normalizedValue = typeof value === "string" ? value.trim() : "";
if (!normalizedType || !normalizedValue) {
return null;
}
const normalizedRelay =
typeof relay === "string" && relay.trim() ? relay.trim() : "";
return { type: normalizedType, value: normalizedValue, relay: normalizedRelay };
}
return null;
};
const pointerDetails = (() => {
const explicitPointer = resolvePointerDetails(targetPointer);
if (explicitPointer) {
return explicitPointer;
}
const pointerTagEntry = mergedPointerTags.find(
(tag) => Array.isArray(tag) && tag.length >= 2 && (tag[0] === "a" || tag[0] === "e")
);
if (!pointerTagEntry) {
return null;
}
return resolvePointerDetails(pointerTagEntry);
})();
const resolvedRelay = pointerDetails?.relay || "";
const normalizedAuthorPubkey = (() => {
if (typeof targetAuthorPubkey === "string" && targetAuthorPubkey.trim()) {
return targetAuthorPubkey.trim();
}
if (pointerDetails?.type === "a" && pointerDetails.value) {
const segments = pointerDetails.value.split(":");
if (segments.length >= 2) {
const candidate = segments[1]?.trim();
if (candidate) {
return candidate;
}
}
}
return "";
})();
if (normalizedAuthorPubkey) {
const existingAuthorTag = tags.find(
(tag) => Array.isArray(tag) && tag[0] === "p" && tag[1] === normalizedAuthorPubkey
);
if (!existingAuthorTag) {
if (resolvedRelay) {
tags.push(["p", normalizedAuthorPubkey, resolvedRelay]);
} else {
tags.push(["p", normalizedAuthorPubkey]);
}
} else if (resolvedRelay) {
if (existingAuthorTag.length < 3) {
existingAuthorTag.push(resolvedRelay);
} else if (existingAuthorTag[2] !== resolvedRelay) {
existingAuthorTag[2] = resolvedRelay;
}
}
}
const sanitizedAdditionalTags = sanitizeAdditionalTags(additionalTags);
if (sanitizedAdditionalTags.length) {
tags.push(...sanitizedAdditionalTags.map((tag) => tag.slice()));
}
appendSchemaTags(tags, schema);
const resolvedContent = ensureValidUtf8Content(content);
const event = {
kind: schema?.kind ?? 7,
pubkey,
created_at,
tags,
content: resolvedContent,
};
if (isDevMode) {
validateEventAgainstSchema(NOTE_TYPES.VIDEO_REACTION, event);
}
return event;
}
function normalizeCommentInputs(params) {
const normalizeString = (value) =>
typeof value === "string" ? value.trim() : "";
return {
normalizedVideoEventId: normalizeString(params.videoEventId),
normalizedVideoEventRelay: normalizeString(params.videoEventRelay),
normalizedVideoDefinitionAddress: normalizeString(params.videoDefinitionAddress),
normalizedVideoDefinitionRelay: normalizeString(params.videoDefinitionRelay),
normalizedRootIdentifier: normalizeString(params.rootIdentifier),
normalizedRootIdentifierRelay: normalizeString(params.rootIdentifierRelay),
normalizedParentCommentId: normalizeString(params.parentCommentId),
normalizedParentCommentRelay: normalizeString(params.parentCommentRelay),
normalizedThreadParticipantPubkey: normalizeString(params.threadParticipantPubkey),
normalizedThreadParticipantRelay: normalizeString(params.threadParticipantRelay),
normalizedParentIdentifier: normalizeString(params.parentIdentifier),
normalizedParentIdentifierRelay: normalizeString(params.parentIdentifierRelay),
resolvedRootKind: normalizeString(params.rootKind),
resolvedRootAuthorPubkey: normalizeString(params.rootAuthorPubkey),
resolvedRootAuthorRelay: normalizeString(params.rootAuthorRelay),
resolvedParentAuthorPubkey: normalizeString(params.parentAuthorPubkey),
resolvedParentAuthorRelay: normalizeString(params.parentAuthorRelay),
resolvedParentKind: normalizeString(params.parentKind),
};
}
function resolveCommentContext(inputs, schema) {
let {
resolvedRootKind,
resolvedRootAuthorPubkey,
resolvedRootAuthorRelay,
resolvedParentAuthorPubkey,
resolvedParentAuthorRelay,
resolvedParentKind,
normalizedVideoDefinitionAddress,
normalizedVideoDefinitionRelay,
normalizedThreadParticipantPubkey,
normalizedThreadParticipantRelay,
normalizedParentCommentId,
} = inputs;
if (normalizedVideoDefinitionAddress) {
const definitionSegments = normalizedVideoDefinitionAddress.split(":");
if (!resolvedRootKind && definitionSegments[0]) {
resolvedRootKind = definitionSegments[0];
}
if (!resolvedRootAuthorPubkey && definitionSegments[1]) {
resolvedRootAuthorPubkey = definitionSegments[1];
}
if (!resolvedRootAuthorRelay && normalizedVideoDefinitionRelay) {
resolvedRootAuthorRelay = normalizedVideoDefinitionRelay;
}
}
if (!resolvedParentAuthorPubkey) {
resolvedParentAuthorPubkey = normalizedThreadParticipantPubkey;
}
if (!resolvedParentAuthorRelay) {
resolvedParentAuthorRelay = normalizedThreadParticipantRelay;
}
if (!resolvedRootAuthorPubkey && !normalizedParentCommentId) {
resolvedRootAuthorPubkey = resolvedParentAuthorPubkey;
}
if (!resolvedRootAuthorRelay && !normalizedParentCommentId) {
resolvedRootAuthorRelay = resolvedParentAuthorRelay;
}
if (!resolvedParentAuthorPubkey && !normalizedParentCommentId) {
resolvedParentAuthorPubkey = resolvedRootAuthorPubkey;
}
if (!resolvedParentAuthorRelay && !normalizedParentCommentId) {
resolvedParentAuthorRelay = resolvedRootAuthorRelay;
}
if (!resolvedParentKind) {
if (normalizedParentCommentId) {
resolvedParentKind = String(schema?.kind ?? 1111);
} else if (resolvedRootKind) {
resolvedParentKind = resolvedRootKind;
}
}
if (!resolvedRootKind) {
resolvedRootKind = resolvedParentKind;
}
return {
resolvedRootKind,
resolvedRootAuthorPubkey,
resolvedRootAuthorRelay,
resolvedParentAuthorPubkey,
resolvedParentAuthorRelay,
resolvedParentKind,
};
}
function buildCommentTags(schema, inputs, context) {
const {
normalizedVideoEventId,
normalizedVideoEventRelay,
normalizedVideoDefinitionAddress,
normalizedVideoDefinitionRelay,
normalizedRootIdentifier,
normalizedRootIdentifierRelay,
normalizedParentCommentId,
normalizedParentCommentRelay,
normalizedParentIdentifier,
normalizedParentIdentifierRelay,
} = inputs;
const {
resolvedRootKind,
resolvedRootAuthorPubkey,
resolvedRootAuthorRelay,
resolvedParentAuthorPubkey,
resolvedParentAuthorRelay,
resolvedParentKind,
} = context;
const tags = [];
const rootDefinitionPointerTagName = schema?.rootDefinitionPointerTagName || "A";
const rootEventPointerTagName = schema?.rootEventPointerTagName || "E";
const rootIdentifierPointerTagName = schema?.rootIdentifierPointerTagName || "I";
const rootKindTagName = schema?.rootKindTagName || "K";
const rootAuthorTagName = schema?.rootAuthorTagName || "P";
const parentIdentifierTagName = schema?.parentIdentifierTagName || "I";
const videoDefinitionTagName = schema?.videoDefinitionTagName || "A";
const videoEventTagName = schema?.videoEventTagName || "E";
const parentCommentTagName = schema?.parentCommentTagName || "E";
const parentAuthorTagName = schema?.parentAuthorTagName || "P";
const parentKindTagName = schema?.parentKindTagName || "K";
const appendPointerTag = (tagName, value, relay, authorHint) => {
if (!tagName || !value) {
return;
}
const tag = [tagName, value];
if (relay) {
tag.push(relay);
}
if (authorHint) {
tag.push(authorHint);
}
const isDuplicate = tags.some(
(existing) =>
existing.length === tag.length &&
existing.every((val, index) => val === tag[index]),
);
if (!isDuplicate) {
tags.push(tag);
}
};
if (normalizedRootIdentifier) {
appendPointerTag(
rootIdentifierPointerTagName,
normalizedRootIdentifier,
normalizedRootIdentifierRelay,
);
} else if (normalizedVideoDefinitionAddress) {
appendPointerTag(
rootDefinitionPointerTagName,
normalizedVideoDefinitionAddress,
normalizedVideoDefinitionRelay,
);
} else if (normalizedVideoEventId) {
appendPointerTag(
rootEventPointerTagName,
normalizedVideoEventId,
normalizedVideoEventRelay,
resolvedRootAuthorPubkey,
);
}
if (rootKindTagName && resolvedRootKind) {
tags.push([rootKindTagName, resolvedRootKind]);
}
if (rootAuthorTagName && resolvedRootAuthorPubkey) {
if (resolvedRootAuthorRelay) {
tags.push([rootAuthorTagName, resolvedRootAuthorPubkey, resolvedRootAuthorRelay]);
} else {
tags.push([rootAuthorTagName, resolvedRootAuthorPubkey]);
}
}
const shouldAppendVideoEventTag =
Boolean(videoEventTagName) && Boolean(normalizedVideoEventId);
if (shouldAppendVideoEventTag) {
appendPointerTag(
videoEventTagName,
normalizedVideoEventId,
normalizedVideoEventRelay,
);
}
if (videoDefinitionTagName && normalizedVideoDefinitionAddress) {
appendPointerTag(
videoDefinitionTagName,
normalizedVideoDefinitionAddress,
normalizedVideoDefinitionRelay,
);
}
if (normalizedParentIdentifier) {
appendPointerTag(
parentIdentifierTagName,
normalizedParentIdentifier,
normalizedParentIdentifierRelay,
);
}
if (parentCommentTagName && normalizedParentCommentId) {
appendPointerTag(
parentCommentTagName,
normalizedParentCommentId,
normalizedParentCommentRelay,
resolvedParentAuthorPubkey,
);
}
if (parentKindTagName && resolvedParentKind) {
tags.push([parentKindTagName, resolvedParentKind]);
}
if (parentAuthorTagName && resolvedParentAuthorPubkey) {
const existingParentAuthorTag = tags.find(
(tag) => Array.isArray(tag) && tag[0] === parentAuthorTagName && tag[1] === resolvedParentAuthorPubkey,
);
if (!existingParentAuthorTag) {
if (resolvedParentAuthorRelay) {
tags.push([parentAuthorTagName, resolvedParentAuthorPubkey, resolvedParentAuthorRelay]);
} else {
tags.push([parentAuthorTagName, resolvedParentAuthorPubkey]);
}
} else if (resolvedParentAuthorRelay) {
if (existingParentAuthorTag.length < 3) {
existingParentAuthorTag.push(resolvedParentAuthorRelay);
} else if (existingParentAuthorTag[2] !== resolvedParentAuthorRelay) {
existingParentAuthorTag[2] = resolvedParentAuthorRelay;
}
}
}
return tags;
}
/**
* Builds a Video Comment event (Kind 1111).
* Supports threaded replies via complex tag structures (root, parent, video def).
*
* @param {Object} params - Comment params including root/parent references.
* @param {string} params.content - Comment text.
* @returns {Object} Unsigned event.
*/
export function buildCommentEvent(params) {
const {
pubkey,
created_at,
additionalTags = [],
content = "",
} = params || {};
const schema = getNostrEventSchema(NOTE_TYPES.VIDEO_COMMENT);
const inputs = normalizeCommentInputs(params || {});
const context = resolveCommentContext(inputs, schema);
const tags = buildCommentTags(schema, inputs, context);
const sanitizedAdditionalTags = sanitizeAdditionalTags(additionalTags);
if (sanitizedAdditionalTags.length) {
tags.push(...sanitizedAdditionalTags.map((tag) => tag.slice()));
}
appendSchemaTags(tags, schema);
const resolvedContent = ensureValidUtf8Content(content);
const event = {
kind: schema?.kind ?? 1111,
pubkey,
created_at,
tags,
content: resolvedContent,
};
if (isDevMode) {
validateEventAgainstSchema(NOTE_TYPES.VIDEO_COMMENT, event);
}
return event;
}
/**
* Builds a Watch History event (Kind 30078 variant).
* Stores watched video IDs for a specific month.
*
* @param {Object} params - Params.
* @param {string} params.monthIdentifier - 'YYYY-MM' slug.
* @param {Array<Array<string>>} params.pointerTags - Watched items (v tags).
* @returns {Object} Unsigned event.
*/
export function buildWatchHistoryEvent(params) {
const {
pubkey,
created_at,
monthIdentifier,
pointerTags = [],
content,
} = params || {};
const schema = getNostrEventSchema(NOTE_TYPES.WATCH_HISTORY);
const tags = [];
const identifierName = schema?.identifierTag?.name || "d";
const defaultMonth = Number.isFinite(created_at)
? new Date(created_at * 1000).toISOString().slice(0, 7)
: new Date().toISOString().slice(0, 7);
const identifierValue =
(typeof monthIdentifier === "string" && monthIdentifier.trim()) ||
schema?.identifierTag?.value ||
defaultMonth;
if (identifierName && identifierValue) {
tags.push([identifierName, identifierValue]);
}
const monthTagName = schema?.monthTagName || "month";
if (monthTagName && identifierValue) {
tags.push([monthTagName, identifierValue]);
}
if (Array.isArray(pointerTags)) {
pointerTags.forEach((tag) => {
if (Array.isArray(tag) && tag.length >= 2) {
tags.push(tag.map((value) => (typeof value === "string" ? value : String(value))));
}
});
}
appendSchemaTags(tags, schema);
const event = {
kind: schema?.kind ?? WATCH_HISTORY_KIND,
pubkey,
created_at: Number.isFinite(created_at) ? created_at : Math.floor(Date.now() / 1000),
tags,
content: safeStringify(content),
};
if (isDevMode) {
validateEventAgainstSchema(NOTE_TYPES.WATCH_HISTORY, event);
}
return event;
}
/**
* Builds a Subscription List event (Kind 30000).
* Stores user channel subscriptions (NIP-51).
*
* @param {Object} params - Params.
* @param {string} [params.encryption] - NIP-04/44 encryption payload.
* @returns {Object} Unsigned event.
*/
export function buildSubscriptionListEvent(params) {
const {
pubkey,
created_at,
content,
encryption,
} = params || {};
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);
const normalizedEncryption =
typeof encryption === "string" ? encryption.trim() : "";
if (normalizedEncryption) {
let updated = false;
for (const tag of tags) {
if (Array.isArray(tag) && tag[0] === "encrypted") {
tag[1] = normalizedEncryption;
updated = true;
break;
}
}
if (!updated) {
tags.push(["encrypted", normalizedEncryption]);
}
}
const event = {
kind: schema?.kind ?? 30000,
pubkey,
created_at,
tags,
content: safeStringify(content),
};
if (isDevMode) {
validateEventAgainstSchema(NOTE_TYPES.SUBSCRIPTION_LIST, event);
}
return event;
}
/**
* Builds a User Block List event (Legacy).
*
* @param {Object} params - Params.
* @returns {Object} Unsigned event.
*/
export function buildBlockListEvent(params) {
const {
pubkey,
created_at,
content,
encryption,
} = params || {};
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]);
}
const normalizedEncryption =
typeof encryption === "string" ? encryption.trim() : "";
if (normalizedEncryption) {
tags.push(["encrypted", normalizedEncryption]);
}
appendSchemaTags(tags, schema);
const event = {
kind: schema?.kind ?? 10000,
pubkey,
created_at,
tags,
content: safeStringify(content),
};
if (isDevMode) {
validateEventAgainstSchema(NOTE_TYPES.USER_BLOCK_LIST, event);
}
return event;
}
/**
* Builds a Hashtag Preferences event (Kind 30015).
* Stores user tag interests/blocks.
*
* @param {Object} params - Params.
* @returns {Object} Unsigned event.
*/
export function buildHashtagPreferenceEvent(params) {
const {
pubkey,
created_at,
content,
} = params || {};
const schema = getNostrEventSchema(NOTE_TYPES.HASHTAG_PREFERENCES);
const tags = [];
const identifierName = schema?.identifierTag?.name || "d";
const identifierValue = schema?.identifierTag?.value || "bitvid:tag-preferences";
if (identifierName && identifierValue) {
tags.push([identifierName, identifierValue]);
}
appendSchemaTags(tags, schema);
const event = {
kind: schema?.kind ?? 30015,
pubkey,
created_at,
tags,
content: safeStringify(content),
};
if (isDevMode) {
validateEventAgainstSchema(NOTE_TYPES.HASHTAG_PREFERENCES, event);
}
return event;
}
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;
}
}
/**
* Builds an Admin List event (Kind 30000).
* Used for moderation lists (whitelist, blacklist, etc.).
*
* @param {string} listKey - 'moderation', 'whitelist', 'blacklist'.
* @param {Object} params - Params.
* @param {Array<string>} params.hexPubkeys - List of affected users.
* @returns {Object} Unsigned event.
*/
export function buildAdminListEvent(listKey, params) {
const {
pubkey,
created_at,
hexPubkeys = []
} = params || {};
const schema = getNostrEventSchema(resolveAdminNoteType(listKey));
if (!schema) {
devLogger.warn(
`[nostrEventSchemas] Unknown admin list key: ${safeStringify(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";
if (Array.isArray(hexPubkeys)) {
hexPubkeys.forEach((hex) => {
if (typeof hex === "string" && hex.trim()) {
tags.push([participantTagName, hex.trim()]);
}
});
}
appendSchemaTags(tags, schema);
const event = {
kind: schema?.kind ?? 30000,
pubkey,
created_at,
tags,
content: "",
};
if (isDevMode) {
const resolvedType = resolveAdminNoteType(listKey);
if (resolvedType) {
validateEventAgainstSchema(resolvedType, event);
}
}
return event;
}
function hasTag(tags, tagName, tagValue = null) {
if (!Array.isArray(tags)) return false;
return tags.some(
(tag) =>
Array.isArray(tag) &&
tag[0] === tagName &&
(tagValue === null || tag[1] === tagValue)
);
}
function hasTagName(tags, tagName) {
if (!Array.isArray(tags)) return false;
return tags.some((tag) => {
if (!Array.isArray(tag) || tag[0] !== tagName) {
return false;
}
if (tag.length < 2 || typeof tag[1] !== "string") {
return false;
}
return tag[1].trim().length > 0;
});
}
/**
* Validates an event object against the schema for a given type.
* Checks kind, required tags, and content structure.
*
* @param {string} type - NOTE_TYPES key.
* @param {Object} event - The event object to validate.
* @returns {{valid: boolean, errors: Array<string>}} Validation result.
*/
export function validateEventStructure(type, event) {
const schema = getNostrEventSchema(type);
const errors = [];
if (!event || typeof event !== "object") {
return { valid: false, errors: ["Event is null or not an object"] };
}
if (!schema) {
return { valid: false, errors: [`Unknown note type: ${type}`] };
}
if (event.kind !== schema.kind) {
errors.push(
`Kind mismatch for ${type}: expected ${schema.kind}, got ${event.kind}`
);
}
if (schema.topicTag) {
if (!hasTag(event.tags, schema.topicTag.name, schema.topicTag.value)) {
errors.push(
`Missing topic tag for ${type}: ${schema.topicTag.name}=${schema.topicTag.value}`
);
}
}
if (schema.identifierTag) {
const expectedValue = schema.identifierTag.value;
if (!hasTag(event.tags, schema.identifierTag.name, expectedValue)) {
errors.push(
`Missing identifier tag for ${type}: ${schema.identifierTag.name}${
expectedValue ? "=" + expectedValue : ""
}`
);
}
}
if (Array.isArray(schema.requiredTagNames)) {
schema.requiredTagNames.forEach((tagName) => {
if (!hasTagName(event.tags, tagName)) {
errors.push(`Missing required tag for ${type}: ${tagName}`);
}
});
}
if (schema.appendTags) {
schema.appendTags.forEach((appendTag) => {
if (Array.isArray(appendTag) && appendTag.length >= 2) {
const tagName = appendTag[0];
const tagValue = appendTag[1];
if (!hasTag(event.tags, tagName, tagValue)) {
errors.push(
`Missing append tag for ${type}: ${tagName}=${tagValue}`
);
}
}
});
}
if (schema.content) {
if (schema.content.format === "json") {
let parsed = null;
try {
parsed = JSON.parse(event.content);
} catch (e) {
errors.push(`Content is not valid JSON for ${type}`);
}
if (parsed && schema.content.fields) {
schema.content.fields.forEach((field) => {
const value = parsed[field.key];
if (field.required && (value === undefined || value === null)) {
errors.push(`Missing required field in JSON content: ${field.key}`);
}
if (
value !== undefined &&
value !== null &&
field.type &&
typeof value !== field.type
) {
errors.push(
`Field '${field.key}' should be of type ${field.type}, got ${typeof value}`
);
}
});
}
} else if (schema.content.format === "empty") {
if (event.content !== "") {
errors.push(`Content should be empty for ${type}`);
}
}
}
return { valid: errors.length === 0, errors };
}
export function validateEventAgainstSchema(type, event) {
if (!isDevMode || !event) return;
const { valid, errors } = validateEventStructure(type, event);
if (!valid) {
errors.forEach((msg) => devLogger.warn(`[schema] ${msg}`));
}
}
if (typeof window !== "undefined") {
window.bitvidNostrEvents = {
NOTE_TYPES,
getSchema: getNostrEventSchema,
getAllSchemas: getAllNostrEventSchemas,
setOverrides: setNostrEventSchemaOverrides,
validateEvent: validateEventAgainstSchema,
validateEventStructure,
};
}