/** * @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"; import { inferImageMimeTypeFromUrl } from "./utils/mime.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", ZAP_TALLY: "zapTally", PLAYLIST: "playlist", SUBMISSION: "submission", 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", ADMIN_EVENT_BLACKLIST: "adminEventBlacklist", 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", eventBlacklist: "event-blacklist", communityBlacklistSources: ADMIN_COMMUNITY_BLACKLIST_SOURCES, }); export { ADMIN_COMMUNITY_BLACKLIST_SOURCES }; export { ADMIN_COMMUNITY_BLACKLIST_PREFIX }; const DEFAULT_APPEND_TAGS = []; // bitvid-native zap tally event kind. Addressable (30000–39999) so `d = // payment_hash` yields one canonical event per real payment. In bitvid's family // (30078 video, 30079 view). See docs/zap-tally-plan.md. export const ZAP_TALLY_KIND = 30081; // bitvid-native creator playlist (#37). Addressable (30000–39999) so `d = // playlist id` yields one canonical, replaceable event per playlist. In bitvid's // family (30078 video, 30079 view, 30081 zap tally). Deliberately NOT NIP-51's // 30005 "video curation set": bitvid already reads 30005 as legacy interest-sets // (hashtagPreferencesService), so reusing it would collide. Ordered `a` tags // (30078:pubkey:d) reference the videos; order = tag order. export const PLAYLIST_KIND = 30082; // bitvid submission (#23): applications / appeals / bug / feature / feedback, // PUBLIC + structured so an admin "Submissions" tab can list + approve/deny them // (replacing the old kind-4 encrypted DM flow). Addressable (30000–39999) in // bitvid's family; published from an EPHEMERAL key (no login), so the `applicant` // tag carries the CLAIMED npub the admin vouches for. `d` = ephemeral id. export const SUBMISSION_KIND = 30083; 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>} additionalTags - Raw tags to sanitize. * @returns {Array>} 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.PLAYLIST]: { type: NOTE_TYPES.PLAYLIST, label: "Playlist", kind: PLAYLIST_KIND, topicTag: { name: "t", value: "playlist" }, identifierTag: { name: "d" }, appendTags: DEFAULT_APPEND_TAGS, content: { format: "text", description: "Reserved for future notes; playlist data lives in tags (title/description/image + ordered `a` video refs).", }, }, [NOTE_TYPES.SUBMISSION]: { type: NOTE_TYPES.SUBMISSION, label: "Submission", kind: SUBMISSION_KIND, topicTag: { name: "t", value: "bitvid-submission" }, identifierTag: { name: "d" }, appendTags: DEFAULT_APPEND_TAGS, content: { format: "text", description: "Free-text submission body (application reason, appeal, bug report, etc.). Structured fields live in tags: k (type), applicant (npub), p (recipient admin).", }, }, [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.ZAP_TALLY]: { // bitvid-native, payer-signed, preimage-verified zap tally (docs/zap-tally-plan.md). // Addressable (d = payment_hash) so each real payment maps to one canonical // event: relay-level idempotency, re-publish replaces rather than duplicates. // Counted alongside NIP-57 9735 receipts (deduped by payment_hash), so a // Most-Zapped tally survives when the recipient's wallet never publishes a 9735. type: NOTE_TYPES.ZAP_TALLY, label: "Zap tally", kind: ZAP_TALLY_KIND, identifierTag: { name: "d" }, appendTags: DEFAULT_APPEND_TAGS, content: { format: "text", description: "Optional zap message; the authoritative amount + proof live in the tags.", }, }, [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', ], …]).", }, }, [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', ], …]).", }, }, [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." }, }, // Per-event (per-video) admin block list (#25): hides a SINGLE offending event // without blocking its author. Same kind-30000 NIP-51 shape as the author lists, // but the entries are EVENT ids carried as `e` tags (not `p` pubkey tags). [NOTE_TYPES.ADMIN_EVENT_BLACKLIST]: { type: NOTE_TYPES.ADMIN_EVENT_BLACKLIST, label: "Admin event blacklist", kind: 30000, identifierTag: { name: "d", value: `${ADMIN_LIST_NAMESPACE}:${ADMIN_LIST_IDENTIFIERS.eventBlacklist}`, }, participantTagName: "e", 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.} 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>} [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 bitvid Playlist event (Kind 30082, addressable). All playlist data * lives in tags: `d` (id), `title`, `description`, `image`, and ordered `a` * refs (`30078:pubkey:d`) to the videos — order is preserved as tag order. * Republishing with the same `d` replaces the playlist (NIP-33). * * @param {Object} params * @param {string} params.pubkey * @param {number} params.created_at * @param {string} params.dTagValue stable playlist id (required) * @param {string} [params.title] * @param {string} [params.description] * @param {string} [params.image] * @param {string[]} [params.videoCoordinates] ordered "30078:pubkey:d" strings (`a`) * @param {string[]} [params.eventRefs] optional ordered raw event ids (`e`) * @param {string} [params.content] * @returns {Object} Unsigned event. */ export function buildPlaylistEvent(params) { const { pubkey, created_at, dTagValue, title = "", description = "", image = "", videoCoordinates = [], eventRefs = [], content = "", } = params || {}; const identifier = typeof dTagValue === "string" ? dTagValue.trim() : ""; if (!identifier) { throw new Error("A playlist requires a d-tag identifier."); } const schema = getNostrEventSchema(NOTE_TYPES.PLAYLIST); const tags = []; if (schema?.topicTag?.name && schema?.topicTag?.value) { tags.push([schema.topicTag.name, schema.topicTag.value]); } tags.push([schema?.identifierTag?.name || "d", identifier]); const trimmedTitle = typeof title === "string" ? title.trim() : ""; if (trimmedTitle) { tags.push(["title", trimmedTitle]); } const trimmedDescription = typeof description === "string" ? description.trim() : ""; if (trimmedDescription) { tags.push(["description", trimmedDescription]); } const trimmedImage = typeof image === "string" ? image.trim() : ""; if (trimmedImage) { tags.push(["image", trimmedImage]); } // Ordered video refs; dedupe so the same video can't appear twice. const seen = new Set(); if (Array.isArray(videoCoordinates)) { videoCoordinates.forEach((coord) => { const value = typeof coord === "string" ? coord.trim() : ""; if (value && !seen.has(`a:${value}`)) { seen.add(`a:${value}`); tags.push(["a", value]); } }); } if (Array.isArray(eventRefs)) { eventRefs.forEach((ref) => { const value = typeof ref === "string" ? ref.trim() : ""; if (value && !seen.has(`e:${value}`)) { seen.add(`e:${value}`); tags.push(["e", value]); } }); } appendSchemaTags(tags, schema); const event = { kind: schema?.kind ?? PLAYLIST_KIND, pubkey, created_at, tags, content: ensureValidUtf8Content(content), }; if (isDevMode) { validateEventAgainstSchema(NOTE_TYPES.PLAYLIST, event); } return event; } /** * Builds a bitvid Submission event (Kind 30083, addressable, PUBLIC). Structured * so an admin "Submissions" tab can list + approve/deny. Published from an * ephemeral key, so the `applicant` tag carries the CLAIMED npub. * * @param {Object} params * @param {string} params.pubkey the (ephemeral) author pubkey * @param {number} params.created_at * @param {string} params.dTagValue stable submission id (required) * @param {string} [params.submissionType] application | appeal | bug | feature | feedback * @param {string} [params.applicantNpub] the npub the submission is about * @param {string} [params.recipientPubkey] the admin hex pubkey (`p` tag) * @param {string} [params.content] free-text body * @returns {Object} Unsigned event. */ export function buildSubmissionEvent(params) { const { pubkey, created_at, dTagValue, submissionType = "application", applicantNpub = "", recipientPubkey = "", content = "", } = params || {}; const identifier = typeof dTagValue === "string" ? dTagValue.trim() : ""; if (!identifier) { throw new Error("A submission requires a d-tag identifier."); } const schema = getNostrEventSchema(NOTE_TYPES.SUBMISSION); const tags = []; if (schema?.topicTag?.name && schema?.topicTag?.value) { tags.push([schema.topicTag.name, schema.topicTag.value]); } tags.push([schema?.identifierTag?.name || "d", identifier]); const type = typeof submissionType === "string" && submissionType.trim() ? submissionType.trim() : "application"; tags.push(["k", type]); const applicant = typeof applicantNpub === "string" ? applicantNpub.trim() : ""; if (applicant) { tags.push(["applicant", applicant]); } const recipient = normalizePointerIdentifier(recipientPubkey); if (recipient) { tags.push(["p", recipient]); } appendSchemaTags(tags, schema); const event = { kind: schema?.kind ?? SUBMISSION_KIND, pubkey, created_at, tags, content: ensureValidUtf8Content(content), }; if (isDevMode) { validateEventAgainstSchema(NOTE_TYPES.SUBMISSION, 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"]); } // Addressable coordinate of the video's NIP-71 mirror, when one exists. Lets // quote-aware clients and indexers link the share note to the video event // that its body quotes via `nostr:naddr1…`. const mirrorCoordinate = typeof video?.mirrorCoordinate === "string" ? video.mirrorCoordinate.trim() : ""; if (/^\d+:[0-9a-f]{64}:/i.test(mirrorCoordinate)) { tags.push(["a", mirrorCoordinate, "", "mention"]); } // NIP-92 `imeta` for the thumbnail. The share content ends with the raw // thumbnail URL, and many clients only render such a URL inline when an // imeta entry describes it — without this the viewer just sees a bare link. // Only emitted for http(s) thumbnails: relative paths and data: URIs are // meaningless to a reader on another client. // // SKIPPED when the note quotes a mirror: the quote card already renders the // video with its own thumbnail, and the body carries no bare image URL to // describe. Emitting both showed the same image twice. const quotesMirror = typeof video?.mirrorNaddr === "string" && video.mirrorNaddr.trim() !== ""; const thumbnailUrl = typeof video?.thumbnail === "string" ? video.thumbnail.trim() : ""; if (!quotesMirror && /^https?:\/\//i.test(thumbnailUrl)) { const imeta = ["imeta", `url ${thumbnailUrl}`]; // Omit `m` entirely when the extension is unrecognized. A wrong media type // is worse than a missing one — clients use it to decide whether to render // the URL as an image at all. const mimeType = inferImageMimeTypeFromUrl(thumbnailUrl); if (mimeType) { imeta.push(`m ${mimeType}`); } // Alt text for screen readers, and a caption fallback in clients that show // it. Newlines would corrupt the space-delimited imeta entry, so collapse. const altText = typeof video?.title === "string" ? video.title.replace(/\s+/g, " ").trim() : ""; if (altText) { imeta.push(`alt ${altText}`); } tags.push(imeta); } 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} 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} 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} 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} [params.eventIds] - IDs to delete. * @param {Array} [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; } // --- WebTorrent piece-map companion event (NIP-78 app data) ------------------ // Restores P2P for Blossom-hosted videos (whose magnet is webseed-only because // Blossom rejects a .torrent) by publishing the torrent metadata as a separate, // infohash-keyed event that bitvid fetches lazily on the torrent path. // See docs/blossom-torrent-metadata-plan.md. export const TORRENT_METADATA_KIND = 30078; export const TORRENT_METADATA_DTAG_PREFIX = "bitvid:torrent:"; export const TORRENT_METADATA_ENVELOPE_VERSION = 1; // Normalize an infohash to the lowercase-hex form used in the addressing key. // BitTorrent v1 (WebTorrent) infohashes are 40 hex chars. export function normalizeInfoHash(infoHash) { const hex = typeof infoHash === "string" ? infoHash.trim().toLowerCase() : ""; return /^[0-9a-f]{40}$/.test(hex) ? hex : ""; } // The parameterized-replaceable `d`-tag value that addresses a video's piece-map. export function torrentMetadataDTag(infoHash) { const hex = normalizeInfoHash(infoHash); return hex ? `${TORRENT_METADATA_DTAG_PREFIX}${hex}` : ""; } /** * Builds the WebTorrent piece-map companion event (kind 30078). The `content` is a * versioned JSON envelope carrying the full `.torrent` file bytes (base64); the * `info` dict inside is what determines the infohash, so storing the raw bytes * keeps the reconstructed infohash exact. Addressed by `d = bitvid:torrent:` * so bitvid can look it up from a magnet's `btih`. * * @param {Object} params * @param {string} params.pubkey - Author (the video's author) hex pubkey. * @param {number} params.created_at - Unix seconds. * @param {string} params.infoHash - BitTorrent v1 infohash (40 hex chars). * @param {string} params.torrentBase64 - base64 of the full `.torrent` file bytes. * @param {string} [params.videoEventId] - The video event id (back-reference only). * @returns {Object} Unsigned event. */ export function buildTorrentMetadataEvent(params) { const { pubkey, created_at, infoHash, torrentBase64, videoEventId = "", } = params || {}; const hex = normalizeInfoHash(infoHash); if (!hex) { throw new Error( "buildTorrentMetadataEvent requires a valid 40-hex-char infoHash." ); } if (typeof torrentBase64 !== "string" || !torrentBase64) { throw new Error( "buildTorrentMetadataEvent requires a non-empty base64 torrent payload." ); } const tags = [ ["d", `${TORRENT_METADATA_DTAG_PREFIX}${hex}`], ["x", hex], ["client", "bitvid"], ]; if (typeof videoEventId === "string" && videoEventId.trim()) { tags.push(["e", videoEventId.trim()]); } const content = JSON.stringify({ v: TORRENT_METADATA_ENVELOPE_VERSION, infohash: hex, torrent: torrentBase64, }); return { kind: TORRENT_METADATA_KIND, pubkey, created_at, tags, content: ensureValidUtf8Content(content), }; } // --- Blossom server list (BUD-03, kind 10063) -------------------------------- // A user's preferred Blossom media servers, in order. Lets other clients discover // where a user hosts blobs, and lets bitvid pre-fill its storage pane from it. export const BLOSSOM_SERVER_LIST_KIND = 10063; /** * Builds a Blossom server-list event (BUD-03, kind 10063): one `['server', ]` * tag per server, in preference order, de-duped. * * @param {Object} params * @param {string} params.pubkey * @param {number} params.created_at * @param {string[]} params.servers - Ordered server URLs. * @returns {Object} Unsigned event. */ export function buildBlossomServerListEvent(params) { const { pubkey, created_at, servers } = params || {}; const seen = new Set(); const tags = []; if (Array.isArray(servers)) { for (const entry of servers) { const url = typeof entry === "string" ? entry.trim() : ""; if (url && !seen.has(url)) { seen.add(url); tags.push(["server", url]); } } } tags.push(["client", "bitvid"]); return { kind: BLOSSOM_SERVER_LIST_KIND, pubkey, created_at, tags, content: "", }; } /** * 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 bitvid-native zap tally event (kind ZAP_TALLY_KIND, addressable by * d=payment_hash). Payer-signed, preimage-verified stand-in for a NIP-57 receipt * when the recipient's LNURL server doesn't publish one. Carries the same * {bolt11, preimage, description(zap request)} a 9735 would, so any client can * verify sha256(preimage)==payment_hash and description_hash==sha256(zapRequest). * See docs/zap-tally-plan.md §3. Returns an UNSIGNED event; the caller signs. * * @param {object} params * @param {string} params.pubkey payer pubkey * @param {number} params.created_at * @param {string} params.paymentHash hex; becomes the `d` tag (idempotency) * @param {string} params.recipientPubkey the zapped party (`p`) * @param {string} [params.eventId] zapped video event id (`e`) * @param {string} [params.coordinate] zapped video coordinate kind:pubkey:d (`a`) * @param {number} [params.amountMsats] echoes the zap request amount tag * @param {string} params.bolt11 paid invoice (carries payment_hash + description_hash) * @param {string} params.preimage NWC pay_invoice preimage (proof of settlement) * @param {string} params.zapRequestJson the signed 9734 JSON (NIP-57 `description`) * @param {string} [params.comment] * @returns {object} Unsigned event. */ export function buildZapTallyEvent(params) { const { pubkey, created_at, paymentHash, recipientPubkey, eventId, coordinate, amountMsats, bolt11, preimage, zapRequestJson, comment = "", } = params || {}; const schema = getNostrEventSchema(NOTE_TYPES.ZAP_TALLY); const tags = []; const normalizedHash = normalizePointerIdentifier(paymentHash); if (normalizedHash) { tags.push(["d", normalizedHash]); // addressable identifier = payment_hash } 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 (Number.isFinite(amountMsats)) { tags.push(["amount", String(Math.max(0, Math.round(amountMsats)))]); } if (typeof bolt11 === "string" && bolt11.trim()) { tags.push(["bolt11", bolt11.trim()]); } if (typeof preimage === "string" && preimage.trim()) { tags.push(["preimage", preimage.trim()]); } if (typeof zapRequestJson === "string" && zapRequestJson.trim()) { tags.push(["description", zapRequestJson.trim()]); } tags.push(["client", "bitvid"]); tags.push(["t", "zap"]); appendSchemaTags(tags, schema); const event = { kind: schema?.kind ?? ZAP_TALLY_KIND, pubkey, created_at, tags, content: ensureValidUtf8Content(typeof comment === "string" ? comment : ""), }; if (isDevMode) { validateEventAgainstSchema(NOTE_TYPES.ZAP_TALLY, 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>} 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; case "eventBlacklist": case "event-blacklist": return NOTE_TYPES.ADMIN_EVENT_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} 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}} 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, }; }