UNPKG

@nostr-dev-kit/ndk

Version:

NDK - Nostr Development Kit. Includes AI Guardrails to catch common mistakes during development.

226 lines (207 loc) 9.78 kB
/** * Event signing guardrails - individual check functions */ import type { NDKEvent } from "../../events/index.js"; type ErrorFn = (id: string, message: string, hint?: string, canDisable?: boolean) => never | undefined; type WarnFn = (id: string, message: string, hint?: string) => never | undefined; /** * Check that event has a kind field */ function checkMissingKind(event: NDKEvent, error: ErrorFn): void { if (event.kind === undefined || event.kind === null) { error( "event-missing-kind", `Cannot sign event without 'kind'.\n\n` + `📦 Event data:\n` + ` • content: ${event.content ? `"${event.content.substring(0, 50)}${event.content.length > 50 ? "..." : ""}"` : "(empty)"}\n` + ` • tags: ${event.tags.length} tag${event.tags.length !== 1 ? "s" : ""}\n` + ` • kind: ${event.kind} ❌\n\n` + `Set event.kind before signing.`, "Example: event.kind = 1; // for text note", false, // Fatal error - cannot be disabled ); } } /** * Check that content is a string, not an object */ function checkContentIsObject(event: NDKEvent, error: ErrorFn): void { if (typeof event.content === "object") { const contentPreview = JSON.stringify(event.content, null, 2).substring(0, 200); error( "event-content-is-object", `Event content is an object. Content must be a string.\n\n` + `📦 Your content (${typeof event.content}):\n${contentPreview}${JSON.stringify(event.content).length > 200 ? "..." : ""}\n\n` + `❌ event.content = { ... } // WRONG\n` + `✅ event.content = JSON.stringify({ ... }) // CORRECT`, "Use JSON.stringify() for structured data: event.content = JSON.stringify(data)", false, // Fatal error - cannot be disabled ); } } /** * Check that created_at is in seconds, not milliseconds */ function checkCreatedAtMilliseconds(event: NDKEvent, error: ErrorFn): void { if (event.created_at && event.created_at > 10000000000) { const correctValue = Math.floor(event.created_at / 1000); const dateString = new Date(event.created_at).toISOString(); error( "event-created-at-milliseconds", `Event created_at is in milliseconds, not seconds.\n\n` + `📦 Your value:\n` + ` • created_at: ${event.created_at} ❌\n` + ` • Interpreted as: ${dateString}\n` + ` • Should be: ${correctValue} ✅\n\n` + `Nostr timestamps MUST be in seconds since Unix epoch.`, "Use Math.floor(Date.now() / 1000) instead of Date.now()", false, // Fatal error - cannot be disabled ); } } /** * Check that p-tags contain valid hex pubkeys */ function checkInvalidPTags(event: NDKEvent, error: ErrorFn): void { const pTags = event.getMatchingTags("p"); pTags.forEach((tag, idx) => { if (tag[1] && !/^[0-9a-f]{64}$/i.test(tag[1])) { const tagPreview = JSON.stringify(tag); error( "tag-invalid-p-tag", `p-tag[${idx}] has invalid pubkey.\n\n` + `📦 Your tag:\n ${tagPreview}\n\n` + `❌ Invalid value: "${tag[1]}"\n` + ` • Length: ${tag[1].length} (expected 64)\n` + ` • Format: ${tag[1].startsWith("npub") ? "bech32 (npub)" : "unknown"}\n\n` + `p-tags MUST contain 64-character hex pubkeys.`, tag[1].startsWith("npub") ? "Use ndkUser.pubkey instead of npub:\n ✅ event.tags.push(['p', ndkUser.pubkey])\n ❌ event.tags.push(['p', 'npub1...'])" : "p-tags must contain valid hex pubkeys (64 characters, 0-9a-f)", false, // Fatal error - cannot be disabled ); } }); } /** * Check that e-tags contain valid hex event IDs */ function checkInvalidETags(event: NDKEvent, error: ErrorFn): void { const eTags = event.getMatchingTags("e"); eTags.forEach((tag, idx) => { if (tag[1] && !/^[0-9a-f]{64}$/i.test(tag[1])) { const tagPreview = JSON.stringify(tag); const isBech32 = tag[1].startsWith("note") || tag[1].startsWith("nevent"); error( "tag-invalid-e-tag", `e-tag[${idx}] has invalid event ID.\n\n` + `📦 Your tag:\n ${tagPreview}\n\n` + `❌ Invalid value: "${tag[1]}"\n` + ` • Length: ${tag[1].length} (expected 64)\n` + ` • Format: ${isBech32 ? "bech32 (note/nevent)" : "unknown"}\n\n` + `e-tags MUST contain 64-character hex event IDs.`, isBech32 ? "Use event.id instead of bech32:\n ✅ event.tags.push(['e', referencedEvent.id])\n ❌ event.tags.push(['e', 'note1...'])" : "e-tags must contain valid hex event IDs (64 characters, 0-9a-f)", false, // Fatal error - cannot be disabled ); } }); } /** * Check for manual reply markers (should use .reply() instead) */ function checkManualReplyMarkers(event: NDKEvent, warn: WarnFn, replyEvents: WeakSet<NDKEvent>): void { if (event.kind !== 1) return; // If this event was created via .reply(), skip the check if (replyEvents.has(event)) return; const eTagsWithMarkers = event.tags.filter((tag) => tag[0] === "e" && (tag[3] === "reply" || tag[3] === "root")); if (eTagsWithMarkers.length > 0) { const tagList = eTagsWithMarkers.map((tag, idx) => ` ${idx + 1}. ${JSON.stringify(tag)}`).join("\n"); warn( "event-manual-reply-markers", `Event has ${eTagsWithMarkers.length} e-tag(s) with manual reply/root markers.\n\n` + `📦 Your tags with markers:\n${tagList}\n\n` + `⚠️ Manual reply markers detected! This will cause incorrect threading.`, `Reply events MUST be created using .reply():\n\n` + ` ✅ CORRECT:\n` + ` const replyEvent = originalEvent.reply();\n` + ` replyEvent.content = 'good point!';\n` + ` await replyEvent.publish();\n\n` + ` ❌ WRONG:\n` + ` event.tags.push(['e', eventId, '', 'reply']);\n\n` + `NDK handles all reply threading automatically - never add reply/root markers manually.`, ); } } /** * Check that hashtag tags don't include the # prefix */ function checkHashtagsWithPrefix(event: NDKEvent, error: ErrorFn): void { const tTags = event.getMatchingTags("t"); tTags.forEach((tag, idx) => { if (tag[1] && tag[1].startsWith("#")) { const tagPreview = JSON.stringify(tag); error( "tag-hashtag-with-prefix", `t-tag[${idx}] contains hashtag with # prefix.\n\n` + `📦 Your tag:\n ${tagPreview}\n\n` + `❌ Invalid value: "${tag[1]}"\n\n` + `Hashtag tags should NOT include the # symbol.`, `Remove the # prefix from hashtag tags:\n ✅ event.tags.push(['t', 'nostr'])\n ❌ event.tags.push(['t', '#nostr'])`, false, // Fatal error - cannot be disabled ); } }); } /** * Check that replaceable events use publishReplaceable() when modified */ function checkReplaceableWithOldTimestamp(event: NDKEvent, warn: WarnFn): void { if (event.kind === undefined || event.kind === null || !event.created_at) return; if (!event.isReplaceable()) return; const nowSeconds = Math.floor(Date.now() / 1000); const ageSeconds = nowSeconds - event.created_at; const TEN_SECONDS = 10; if (ageSeconds > TEN_SECONDS) { const ageMinutes = Math.floor(ageSeconds / 60); const ageDescription = ageMinutes > 0 ? `${ageMinutes} minute${ageMinutes !== 1 ? "s" : ""}` : `${ageSeconds} seconds`; warn( "event-replaceable-old-timestamp", `Publishing a replaceable event with an old created_at timestamp.\n\n` + `📦 Event details:\n` + ` • kind: ${event.kind} (replaceable)\n` + ` • created_at: ${event.created_at}\n` + ` • age: ${ageDescription} old\n` + ` • current time: ${nowSeconds}\n\n` + `⚠️ This is wrong and will be rejected by relays.`, `For replaceable events, use publishReplaceable():\n\n` + ` ✅ CORRECT:\n` + ` await event.publishReplaceable();\n` + ` // Automatically updates created_at to now\n\n` + ` ❌ WRONG:\n` + ` await event.publish();\n` + ` // Uses old created_at`, ); } } /** * Called when an event is about to be signed. * Runs all signing-related checks. */ export function signing(event: NDKEvent, error: ErrorFn, warn: WarnFn, replyEvents: WeakSet<NDKEvent>): void { checkMissingKind(event, error); checkContentIsObject(event, error); checkCreatedAtMilliseconds(event, error); checkInvalidPTags(event, error); checkInvalidETags(event, error); checkHashtagsWithPrefix(event, error); checkManualReplyMarkers(event, warn, replyEvents); } /** * Called when an event is about to be published. * Runs all publishing-related checks. */ export function publishing(event: NDKEvent, warn: WarnFn): void { checkReplaceableWithOldTimestamp(event, warn); }