Files
bitvid/js/nostrEventSchemas.js
T
thePR0M3TH3ANandClaude Opus 4.8 e81e65ff40 feat(blossom): Phase 2 management — blob delete-on-orphan + kind-10063 server list (#30)
- deleteByUrl maps a Blossom URL back to its content-addressed blob (blobSha256FromUrl) and deletes it via BUD-12, defaulting to the URL's own origin. Wired into the edit-modal orphan cleanup so a superseded Blossom video/thumbnail is reclaimed (best-effort, never blocks the edit).

- BUD-03 kind-10063 server-list discovery: read (serversFromServerListEvent) + build (buildBlossomServerListEvent) + an extracted, testable import/publish helper (blossomServerList.js), plus 'Import from my server list' / 'Publish my server list' controls in the Storage pane's Blossom section.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-12 10:32:22 -04:00

3662 lines
101 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* @fileoverview Central definition for Nostr event schemas, Kinds, and builder functions.
* This module manages the construction and validation of all application-specific Nostr events,
* ensuring they conform to defined schemas (NIP-01, NIP-33, etc.).
*
* It exports:
* - NOTE_TYPES: Enum of supported event types.
* - Builder functions: Pure functions to create unsigned event objects (e.g., buildVideoPostEvent).
* - Validation helpers: Functions to check event structure against schemas.
*/
import { devLogger } from "./utils/logger.js";
import { decodeNpubToHex } from "./nostr/nip46Client.js";
import {
ADMIN_LIST_NAMESPACE,
ADMIN_COMMUNITY_BLACKLIST_SOURCES,
ADMIN_COMMUNITY_BLACKLIST_PREFIX,
isDevMode,
WATCH_HISTORY_KIND,
WATCH_HISTORY_VERSION_TAG_VALUE,
} from "./config.js";
import {
buildStoragePointerValue,
deriveStoragePointerFromUrl,
} from "./utils/storagePointer.js";
/**
* Enumeration of all supported Nostr event types in the application.
* Maps logical names (e.g., VIDEO_POST) to schema definitions.
* @readonly
* @enum {string}
*/
export const NOTE_TYPES = Object.freeze({
VIDEO_POST: "videoPost",
VIDEO_MIRROR: "videoMirror",
NIP71_VIDEO: "nip71Video",
NIP71_SHORT_VIDEO: "nip71ShortVideo",
REPOST: "repost",
GENERIC_REPOST: "genericRepost",
SHARE: "share",
RELAY_LIST: "relayList",
VIEW_EVENT: "viewEvent",
VIDEO_REACTION: "videoReaction",
VIDEO_COMMENT: "videoComment",
DM_ATTACHMENT: "dmAttachment",
DM_READ_RECEIPT: "dmReadReceipt",
DM_TYPING: "dmTypingIndicator",
ZAP_REQUEST: "zapRequest",
ZAP_RECEIPT: "zapReceipt",
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<Array<string>>} additionalTags - Raw tags to sanitize.
* @returns {Array<Array<string>>} Cleaned tags.
*/
export function sanitizeAdditionalTags(additionalTags) {
if (!Array.isArray(additionalTags) || !additionalTags.length) {
return [];
}
const sanitized = [];
additionalTags.forEach((tag) => {
if (!Array.isArray(tag) || tag.length < 2) {
return;
}
const rawName = tag[0];
if (typeof rawName !== "string") {
return;
}
const tagName = rawName.trim();
if (!tagName) {
return;
}
const lowerName = tagName.toLowerCase();
let primaryValue = "";
if (ADDITIONAL_POINTER_TAGS.has(lowerName)) {
primaryValue = normalizePointerIdentifier(tag[1]);
} else {
primaryValue = normalizeTagSlotValue(tag[1]);
}
if (!primaryValue) {
return;
}
const sanitizedTag = [tagName, primaryValue];
for (let index = 2; index < tag.length; index += 1) {
const extraValue = normalizeTagSlotValue(tag[index]);
if (extraValue) {
sanitizedTag.push(extraValue);
}
}
sanitized.push(sanitizedTag);
});
return sanitized;
}
function ensureValidUtf8Content(value) {
let normalized = "";
if (typeof value === "string") {
normalized = value;
} else if (value === undefined || value === null) {
normalized = "";
} else if (typeof value === "object") {
try {
const stringified = JSON.stringify(value);
normalized = typeof stringified === "string" ? stringified : "";
} catch (error) {
devLogger.warn(
"[nostrEventSchemas] Failed to serialize view event content:",
error
);
normalized = "";
}
} else {
normalized = String(value);
}
if (normalized) {
const builder = [];
let mutated = false;
for (let index = 0; index < normalized.length; index += 1) {
const code = normalized.charCodeAt(index);
if (code >= 0xd800 && code <= 0xdbff) {
const nextCode = normalized.charCodeAt(index + 1);
if (nextCode >= 0xdc00 && nextCode <= 0xdfff) {
builder.push(normalized[index], normalized[index + 1]);
index += 1;
} else {
mutated = true;
}
} else if (code >= 0xdc00 && code <= 0xdfff) {
mutated = true;
} else {
builder.push(normalized[index]);
}
}
if (mutated) {
devLogger.warn(
"[nostrEventSchemas] Dropping unmatched surrogate characters from event content"
);
normalized = builder.join("");
}
}
if (!cachedUtf8Encoder && typeof TextEncoder !== "undefined") {
cachedUtf8Encoder = new TextEncoder();
}
if (cachedUtf8Encoder) {
try {
cachedUtf8Encoder.encode(normalized);
} catch (error) {
devLogger.warn(
"[nostrEventSchemas] Dropping invalid UTF-8 characters from event content",
error
);
const sanitized = Array.from(normalized)
.filter((char) => {
const code = char.charCodeAt(0);
return code < 0xd800 || code > 0xdfff;
})
.join("");
try {
cachedUtf8Encoder.encode(sanitized);
normalized = sanitized;
} catch (encodeError) {
devLogger.warn(
"[nostrEventSchemas] Failed to normalize view event content; defaulting to empty string",
encodeError
);
normalized = "";
}
}
}
return normalized;
}
const BASE_SCHEMAS = {
[NOTE_TYPES.VIDEO_POST]: {
type: NOTE_TYPES.VIDEO_POST,
label: "Video post",
kind: 30078,
topicTag: { name: "t", value: "video" },
identifierTag: { name: "d" },
requiredTagNames: ["s"],
appendTags: DEFAULT_APPEND_TAGS,
content: {
format: "json",
schemaVersion: 3,
fields: [
{ key: "version", type: "number", required: true },
{ key: "title", type: "string", required: true },
{ key: "url", type: "string", required: false },
{ key: "magnet", type: "string", required: false },
{ key: "thumbnail", type: "string", required: false },
{ key: "description", type: "string", required: false },
{ key: "mode", type: "string", required: false },
{ key: "videoRootId", type: "string", required: true },
{ key: "deleted", type: "boolean", required: false },
{ key: "isPrivate", type: "boolean", required: false },
{ key: "isNsfw", type: "boolean", required: false },
{ key: "isForKids", type: "boolean", required: false },
{ key: "enableComments", type: "boolean", required: false },
{ key: "infoHash", type: "string", required: false },
{ key: "fileSha256", type: "string", required: false },
{ key: "originalFileSha256", type: "string", required: false },
{ key: "ws", type: "string", required: false },
{ key: "xs", type: "string", required: false },
],
},
},
[NOTE_TYPES.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', <hex>], …]).",
},
},
[NOTE_TYPES.SUBSCRIPTION_BACKUP]: {
type: NOTE_TYPES.SUBSCRIPTION_BACKUP,
label: "Subscription list backup",
kind: 30000,
identifierTag: {
name: "d",
},
appendTags: [["encrypted", "nip44_v2"]],
content: {
format: "encrypted-tag-list",
description:
"NIP-04/NIP-44 encrypted JSON array of NIP-51 tag tuples (e.g., [['p', <hex>], …]).",
},
},
[NOTE_TYPES.USER_BLOCK_LIST]: {
type: NOTE_TYPES.USER_BLOCK_LIST,
label: "User block list (legacy)",
kind: KIND_MUTE_LIST,
identifierTag: {
name: "d",
value: BLOCK_LIST_IDENTIFIER,
},
appendTags: DEFAULT_APPEND_TAGS,
content: {
format: "encrypted-tag-list",
description:
"Legacy NIP-51 block list using custom d tag. Migrated to standard Mute List (kind 10000).",
},
},
[NOTE_TYPES.HASHTAG_PREFERENCES]: {
type: NOTE_TYPES.HASHTAG_PREFERENCES,
label: "Hashtag preferences",
kind: 30015,
identifierTag: {
name: "d",
value: "bitvid:tag-preferences",
},
appendTags: [["encrypted", "nip44_v2"]],
content: {
format: "nip44-json",
description:
"NIP-44 encrypted JSON: { version, interests: string[], disinterests: string[] }.",
},
},
[NOTE_TYPES.ADMIN_MODERATION_LIST]: {
type: NOTE_TYPES.ADMIN_MODERATION_LIST,
label: "Admin moderation list",
kind: 30000,
identifierTag: {
name: "d",
value: `${ADMIN_LIST_NAMESPACE}:${ADMIN_LIST_IDENTIFIERS.moderation}`,
},
participantTagName: "p",
appendTags: DEFAULT_APPEND_TAGS,
content: { format: "empty", description: "Content field unused." },
},
[NOTE_TYPES.ADMIN_BLACKLIST]: {
type: NOTE_TYPES.ADMIN_BLACKLIST,
label: "Admin blacklist",
kind: 30000,
identifierTag: {
name: "d",
value: `${ADMIN_LIST_NAMESPACE}:${ADMIN_LIST_IDENTIFIERS.blacklist}`,
},
participantTagName: "p",
appendTags: DEFAULT_APPEND_TAGS,
content: { format: "empty", description: "Content field unused." },
},
[NOTE_TYPES.ADMIN_WHITELIST]: {
type: NOTE_TYPES.ADMIN_WHITELIST,
label: "Admin whitelist",
kind: 30000,
identifierTag: {
name: "d",
value: `${ADMIN_LIST_NAMESPACE}:${ADMIN_LIST_IDENTIFIERS.whitelist}`,
},
participantTagName: "p",
appendTags: DEFAULT_APPEND_TAGS,
content: { format: "empty", description: "Content field unused." },
},
// 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.<string, Object>} Map of schema configurations by type.
*/
export function getAllNostrEventSchemas() {
const entries = {};
for (const type of Object.keys(BASE_SCHEMAS)) {
entries[type] = getNostrEventSchema(type);
}
return entries;
}
function appendSchemaTags(tags, schema) {
if (!Array.isArray(schema?.appendTags)) {
return tags;
}
schema.appendTags.forEach((tag) => {
if (Array.isArray(tag) && tag.length >= 2) {
tags.push(tag.map((value) => (typeof value === "string" ? value : String(value))));
}
});
return tags;
}
function collectPointerTags(schema, {
pointerValue,
pointerTag,
pointerTags = [],
} = {}) {
const normalized = [];
const pointerTagName = schema?.pointerTagName;
if (pointerValue && pointerTagName) {
const normalizedValue =
typeof pointerValue === "string" ? pointerValue : String(pointerValue);
if (normalizedValue) {
normalized.push([pointerTagName, normalizedValue]);
}
}
if (Array.isArray(pointerTag) && pointerTag.length >= 2) {
normalized.push(
pointerTag.map((value) => (typeof value === "string" ? value : String(value)))
);
}
if (Array.isArray(pointerTags)) {
pointerTags.forEach((tag) => {
if (Array.isArray(tag) && tag.length >= 2) {
normalized.push(
tag.map((value) => (typeof value === "string" ? value : String(value)))
);
}
});
}
return normalized;
}
function mergePointerTags(pointerTags = []) {
if (!Array.isArray(pointerTags) || pointerTags.length === 0) {
return [];
}
const merged = [];
const pointerIndexByKey = new Map();
const toTrimmedString = (value) => {
if (typeof value === "string") {
return value.trim();
}
if (value === undefined || value === null) {
return "";
}
return String(value).trim();
};
pointerTags.forEach((tag) => {
if (!Array.isArray(tag) || tag.length < 2) {
return;
}
const normalizedType = toTrimmedString(tag[0]);
if (!normalizedType) {
return;
}
const canonicalType =
normalizedType === "a" ? "a" : normalizedType === "e" ? "e" : normalizedType;
const normalizedValue = toTrimmedString(tag[1]);
if (!normalizedValue) {
return;
}
const sanitizedTag = [canonicalType, normalizedValue];
for (let index = 2; index < tag.length; index += 1) {
const extra = toTrimmedString(tag[index]);
if (extra) {
sanitizedTag.push(extra);
}
}
if (canonicalType !== "a" && canonicalType !== "e") {
merged.push(sanitizedTag);
return;
}
const dedupeKey = `${canonicalType}:${normalizedValue.toLowerCase()}`;
const relayHint =
sanitizedTag.length > 2 && typeof sanitizedTag[2] === "string"
? sanitizedTag[2].trim()
: "";
if (!pointerIndexByKey.has(dedupeKey)) {
if (relayHint && sanitizedTag.length > 2) {
sanitizedTag[2] = relayHint;
}
const index = merged.length;
merged.push(sanitizedTag);
pointerIndexByKey.set(dedupeKey, { index, relay: relayHint });
return;
}
const existingEntry = pointerIndexByKey.get(dedupeKey);
const existingTag = merged[existingEntry.index];
const existingRelay =
existingTag.length > 2 && typeof existingTag[2] === "string"
? existingTag[2].trim()
: "";
const hasExistingRelay = Boolean(existingRelay);
const hasNewRelay = Boolean(relayHint);
if (
(!hasExistingRelay && hasNewRelay) ||
(hasExistingRelay && hasNewRelay && existingRelay !== relayHint)
) {
const replacement = sanitizedTag.slice();
if (relayHint && replacement.length > 2) {
replacement[2] = relayHint;
}
merged[existingEntry.index] = replacement;
pointerIndexByKey.set(dedupeKey, {
index: existingEntry.index,
relay: relayHint,
});
}
});
return merged;
}
/**
* Builds a Video Post event (Kind 30078).
* Ensures required tags ('d', 's', 't') are present and content is serialized.
*
* @param {Object} params - Event parameters.
* @param {string} params.pubkey - Author hex pubkey.
* @param {number} params.created_at - Timestamp in seconds.
* @param {string} params.dTagValue - NIP-33 identifier (video slug).
* @param {Object|string} params.content - Video metadata payload.
* @param {Array<Array<string>>} [params.additionalTags] - Extra tags.
* @returns {Object} Unsigned Nostr event.
*/
export function buildVideoPostEvent(params) {
const {
pubkey,
created_at,
dTagValue,
content,
additionalTags = [],
} = params || {};
const schema = getNostrEventSchema(NOTE_TYPES.VIDEO_POST);
const tags = [];
if (schema?.topicTag?.name && schema?.topicTag?.value) {
tags.push([schema.topicTag.name, schema.topicTag.value]);
}
if (schema?.identifierTag?.name && dTagValue) {
tags.push([schema.identifierTag.name, dTagValue]);
}
appendSchemaTags(tags, schema);
const sanitizedAdditionalTags = sanitizeAdditionalTags(additionalTags);
if (sanitizedAdditionalTags.length) {
tags.push(...sanitizedAdditionalTags.map((tag) => tag.slice()));
}
const hasSTag = tags.some((t) => t[0] === "s");
if (!hasSTag) {
let contentObj = content;
if (typeof content === "string") {
try {
contentObj = JSON.parse(content);
} catch (e) {
contentObj = {};
}
}
contentObj = contentObj || {};
let pointer = "";
if (contentObj.infoHash) {
pointer = buildStoragePointerValue({
provider: "btih",
prefix: contentObj.infoHash,
});
} else if (contentObj.url) {
pointer = deriveStoragePointerFromUrl(contentObj.url);
}
if (!pointer && (dTagValue || contentObj.videoRootId)) {
const prefix = dTagValue || contentObj.videoRootId;
pointer = buildStoragePointerValue({ provider: "nostr", prefix });
}
if (pointer) {
tags.push(["s", pointer]);
}
}
let serializedContent = "";
if (typeof content === "string") {
serializedContent = content;
} else {
try {
serializedContent = JSON.stringify(content ?? {});
} catch (error) {
serializedContent = "{}";
}
}
const event = {
kind: schema?.kind ?? 30078,
pubkey,
created_at,
tags,
content: serializedContent,
};
if (isDevMode) {
validateEventAgainstSchema(NOTE_TYPES.VIDEO_POST, event);
}
return event;
}
/**
* Builds an HTTP Auth event (Kind 27235).
* Used for NIP-98 authentication.
*
* @param {Object} params - Auth params.
* @param {string} params.url - Target URL.
* @param {string} params.method - HTTP method.
* @returns {Object} Unsigned event.
*/
export function buildHttpAuthEvent(params) {
const {
pubkey,
created_at,
url,
method,
payload,
content,
additionalTags = [],
} = params || {};
const schema = getNostrEventSchema(NOTE_TYPES.HTTP_AUTH);
const tags = [];
const domainTagName = schema?.domainTagName || "u";
if (url) {
tags.push([domainTagName, url]);
}
const methodTagName = schema?.methodTagName || "method";
if (method) {
tags.push([methodTagName, method]);
}
const payloadTagName = schema?.payloadTagName || "payload";
if (payload) {
tags.push([payloadTagName, payload]);
}
appendSchemaTags(tags, schema);
const sanitizedAdditionalTags = sanitizeAdditionalTags(additionalTags);
if (sanitizedAdditionalTags.length) {
tags.push(...sanitizedAdditionalTags.map((tag) => tag.slice()));
}
const event = {
kind: schema?.kind ?? 27235,
pubkey,
created_at,
tags,
content: typeof content === "string" ? content : "",
};
if (isDevMode) {
validateEventAgainstSchema(NOTE_TYPES.HTTP_AUTH, event);
}
return event;
}
/**
* Builds a Report event (Kind 1984).
* Used for flagging content or users.
*
* @param {Object} params - Report params.
* @param {string} [params.eventId] - Reported event ID.
* @param {string} [params.userId] - Reported user pubkey.
* @param {string} [params.reportType] - Type of report (e.g., 'spam').
* @returns {Object} Unsigned event.
*/
export function buildReportEvent(params) {
const {
pubkey,
created_at,
eventId,
userId,
reportType,
relayHint,
content,
additionalTags = [],
} = params || {};
const schema = getNostrEventSchema(NOTE_TYPES.REPORT);
const tags = [];
const normalizedReportType = typeof reportType === "string" ? reportType.trim() : "";
const normalizedRelayHint = typeof relayHint === "string" ? relayHint.trim() : "";
if (eventId) {
const eTag = ["e", eventId];
if (normalizedReportType || normalizedRelayHint) {
eTag.push(normalizedReportType);
}
if (normalizedRelayHint) {
eTag.push(normalizedRelayHint);
}
tags.push(eTag);
}
if (userId) {
const pTag = ["p", userId];
if (normalizedReportType) {
pTag.push(normalizedReportType);
}
tags.push(pTag);
}
// Some implementations use 't' tag for report type too?
// ModerationService logic:
// tags.push(["t", normalizedType]);
// So we support it.
if (normalizedReportType) {
// Check if we should add it as 't' tag or if it's already in e/p
// ModerationService adds it explicitly
const hasTTag = tags.some(t => t[0] === "t" && t[1] === normalizedReportType);
if (!hasTTag) {
// ModerationService does: ["t", normalizedType]
// But let's check what NIP-56 says. It relies on e/p markers.
// But we should match what ModerationService expects/does to be safe.
// It adds ["t", normalizedType]
tags.push(["t", normalizedReportType]);
}
}
appendSchemaTags(tags, schema);
const sanitizedAdditionalTags = sanitizeAdditionalTags(additionalTags);
if (sanitizedAdditionalTags.length) {
tags.push(...sanitizedAdditionalTags.map((tag) => tag.slice()));
}
const event = {
kind: schema?.kind ?? 1984,
pubkey,
created_at,
tags,
content: typeof content === "string" ? content : "",
};
if (isDevMode) {
validateEventAgainstSchema(NOTE_TYPES.REPORT, event);
}
return event;
}
/**
* Builds a NIP-59 Gift Wrap event (Kind 1059).
* Contains an encrypted Seal event.
*
* @param {Object} params - Params.
* @param {string} params.recipientPubkey - Intended recipient.
* @param {string} params.ciphertext - Encrypted seal.
* @returns {Object} Unsigned event.
*/
export function buildGiftWrapEvent(params) {
const {
pubkey,
created_at,
recipientPubkey,
ciphertext = "",
relayHint,
additionalTags = [],
} = params || {};
const schema = getNostrEventSchema(NOTE_TYPES.GIFT_WRAP);
const tags = [];
const recipient = normalizePointerIdentifier(recipientPubkey);
if (recipient) {
const pTag = [schema?.recipientTagName || "p", recipient];
if (typeof relayHint === "string" && relayHint.trim()) {
pTag.push(relayHint.trim());
}
tags.push(pTag);
}
appendSchemaTags(tags, schema);
const sanitizedAdditionalTags = sanitizeAdditionalTags(additionalTags);
if (sanitizedAdditionalTags.length) {
tags.push(...sanitizedAdditionalTags.map((tag) => tag.slice()));
}
const event = {
kind: schema?.kind ?? 1059,
pubkey,
created_at,
tags,
content: typeof ciphertext === "string" ? ciphertext : "",
};
if (isDevMode) {
validateEventAgainstSchema(NOTE_TYPES.GIFT_WRAP, event);
}
return event;
}
/**
* Builds a NIP-59 Seal event (Kind 13).
* Wraps the actual rumor event.
*
* @param {Object} params - Params.
* @param {string} params.ciphertext - Encrypted rumor.
* @returns {Object} Unsigned event.
*/
export function buildSealEvent(params) {
const {
pubkey,
created_at,
ciphertext = "",
additionalTags = [],
} = params || {};
const schema = getNostrEventSchema(NOTE_TYPES.SEAL);
const tags = [];
appendSchemaTags(tags, schema);
const sanitizedAdditionalTags = sanitizeAdditionalTags(additionalTags);
if (sanitizedAdditionalTags.length) {
tags.push(...sanitizedAdditionalTags.map((tag) => tag.slice()));
}
const event = {
kind: schema?.kind ?? 13,
pubkey,
created_at,
tags,
content: typeof ciphertext === "string" ? ciphertext : "",
};
if (isDevMode) {
validateEventAgainstSchema(NOTE_TYPES.SEAL, event);
}
return event;
}
/**
* Builds a Chat Message rumor (Kind 14).
* Intended to be wrapped in a Seal.
*
* @param {Object} params - Params.
* @param {string} params.content - Chat message text.
* @returns {Object} Unsigned event.
*/
export function buildChatMessageEvent(params) {
const {
pubkey,
created_at,
recipientPubkey,
content = "",
additionalTags = [],
} = params || {};
const schema = getNostrEventSchema(NOTE_TYPES.CHAT_MESSAGE);
const tags = [];
const participant = normalizePointerIdentifier(recipientPubkey);
if (participant) {
tags.push([schema?.participantTagName || "p", participant]);
}
appendSchemaTags(tags, schema);
const sanitizedAdditionalTags = sanitizeAdditionalTags(additionalTags);
if (sanitizedAdditionalTags.length) {
tags.push(...sanitizedAdditionalTags.map((tag) => tag.slice()));
}
const event = {
kind: schema?.kind ?? 14,
pubkey,
created_at,
tags,
content: typeof content === "string" ? content : "",
};
if (isDevMode) {
validateEventAgainstSchema(NOTE_TYPES.CHAT_MESSAGE, event);
}
return event;
}
/**
* Builds a NIP-94 Video Mirror event (Kind 1063).
*
* @param {Object} params - Params.
* @returns {Object} Unsigned event.
*/
export function buildVideoMirrorEvent(params) {
const {
pubkey,
created_at,
tags = [],
content = "",
} = params || {};
const schema = getNostrEventSchema(NOTE_TYPES.VIDEO_MIRROR);
const combinedTags = [];
appendSchemaTags(combinedTags, schema);
if (Array.isArray(tags)) {
tags.forEach((tag) => {
if (Array.isArray(tag) && tag.length >= 2) {
combinedTags.push(tag.map((value) => (typeof value === "string" ? value : String(value))));
}
});
}
const event = {
kind: schema?.kind ?? 1063,
pubkey,
created_at,
tags: combinedTags,
content: safeStringify(content),
};
if (isDevMode) {
validateEventAgainstSchema(NOTE_TYPES.VIDEO_MIRROR, event);
}
return event;
}
/**
* Builds a 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"]);
}
const seenRelayTags = new Set();
const pushRelayTag = (url, marker) => {
const normalizedUrl = typeof url === "string" ? url.trim() : "";
if (!normalizedUrl) {
return;
}
const normalizedMarker =
typeof marker === "string" ? marker.trim().toLowerCase() : "";
const resolvedMarker =
normalizedMarker === "read" || normalizedMarker === "write"
? normalizedMarker
: "";
const dedupeKey = `${normalizedUrl}|${resolvedMarker}`;
if (seenRelayTags.has(dedupeKey)) {
return;
}
seenRelayTags.add(dedupeKey);
if (resolvedMarker) {
const markerValue = resolvedMarker === "read" ? readMarker : writeMarker;
tags.push([relayTagName, normalizedUrl, markerValue]);
} else {
tags.push([relayTagName, normalizedUrl]);
}
};
if (Array.isArray(relays)) {
relays.forEach((entry) => {
if (typeof entry === "string") {
pushRelayTag(entry, "");
return;
}
if (Array.isArray(entry) && entry.length) {
if (entry[0] === relayTagName) {
pushRelayTag(entry[1], entry[2]);
return;
}
pushRelayTag(entry[0], entry[1]);
return;
}
if (entry && typeof entry === "object") {
const url =
typeof entry.url === "string"
? entry.url
: typeof entry.relay === "string"
? entry.relay
: "";
let marker = "";
if (typeof entry.mode === "string") {
marker = entry.mode;
} else if (typeof entry.marker === "string") {
marker = entry.marker;
} else if (entry.read === true && entry.write === false) {
marker = "read";
} else if (entry.write === true && entry.read === false) {
marker = "write";
}
pushRelayTag(url, marker);
}
});
}
appendSchemaTags(tags, schema);
const sanitizedAdditionalTags = sanitizeAdditionalTags(additionalTags);
if (sanitizedAdditionalTags.length) {
tags.push(...sanitizedAdditionalTags.map((tag) => tag.slice()));
}
const resolvedContent = ensureValidUtf8Content(content);
const event = {
kind: schema?.kind ?? 1,
pubkey,
created_at,
tags,
content: resolvedContent,
};
if (isDevMode) {
validateEventAgainstSchema(NOTE_TYPES.SHARE, event);
}
return event;
}
/**
* Builds a Relay List event (Kind 10002).
* NIP-65: Defines read/write relays for the user.
*
* @param {Object} params - Params.
* @param {Array<string|Object>} params.relays - List of relay URLs or objects with markers.
* @returns {Object} Unsigned event.
*/
export function buildRelayListEvent(params) {
const {
pubkey,
created_at,
relays = [],
additionalTags = [],
} = params || {};
const schema = getNostrEventSchema(NOTE_TYPES.RELAY_LIST);
const tags = [];
const relayTagName = schema?.relayTagName || "r";
const readMarker = schema?.readMarker || "read";
const writeMarker = schema?.writeMarker || "write";
if (Array.isArray(relays)) {
relays.forEach((entry) => {
let url = "";
let mode = "";
if (typeof entry === "string") {
url = entry;
} else if (entry && typeof entry === "object") {
if (typeof entry.url === "string") {
url = entry.url;
}
if (typeof entry.mode === "string") {
mode = entry.mode;
} else if (typeof entry.marker === "string") {
mode = entry.marker;
} else if (entry.read === true && entry.write === false) {
mode = "read";
} else if (entry.write === true && entry.read === false) {
mode = "write";
}
} else if (Array.isArray(entry) && entry.length >= 1) {
url = typeof entry[0] === "string" ? entry[0] : String(entry[0]);
if (entry.length > 1 && typeof entry[1] === "string") {
mode = entry[1];
}
}
const normalizedUrl = typeof url === "string" ? url.trim() : "";
if (!normalizedUrl) {
return;
}
const normalizedMode = typeof mode === "string" ? mode.trim().toLowerCase() : "";
if (normalizedMode === "read") {
tags.push([relayTagName, normalizedUrl, readMarker]);
} else if (normalizedMode === "write") {
tags.push([relayTagName, normalizedUrl, writeMarker]);
} else {
tags.push([relayTagName, normalizedUrl]);
}
});
}
appendSchemaTags(tags, schema);
const sanitizedAdditionalTags = sanitizeAdditionalTags(additionalTags);
if (sanitizedAdditionalTags.length) {
tags.push(...sanitizedAdditionalTags.map((tag) => tag.slice()));
}
const event = {
kind: schema?.kind ?? 10002,
pubkey,
created_at,
tags,
content: "",
};
if (isDevMode) {
validateEventAgainstSchema(NOTE_TYPES.RELAY_LIST, event);
}
return event;
}
/**
* Builds a DM Relay List event (Kind 10050).
* NIP-17: Hints for where to find DMs.
*
* @param {Object} params - Params.
* @param {Array<string>} params.relays - List of relay URLs.
* @returns {Object} Unsigned event.
*/
export function buildDmRelayListEvent(params) {
const {
pubkey,
created_at,
relays = [],
additionalTags = [],
} = params || {};
const schema = getNostrEventSchema(NOTE_TYPES.DM_RELAY_LIST);
const tags = [];
const relayTagName = schema?.relayTagName || "relay";
if (Array.isArray(relays)) {
relays.forEach((relay) => {
const normalized = typeof relay === "string" ? relay.trim() : "";
if (normalized) {
tags.push([relayTagName, normalized]);
}
});
}
appendSchemaTags(tags, schema);
const sanitizedAdditionalTags = sanitizeAdditionalTags(additionalTags);
if (sanitizedAdditionalTags.length) {
tags.push(...sanitizedAdditionalTags.map((tag) => tag.slice()));
}
const event = {
kind: schema?.kind ?? 10050,
pubkey,
created_at,
tags,
content: "",
};
if (isDevMode) {
validateEventAgainstSchema(NOTE_TYPES.DM_RELAY_LIST, event);
}
return event;
}
/**
* Builds a Profile Metadata event (Kind 0).
* NIP-01: User profile information.
*
* @param {Object} params - Params.
* @param {Object} params.metadata - Profile object {name, about, picture, ...}.
* @returns {Object} Unsigned event.
*/
export function buildProfileMetadataEvent(params) {
const {
pubkey,
created_at,
metadata = {},
content,
additionalTags = [],
} = params || {};
const schema = getNostrEventSchema(NOTE_TYPES.PROFILE_METADATA);
const tags = [];
appendSchemaTags(tags, schema);
const sanitizedAdditionalTags = sanitizeAdditionalTags(additionalTags);
if (sanitizedAdditionalTags.length) {
tags.push(...sanitizedAdditionalTags.map((tag) => tag.slice()));
}
let serializedContent = "";
if (typeof content === "string") {
serializedContent = content;
} else {
try {
serializedContent = JSON.stringify(metadata || {});
} catch (error) {
serializedContent = "{}";
}
}
const event = {
kind: schema?.kind ?? 0,
pubkey,
created_at,
tags,
content: serializedContent,
};
if (isDevMode) {
validateEventAgainstSchema(NOTE_TYPES.PROFILE_METADATA, event);
}
return event;
}
/**
* Builds a Mute List event (Kind 10000).
* NIP-51: Public mutes (p tags) and private blocks (encrypted content).
*
* @param {Object} params - Params.
* @param {Array<string>} params.pTags - Publicly muted pubkeys.
* @param {boolean} [params.encrypted] - If true, adds 'encrypted' tag.
* @returns {Object} Unsigned event.
*/
export function buildMuteListEvent(params) {
const {
pubkey,
created_at,
pTags = [],
content = "",
encrypted = false,
encryptionTag = "",
additionalTags = [],
} = params || {};
const schema = getNostrEventSchema(NOTE_TYPES.MUTE_LIST);
const tags = [];
const participantTagName = schema?.participantTagName || "p";
if (Array.isArray(pTags)) {
pTags.forEach((hex) => {
if (typeof hex === "string" && hex.trim()) {
tags.push([participantTagName, hex.trim()]);
}
});
}
if (encrypted && encryptionTag) {
tags.push(["encrypted", encryptionTag]);
}
appendSchemaTags(tags, schema);
const sanitizedAdditionalTags = sanitizeAdditionalTags(additionalTags);
if (sanitizedAdditionalTags.length) {
tags.push(...sanitizedAdditionalTags.map((tag) => tag.slice()));
}
const event = {
kind: schema?.kind ?? KIND_MUTE_LIST,
pubkey,
created_at,
tags,
content: typeof content === "string" ? content : "",
};
if (isDevMode) {
validateEventAgainstSchema(NOTE_TYPES.MUTE_LIST, event);
}
return event;
}
/**
* Builds a Deletion event (Kind 5).
* NIP-09: Request to delete past events.
*
* @param {Object} params - Params.
* @param {Array<string>} [params.eventIds] - IDs to delete.
* @param {Array<string>} [params.addresses] - NIP-33 addresses to delete.
* @returns {Object} Unsigned event.
*/
export function buildDeletionEvent(params) {
const {
pubkey,
created_at,
eventIds = [],
addresses = [],
reason = "",
additionalTags = [],
} = params || {};
const schema = getNostrEventSchema(NOTE_TYPES.DELETION);
const tags = [];
if (Array.isArray(eventIds)) {
eventIds.forEach((id) => {
const normalized = normalizePointerIdentifier(id);
if (normalized) {
tags.push(["e", normalized]);
}
});
}
if (Array.isArray(addresses)) {
addresses.forEach((addr) => {
const normalized = typeof addr === "string" ? addr.trim() : "";
if (normalized) {
tags.push(["a", normalized]);
}
});
}
appendSchemaTags(tags, schema);
const sanitizedAdditionalTags = sanitizeAdditionalTags(additionalTags);
if (sanitizedAdditionalTags.length) {
tags.push(...sanitizedAdditionalTags.map((tag) => tag.slice()));
}
const event = {
kind: schema?.kind ?? 5,
pubkey,
created_at,
tags,
content: typeof reason === "string" ? reason : "",
};
if (isDevMode) {
validateEventAgainstSchema(NOTE_TYPES.DELETION, event);
}
return event;
}
/**
* Builds a Legacy Direct Message event (Kind 4).
* NIP-04: Encrypted text message.
*
* @param {Object} params - Params.
* @param {string} params.recipientPubkey - Recipient hex key.
* @param {string} params.ciphertext - Encrypted content.
* @returns {Object} Unsigned event.
*/
export function buildLegacyDirectMessageEvent(params) {
const {
pubkey,
created_at,
recipientPubkey,
ciphertext = "",
additionalTags = [],
} = params || {};
const schema = getNostrEventSchema(NOTE_TYPES.LEGACY_DM);
const tags = [];
const recipient = normalizePointerIdentifier(recipientPubkey);
if (recipient) {
tags.push([schema?.recipientTagName || "p", recipient]);
}
appendSchemaTags(tags, schema);
const sanitizedAdditionalTags = sanitizeAdditionalTags(additionalTags);
if (sanitizedAdditionalTags.length) {
tags.push(...sanitizedAdditionalTags.map((tag) => tag.slice()));
}
const event = {
kind: schema?.kind ?? 4,
pubkey,
created_at,
tags,
content: typeof ciphertext === "string" ? ciphertext : "",
};
if (isDevMode) {
validateEventAgainstSchema(NOTE_TYPES.LEGACY_DM, event);
}
return event;
}
/**
* Builds a DM Attachment event (Kind 15).
* NIP-17: File rumor with metadata tags (x, url, etc.).
*
* @param {Object} params - Params.
* @param {Object} params.attachment - {url, x (hash), name, type, size, key}.
* @returns {Object} Unsigned event.
*/
export function buildDmAttachmentEvent(params) {
const {
pubkey,
created_at,
recipientPubkey,
attachment = {},
additionalTags = [],
} = params || {};
const schema = getNostrEventSchema(NOTE_TYPES.DM_ATTACHMENT);
const tags = [];
const participantTagName = schema?.participantTagName || "p";
if (recipientPubkey && typeof recipientPubkey === "string") {
tags.push([participantTagName, recipientPubkey.trim()]);
}
if (attachment?.x) {
tags.push([schema?.hashTagName || "x", attachment.x]);
}
if (attachment?.url) {
tags.push([schema?.urlTagName || "url", attachment.url]);
}
if (attachment?.name) {
tags.push([schema?.nameTagName || "name", attachment.name]);
}
if (attachment?.type) {
tags.push([schema?.typeTagName || "type", attachment.type]);
}
if (Number.isFinite(attachment?.size)) {
tags.push([schema?.sizeTagName || "size", String(attachment.size)]);
}
if (attachment?.key) {
tags.push([schema?.keyTagName || "k", attachment.key]);
}
appendSchemaTags(tags, schema);
const sanitizedAdditionalTags = sanitizeAdditionalTags(additionalTags);
if (sanitizedAdditionalTags.length) {
tags.push(...sanitizedAdditionalTags.map((tag) => tag.slice()));
}
const event = {
kind: schema?.kind ?? 15,
pubkey,
created_at,
tags,
content: "",
};
if (isDevMode) {
validateEventAgainstSchema(NOTE_TYPES.DM_ATTACHMENT, event);
}
return event;
}
/**
* Builds a DM Read Receipt event (Kind 20001).
*
* @param {Object} params - Params.
* @param {string} params.eventId - ID of message read.
* @returns {Object} Unsigned event.
*/
export function buildDmReadReceiptEvent(params) {
const {
pubkey,
created_at,
recipientPubkey,
eventId,
messageKind,
additionalTags = [],
} = params || {};
const schema = getNostrEventSchema(NOTE_TYPES.DM_READ_RECEIPT);
const tags = [];
const recipient = normalizePointerIdentifier(recipientPubkey);
if (recipient) {
tags.push([schema?.recipientTagName || "p", recipient]);
}
const normalizedEventId = normalizePointerIdentifier(eventId);
if (normalizedEventId) {
tags.push([schema?.eventTagName || "e", normalizedEventId]);
}
if (Number.isFinite(messageKind)) {
tags.push([schema?.kindTagName || "k", String(Math.floor(messageKind))]);
}
appendSchemaTags(tags, schema);
const sanitizedAdditionalTags = sanitizeAdditionalTags(additionalTags);
if (sanitizedAdditionalTags.length) {
tags.push(...sanitizedAdditionalTags.map((tag) => tag.slice()));
}
const event = {
kind: schema?.kind ?? 20001,
pubkey,
created_at,
tags,
content: "",
};
if (isDevMode) {
validateEventAgainstSchema(NOTE_TYPES.DM_READ_RECEIPT, event);
}
return event;
}
/**
* Builds a DM Typing Indicator event (Kind 20002).
* Ephemeral event to show typing status.
*
* @param {Object} params - Params.
* @param {number} params.expiresAt - Expiration timestamp.
* @returns {Object} Unsigned event.
*/
export function buildDmTypingIndicatorEvent(params) {
const {
pubkey,
created_at,
recipientPubkey,
eventId,
expiresAt,
additionalTags = [],
} = params || {};
const schema = getNostrEventSchema(NOTE_TYPES.DM_TYPING);
const tags = [];
const recipient = normalizePointerIdentifier(recipientPubkey);
if (recipient) {
tags.push([schema?.recipientTagName || "p", recipient]);
}
const normalizedEventId = normalizePointerIdentifier(eventId);
if (normalizedEventId) {
tags.push([schema?.eventTagName || "e", normalizedEventId]);
}
if (schema?.statusTagName && schema?.statusTagValue) {
tags.push([schema.statusTagName, schema.statusTagValue]);
}
if (Number.isFinite(expiresAt)) {
tags.push([schema?.expirationTagName || "expiration", String(Math.floor(expiresAt))]);
}
appendSchemaTags(tags, schema);
const sanitizedAdditionalTags = sanitizeAdditionalTags(additionalTags);
if (sanitizedAdditionalTags.length) {
tags.push(...sanitizedAdditionalTags.map((tag) => tag.slice()));
}
const event = {
kind: schema?.kind ?? 20002,
pubkey,
created_at,
tags,
content: "",
};
if (isDevMode) {
validateEventAgainstSchema(NOTE_TYPES.DM_TYPING, event);
}
return event;
}
/**
* Builds a View event (Kind 30078 or WATCH_HISTORY_KIND).
* Tracks analytics for video views.
*
* @param {Object} params - Params.
* @param {boolean} [params.includeSessionTag] - Whether to include a session marker.
* @returns {Object} Unsigned event.
*/
export function buildViewEvent(params) {
const {
pubkey,
created_at,
pointerValue,
pointerTag,
pointerTags = [],
dedupeTag,
includeSessionTag = false,
additionalTags = [],
content = "",
} = params || {};
const schema = getNostrEventSchema(NOTE_TYPES.VIEW_EVENT);
const tags = [];
if (schema?.topicTag?.name && schema?.topicTag?.value) {
tags.push([schema.topicTag.name, schema.topicTag.value]);
}
const normalizedPointerTags = collectPointerTags(schema, {
pointerValue,
pointerTag,
pointerTags,
});
normalizedPointerTags.forEach((tag) => {
tags.push(tag);
});
const sanitizedAdditionalTags = sanitizeAdditionalTags(additionalTags);
if (sanitizedAdditionalTags.length) {
tags.push(...sanitizedAdditionalTags.map((tag) => tag.slice()));
}
// No random fallback: view events must be parameterized-replaceable on the
// exact d-tag the caller supplies (publishViewEvent computes a deterministic,
// window-bucketed one). A random d-tag here would make every view a unique
// event and defeat relay-side dedupe, inflating view counts (item #4 audit).
const resolvedDedupeTag = typeof dedupeTag === "string" ? dedupeTag : "";
if (resolvedDedupeTag && schema?.identifierTag?.name) {
const identifierName = schema.identifierTag.name;
const hasDedupe = tags.some(
(tag) => tag[0] === identifierName && tag[1] === resolvedDedupeTag
);
if (!hasDedupe) {
tags.push([identifierName, resolvedDedupeTag]);
}
}
if (includeSessionTag && schema?.sessionTag?.name && schema?.sessionTag?.value) {
const hasSession = tags.some(
(tag) => tag[0] === schema.sessionTag.name && tag[1] === schema.sessionTag.value
);
if (!hasSession) {
tags.push([schema.sessionTag.name, schema.sessionTag.value]);
}
}
appendSchemaTags(tags, schema);
const resolvedContent = ensureValidUtf8Content(content);
const event = {
kind: schema?.kind ?? WATCH_HISTORY_KIND,
pubkey,
created_at,
tags,
content: resolvedContent,
};
if (isDevMode) {
validateEventAgainstSchema(NOTE_TYPES.VIEW_EVENT, event);
}
return event;
}
// --- 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:<hex>`
* 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', <url>]`
* 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<Array<string>>} params.pointerTags - Watched items (v tags).
* @returns {Object} Unsigned event.
*/
export function buildWatchHistoryEvent(params) {
const {
pubkey,
created_at,
monthIdentifier,
pointerTags = [],
content,
} = params || {};
const schema = getNostrEventSchema(NOTE_TYPES.WATCH_HISTORY);
const tags = [];
const identifierName = schema?.identifierTag?.name || "d";
const defaultMonth = Number.isFinite(created_at)
? new Date(created_at * 1000).toISOString().slice(0, 7)
: new Date().toISOString().slice(0, 7);
const identifierValue =
(typeof monthIdentifier === "string" && monthIdentifier.trim()) ||
schema?.identifierTag?.value ||
defaultMonth;
if (identifierName && identifierValue) {
tags.push([identifierName, identifierValue]);
}
const monthTagName = schema?.monthTagName || "month";
if (monthTagName && identifierValue) {
tags.push([monthTagName, identifierValue]);
}
if (Array.isArray(pointerTags)) {
pointerTags.forEach((tag) => {
if (Array.isArray(tag) && tag.length >= 2) {
tags.push(tag.map((value) => (typeof value === "string" ? value : String(value))));
}
});
}
appendSchemaTags(tags, schema);
const event = {
kind: schema?.kind ?? WATCH_HISTORY_KIND,
pubkey,
created_at: Number.isFinite(created_at) ? created_at : Math.floor(Date.now() / 1000),
tags,
content: safeStringify(content),
};
if (isDevMode) {
validateEventAgainstSchema(NOTE_TYPES.WATCH_HISTORY, event);
}
return event;
}
/**
* Builds a Subscription List event (Kind 30000).
* Stores user channel subscriptions (NIP-51).
*
* @param {Object} params - Params.
* @param {string} [params.encryption] - NIP-04/44 encryption payload.
* @returns {Object} Unsigned event.
*/
export function buildSubscriptionListEvent(params) {
const {
pubkey,
created_at,
content,
encryption,
} = params || {};
const schema = getNostrEventSchema(NOTE_TYPES.SUBSCRIPTION_LIST);
const tags = [];
const identifierName = schema?.identifierTag?.name || "d";
const identifierValue = schema?.identifierTag?.value || SUBSCRIPTION_LIST_IDENTIFIER;
if (identifierName && identifierValue) {
tags.push([identifierName, identifierValue]);
}
appendSchemaTags(tags, schema);
const normalizedEncryption =
typeof encryption === "string" ? encryption.trim() : "";
if (normalizedEncryption) {
let updated = false;
for (const tag of tags) {
if (Array.isArray(tag) && tag[0] === "encrypted") {
tag[1] = normalizedEncryption;
updated = true;
break;
}
}
if (!updated) {
tags.push(["encrypted", normalizedEncryption]);
}
}
const event = {
kind: schema?.kind ?? 30000,
pubkey,
created_at,
tags,
content: safeStringify(content),
};
if (isDevMode) {
validateEventAgainstSchema(NOTE_TYPES.SUBSCRIPTION_LIST, event);
}
return event;
}
/**
* Builds a User Block List event (Legacy).
*
* @param {Object} params - Params.
* @returns {Object} Unsigned event.
*/
export function buildBlockListEvent(params) {
const {
pubkey,
created_at,
content,
encryption,
} = params || {};
const schema = getNostrEventSchema(NOTE_TYPES.USER_BLOCK_LIST);
const tags = [];
const identifierName = schema?.identifierTag?.name || "d";
const identifierValue = schema?.identifierTag?.value || BLOCK_LIST_IDENTIFIER;
if (identifierName && identifierValue) {
tags.push([identifierName, identifierValue]);
}
const normalizedEncryption =
typeof encryption === "string" ? encryption.trim() : "";
if (normalizedEncryption) {
tags.push(["encrypted", normalizedEncryption]);
}
appendSchemaTags(tags, schema);
const event = {
kind: schema?.kind ?? 10000,
pubkey,
created_at,
tags,
content: safeStringify(content),
};
if (isDevMode) {
validateEventAgainstSchema(NOTE_TYPES.USER_BLOCK_LIST, event);
}
return event;
}
/**
* Builds a Hashtag Preferences event (Kind 30015).
* Stores user tag interests/blocks.
*
* @param {Object} params - Params.
* @returns {Object} Unsigned event.
*/
export function buildHashtagPreferenceEvent(params) {
const {
pubkey,
created_at,
content,
} = params || {};
const schema = getNostrEventSchema(NOTE_TYPES.HASHTAG_PREFERENCES);
const tags = [];
const identifierName = schema?.identifierTag?.name || "d";
const identifierValue = schema?.identifierTag?.value || "bitvid:tag-preferences";
if (identifierName && identifierValue) {
tags.push([identifierName, identifierValue]);
}
appendSchemaTags(tags, schema);
const event = {
kind: schema?.kind ?? 30015,
pubkey,
created_at,
tags,
content: safeStringify(content),
};
if (isDevMode) {
validateEventAgainstSchema(NOTE_TYPES.HASHTAG_PREFERENCES, event);
}
return event;
}
function resolveAdminNoteType(listKey) {
switch (listKey) {
case "moderation":
case "editors":
return NOTE_TYPES.ADMIN_MODERATION_LIST;
case "whitelist":
return NOTE_TYPES.ADMIN_WHITELIST;
case "blacklist":
return NOTE_TYPES.ADMIN_BLACKLIST;
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<string>} params.hexPubkeys - List of affected users.
* @returns {Object} Unsigned event.
*/
export function buildAdminListEvent(listKey, params) {
const {
pubkey,
created_at,
hexPubkeys = []
} = params || {};
const schema = getNostrEventSchema(resolveAdminNoteType(listKey));
if (!schema) {
devLogger.warn(
`[nostrEventSchemas] Unknown admin list key: ${safeStringify(listKey)}`,
);
return {
kind: 30000,
pubkey,
created_at,
tags: [],
content: "",
};
}
const tags = [];
if (schema?.identifierTag?.name && schema?.identifierTag?.value) {
tags.push([schema.identifierTag.name, schema.identifierTag.value]);
}
const participantTagName = schema?.participantTagName || "p";
if (Array.isArray(hexPubkeys)) {
hexPubkeys.forEach((hex) => {
if (typeof hex === "string" && hex.trim()) {
tags.push([participantTagName, hex.trim()]);
}
});
}
appendSchemaTags(tags, schema);
const event = {
kind: schema?.kind ?? 30000,
pubkey,
created_at,
tags,
content: "",
};
if (isDevMode) {
const resolvedType = resolveAdminNoteType(listKey);
if (resolvedType) {
validateEventAgainstSchema(resolvedType, event);
}
}
return event;
}
function hasTag(tags, tagName, tagValue = null) {
if (!Array.isArray(tags)) return false;
return tags.some(
(tag) =>
Array.isArray(tag) &&
tag[0] === tagName &&
(tagValue === null || tag[1] === tagValue)
);
}
function hasTagName(tags, tagName) {
if (!Array.isArray(tags)) return false;
return tags.some((tag) => {
if (!Array.isArray(tag) || tag[0] !== tagName) {
return false;
}
if (tag.length < 2 || typeof tag[1] !== "string") {
return false;
}
return tag[1].trim().length > 0;
});
}
/**
* Validates an event object against the schema for a given type.
* Checks kind, required tags, and content structure.
*
* @param {string} type - NOTE_TYPES key.
* @param {Object} event - The event object to validate.
* @returns {{valid: boolean, errors: Array<string>}} Validation result.
*/
export function validateEventStructure(type, event) {
const schema = getNostrEventSchema(type);
const errors = [];
if (!event || typeof event !== "object") {
return { valid: false, errors: ["Event is null or not an object"] };
}
if (!schema) {
return { valid: false, errors: [`Unknown note type: ${type}`] };
}
if (event.kind !== schema.kind) {
errors.push(
`Kind mismatch for ${type}: expected ${schema.kind}, got ${event.kind}`
);
}
if (schema.topicTag) {
if (!hasTag(event.tags, schema.topicTag.name, schema.topicTag.value)) {
errors.push(
`Missing topic tag for ${type}: ${schema.topicTag.name}=${schema.topicTag.value}`
);
}
}
if (schema.identifierTag) {
const expectedValue = schema.identifierTag.value;
if (!hasTag(event.tags, schema.identifierTag.name, expectedValue)) {
errors.push(
`Missing identifier tag for ${type}: ${schema.identifierTag.name}${
expectedValue ? "=" + expectedValue : ""
}`
);
}
}
if (Array.isArray(schema.requiredTagNames)) {
schema.requiredTagNames.forEach((tagName) => {
if (!hasTagName(event.tags, tagName)) {
errors.push(`Missing required tag for ${type}: ${tagName}`);
}
});
}
if (schema.appendTags) {
schema.appendTags.forEach((appendTag) => {
if (Array.isArray(appendTag) && appendTag.length >= 2) {
const tagName = appendTag[0];
const tagValue = appendTag[1];
if (!hasTag(event.tags, tagName, tagValue)) {
errors.push(
`Missing append tag for ${type}: ${tagName}=${tagValue}`
);
}
}
});
}
if (schema.content) {
if (schema.content.format === "json") {
let parsed = null;
try {
parsed = JSON.parse(event.content);
} catch (e) {
errors.push(`Content is not valid JSON for ${type}`);
}
if (parsed && schema.content.fields) {
schema.content.fields.forEach((field) => {
const value = parsed[field.key];
if (field.required && (value === undefined || value === null)) {
errors.push(`Missing required field in JSON content: ${field.key}`);
}
if (
value !== undefined &&
value !== null &&
field.type &&
typeof value !== field.type
) {
errors.push(
`Field '${field.key}' should be of type ${field.type}, got ${typeof value}`
);
}
});
}
} else if (schema.content.format === "empty") {
if (event.content !== "") {
errors.push(`Content should be empty for ${type}`);
}
}
}
return { valid: errors.length === 0, errors };
}
export function validateEventAgainstSchema(type, event) {
if (!isDevMode || !event) return;
const { valid, errors } = validateEventStructure(type, event);
if (!valid) {
errors.forEach((msg) => devLogger.warn(`[schema] ${msg}`));
}
}
if (typeof window !== "undefined") {
window.bitvidNostrEvents = {
NOTE_TYPES,
getSchema: getNostrEventSchema,
getAllSchemas: getAllNostrEventSchemas,
setOverrides: setNostrEventSchemaOverrides,
validateEvent: validateEventAgainstSchema,
validateEventStructure,
};
}