Files
bitvid/js/nostrEventSchemas.js
thePR0M3TH3ANandClaude Opus 5 38b51f13a5 [share] Give share-to-Nostr notes a real preview
"Share on Nostr" published a kind-1 note whose body ended with a bare thumbnail
URL. Many clients only inline such a URL when a NIP-92 `imeta` entry describes
it, so the viewer often saw a plain link instead of a preview image.

- buildShareEvent now emits an `imeta` entry for the thumbnail. The media type
  is inferred from the extension and OMITTED when unknown -- a wrong `m` is
  worse than a missing one, since clients use it to decide whether to render
  inline at all, so extensionless Blossom URLs ship url + alt only.
- inferImageMimeTypeFromUrl lives in a SEPARATE map from EXTENSION_MIME_MAP:
  that map gates video upload/publish paths, and folding image types into it
  would let an image pass a video check.
- shareNostrController was passing only { id, pubkey } to buildShareEvent, so
  the tag could never be built; it now forwards title and thumbnail.

When a video has a NIP-71 mirror, the note instead quotes it with
`nostr:naddr1...`, which nostr clients render as a native video quote card --
the closest thing to a rich preview that does not depend on any client fetching
OpenGraph tags (most never do).

- The mirror is addressable (kind 34235/34236, author = video pubkey,
  d = videoRootId), so js/nostr/mirrorPointer.js derives the coordinate with no
  relay lookup, and the pointer survives the author editing the video where a
  plain `nevent` would rot on the next replace.
- The pointer is only embedded after nip71MirrorService.findMirror() confirms
  the mirror exists. A pointer to a missing event renders as a broken/empty
  quote box, which is worse than the plain-link fallback, so a disabled
  service, a throwing lookup, a 2.5s timeout or a missing videoRootId all fall
  back to the thumbnail note.
- resolveMirrorKind prefers the kinds actually observed on relays and only then
  falls back to the same portrait rule the mirror builder uses, so a derived
  pointer matches what would be published rather than guessing independently.
- While quoting, the raw thumbnail URL is dropped from the body and the imeta
  tag is skipped: the quote card already shows the thumbnail, and emitting both
  rendered the same image twice. An `a` tag links the note to the coordinate.

Known limitation: a client rendering the quote card still has to play the
video, so mirrors whose imeta url is an .m3u8 hit the same HLS wall -- title and
thumbnail render, playback depends on the client.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-07-28 13:59:10 -04:00

3709 lines
103 KiB
JavaScript
Raw Permalink 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";
import { inferImageMimeTypeFromUrl } from "./utils/mime.js";
/**
* Enumeration of all supported Nostr event types in the application.
* Maps logical names (e.g., VIDEO_POST) to schema definitions.
* @readonly
* @enum {string}
*/
export const NOTE_TYPES = Object.freeze({
VIDEO_POST: "videoPost",
VIDEO_MIRROR: "videoMirror",
NIP71_VIDEO: "nip71Video",
NIP71_SHORT_VIDEO: "nip71ShortVideo",
REPOST: "repost",
GENERIC_REPOST: "genericRepost",
SHARE: "share",
RELAY_LIST: "relayList",
VIEW_EVENT: "viewEvent",
VIDEO_REACTION: "videoReaction",
VIDEO_COMMENT: "videoComment",
DM_ATTACHMENT: "dmAttachment",
DM_READ_RECEIPT: "dmReadReceipt",
DM_TYPING: "dmTypingIndicator",
ZAP_REQUEST: "zapRequest",
ZAP_RECEIPT: "zapReceipt",
ZAP_TALLY: "zapTally",
PLAYLIST: "playlist",
SUBMISSION: "submission",
WATCH_HISTORY: "watchHistory",
SUBSCRIPTION_LIST: "subscriptionList",
SUBSCRIPTION_BACKUP: "subscriptionBackup",
USER_BLOCK_LIST: "userBlockList",
HASHTAG_PREFERENCES: "hashtagPreferences",
DM_RELAY_LIST: "dmRelayList",
ADMIN_MODERATION_LIST: "adminModerationList",
ADMIN_BLACKLIST: "adminBlacklist",
ADMIN_WHITELIST: "adminWhitelist",
ADMIN_EVENT_BLACKLIST: "adminEventBlacklist",
PROFILE_METADATA: "profileMetadata",
MUTE_LIST: "muteList",
DELETION: "deletion",
LEGACY_DM: "legacyDm",
HTTP_AUTH: "httpAuth",
REPORT: "report",
GIFT_WRAP: "giftWrap",
SEAL: "seal",
CHAT_MESSAGE: "chatMessage",
});
/** Kind for NIP-51 Mute List. */
export const KIND_MUTE_LIST = 10000;
export const SUBSCRIPTION_LIST_IDENTIFIER = "subscriptions";
export const BLOCK_LIST_IDENTIFIER = "user-blocks";
/**
* Identifiers for admin-related NIP-33 lists.
* @readonly
*/
export const ADMIN_LIST_IDENTIFIERS = Object.freeze({
moderation: "editors",
editors: "editors",
whitelist: "whitelist",
blacklist: "blacklist",
eventBlacklist: "event-blacklist",
communityBlacklistSources: ADMIN_COMMUNITY_BLACKLIST_SOURCES,
});
export { ADMIN_COMMUNITY_BLACKLIST_SOURCES };
export { ADMIN_COMMUNITY_BLACKLIST_PREFIX };
const DEFAULT_APPEND_TAGS = [];
// bitvid-native zap tally event kind. Addressable (30000–39999) so `d =
// payment_hash` yields one canonical event per real payment. In bitvid's family
// (30078 video, 30079 view). See docs/zap-tally-plan.md.
export const ZAP_TALLY_KIND = 30081;
// bitvid-native creator playlist (#37). Addressable (30000–39999) so `d =
// playlist id` yields one canonical, replaceable event per playlist. In bitvid's
// family (30078 video, 30079 view, 30081 zap tally). Deliberately NOT NIP-51's
// 30005 "video curation set": bitvid already reads 30005 as legacy interest-sets
// (hashtagPreferencesService), so reusing it would collide. Ordered `a` tags
// (30078:pubkey:d) reference the videos; order = tag order.
export const PLAYLIST_KIND = 30082;
// bitvid submission (#23): applications / appeals / bug / feature / feedback,
// PUBLIC + structured so an admin "Submissions" tab can list + approve/deny them
// (replacing the old kind-4 encrypted DM flow). Addressable (30000–39999) in
// bitvid's family; published from an EPHEMERAL key (no login), so the `applicant`
// tag carries the CLAIMED npub the admin vouches for. `d` = ephemeral id.
export const SUBMISSION_KIND = 30083;
let cachedUtf8Encoder = null;
const ADDITIONAL_POINTER_TAGS = new Set(["p", "e"]);
const HEX_32_BYTE_REGEX = /^[0-9a-f]{64}$/i;
function safeStringify(value) {
if (typeof value === "string") {
return value;
}
if (value === null || value === undefined) {
return "";
}
if (typeof value === "object") {
try {
return JSON.stringify(value);
} catch (e) {
return "";
}
}
try {
return String(value);
} catch (err) {
return "";
}
}
function normalizePointerIdentifier(value) {
if (typeof value !== "string") {
return "";
}
const trimmed = value.trim();
if (!trimmed) {
return "";
}
if (HEX_32_BYTE_REGEX.test(trimmed)) {
return trimmed.toLowerCase();
}
const decoded = decodeNpubToHex(trimmed);
if (decoded && HEX_32_BYTE_REGEX.test(decoded)) {
return decoded.toLowerCase();
}
return "";
}
function normalizeTagSlotValue(value) {
if (typeof value === "string") {
const trimmed = value.trim();
return trimmed;
}
if (typeof value === "number") {
if (!Number.isFinite(value)) {
return "";
}
return String(value);
}
if (value === null || value === undefined) {
return "";
}
return "";
}
/**
* Normalizes and sanitizes an array of additional user-provided tags.
* Ensures that tags are arrays of strings and handles hex/npub conversion for 'p'/'e' tags.
*
* @param {Array<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"]);
}
// Addressable coordinate of the video's NIP-71 mirror, when one exists. Lets
// quote-aware clients and indexers link the share note to the video event
// that its body quotes via `nostr:naddr1…`.
const mirrorCoordinate =
typeof video?.mirrorCoordinate === "string"
? video.mirrorCoordinate.trim()
: "";
if (/^\d+:[0-9a-f]{64}:/i.test(mirrorCoordinate)) {
tags.push(["a", mirrorCoordinate, "", "mention"]);
}
// NIP-92 `imeta` for the thumbnail. The share content ends with the raw
// thumbnail URL, and many clients only render such a URL inline when an
// imeta entry describes it — without this the viewer just sees a bare link.
// Only emitted for http(s) thumbnails: relative paths and data: URIs are
// meaningless to a reader on another client.
//
// SKIPPED when the note quotes a mirror: the quote card already renders the
// video with its own thumbnail, and the body carries no bare image URL to
// describe. Emitting both showed the same image twice.
const quotesMirror =
typeof video?.mirrorNaddr === "string" && video.mirrorNaddr.trim() !== "";
const thumbnailUrl =
typeof video?.thumbnail === "string" ? video.thumbnail.trim() : "";
if (!quotesMirror && /^https?:\/\//i.test(thumbnailUrl)) {
const imeta = ["imeta", `url ${thumbnailUrl}`];
// Omit `m` entirely when the extension is unrecognized. A wrong media type
// is worse than a missing one — clients use it to decide whether to render
// the URL as an image at all.
const mimeType = inferImageMimeTypeFromUrl(thumbnailUrl);
if (mimeType) {
imeta.push(`m ${mimeType}`);
}
// Alt text for screen readers, and a caption fallback in clients that show
// it. Newlines would corrupt the space-delimited imeta entry, so collapse.
const altText =
typeof video?.title === "string" ? video.title.replace(/\s+/g, " ").trim() : "";
if (altText) {
imeta.push(`alt ${altText}`);
}
tags.push(imeta);
}
const seenRelayTags = new Set();
const pushRelayTag = (url, marker) => {
const normalizedUrl = typeof url === "string" ? url.trim() : "";
if (!normalizedUrl) {
return;
}
const normalizedMarker =
typeof marker === "string" ? marker.trim().toLowerCase() : "";
const resolvedMarker =
normalizedMarker === "read" || normalizedMarker === "write"
? normalizedMarker
: "";
const dedupeKey = `${normalizedUrl}|${resolvedMarker}`;
if (seenRelayTags.has(dedupeKey)) {
return;
}
seenRelayTags.add(dedupeKey);
if (resolvedMarker) {
const markerValue = resolvedMarker === "read" ? readMarker : writeMarker;
tags.push([relayTagName, normalizedUrl, markerValue]);
} else {
tags.push([relayTagName, normalizedUrl]);
}
};
if (Array.isArray(relays)) {
relays.forEach((entry) => {
if (typeof entry === "string") {
pushRelayTag(entry, "");
return;
}
if (Array.isArray(entry) && entry.length) {
if (entry[0] === relayTagName) {
pushRelayTag(entry[1], entry[2]);
return;
}
pushRelayTag(entry[0], entry[1]);
return;
}
if (entry && typeof entry === "object") {
const url =
typeof entry.url === "string"
? entry.url
: typeof entry.relay === "string"
? entry.relay
: "";
let marker = "";
if (typeof entry.mode === "string") {
marker = entry.mode;
} else if (typeof entry.marker === "string") {
marker = entry.marker;
} else if (entry.read === true && entry.write === false) {
marker = "read";
} else if (entry.write === true && entry.read === false) {
marker = "write";
}
pushRelayTag(url, marker);
}
});
}
appendSchemaTags(tags, schema);
const sanitizedAdditionalTags = sanitizeAdditionalTags(additionalTags);
if (sanitizedAdditionalTags.length) {
tags.push(...sanitizedAdditionalTags.map((tag) => tag.slice()));
}
const resolvedContent = ensureValidUtf8Content(content);
const event = {
kind: schema?.kind ?? 1,
pubkey,
created_at,
tags,
content: resolvedContent,
};
if (isDevMode) {
validateEventAgainstSchema(NOTE_TYPES.SHARE, event);
}
return event;
}
/**
* Builds a Relay List event (Kind 10002).
* NIP-65: Defines read/write relays for the user.
*
* @param {Object} params - Params.
* @param {Array<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,
};
}