diff --git a/docs/nostr-event-schemas.md b/docs/nostr-event-schemas.md index 5b58df44..51a9c2f3 100644 --- a/docs/nostr-event-schemas.md +++ b/docs/nostr-event-schemas.md @@ -7,6 +7,19 @@ no longer requires hunting through the codebase. It also exposes helpers for building events and for overriding the schema at runtime when you need to experiment. +## Validation + +To verify that event builders produce spec-compliant events, run the validation script: + +```bash +node scripts/agent/validate-events.mjs +``` + +This script generates example events for all defined builders and checks them against their schemas, ensuring: +- Correct kind +- Presence of required tags (topic, identifier, etc.) +- Correct content format (JSON vs text vs empty) + ## Runtime helpers ```js diff --git a/js/nostrEventSchemas.js b/js/nostrEventSchemas.js index 117ce77f..09eae359 100644 --- a/js/nostrEventSchemas.js +++ b/js/nostrEventSchemas.js @@ -891,7 +891,7 @@ export function buildRepostEvent(params) { if (normalizedEventRelay) { tags.push(["e", normalizedEventId, normalizedEventRelay]); } else { - tags.push(["e", normalizedEventId]); + throw new Error("missing-event-relay"); } } diff --git a/scripts/agent/validate-events.mjs b/scripts/agent/validate-events.mjs new file mode 100644 index 00000000..65a695d9 --- /dev/null +++ b/scripts/agent/validate-events.mjs @@ -0,0 +1,350 @@ + +import { + getNostrEventSchema, + NOTE_TYPES, + buildVideoPostEvent, + buildVideoMirrorEvent, + buildRepostEvent, + buildRelayListEvent, + buildDmRelayListEvent, + buildProfileMetadataEvent, + buildMuteListEvent, + buildDeletionEvent, + buildLegacyDirectMessageEvent, + buildDmAttachmentEvent, + buildDmReadReceiptEvent, + buildDmTypingIndicatorEvent, + buildViewEvent, + buildZapRequestEvent, + buildReactionEvent, + buildCommentEvent, + buildWatchHistoryEvent, + buildSubscriptionListEvent, + buildBlockListEvent, + buildHashtagPreferenceEvent, + buildAdminListEvent +} from '../../js/nostrEventSchemas.js'; + +const COLORS = { + RESET: "\x1b[0m", + RED: "\x1b[31m", + GREEN: "\x1b[32m", + YELLOW: "\x1b[33m", + BLUE: "\x1b[34m", +}; + +let errorCount = 0; +let successCount = 0; + +function logError(msg) { + console.error(`${COLORS.RED}[FAIL] ${msg}${COLORS.RESET}`); + errorCount++; +} + +function logSuccess(msg) { + console.log(`${COLORS.GREEN}[PASS] ${msg}${COLORS.RESET}`); + successCount++; +} + +function logInfo(msg) { + console.log(`${COLORS.BLUE}[INFO] ${msg}${COLORS.RESET}`); +} + +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 validateEvent(type, event, description) { + const schema = getNostrEventSchema(type); + if (!schema) { + logError(`${description}: No schema found for type ${type}`); + return; + } + + let valid = true; + const errors = []; + + // Validate Kind + if (event.kind !== schema.kind) { + errors.push(`Kind mismatch: expected ${schema.kind}, got ${event.kind}`); + valid = false; + } + + // Validate Topic Tag + if (schema.topicTag) { + if (!hasTag(event.tags, schema.topicTag.name, schema.topicTag.value)) { + errors.push(`Missing topic tag: ${schema.topicTag.name}=${schema.topicTag.value}`); + valid = false; + } + } + + // Validate Identifier Tag + if (schema.identifierTag) { + const expectedValue = schema.identifierTag.value; + if (expectedValue) { + if (!hasTag(event.tags, schema.identifierTag.name, expectedValue)) { + errors.push(`Missing identifier tag: ${schema.identifierTag.name}=${expectedValue}`); + valid = false; + } + } else { + // If value is not fixed in schema, just check tag name presence if it's supposed to be there. + // Usually d-tags are present. + if (!hasTag(event.tags, schema.identifierTag.name)) { + errors.push(`Missing identifier tag: ${schema.identifierTag.name}`); + valid = false; + } + } + } + + // Validate Append Tags + 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: ${tagName}=${tagValue}`); + valid = false; + } + } + }); + } + + // Validate Content Format + if (schema.content) { + if (schema.content.format === "json") { + try { + JSON.parse(event.content); + } catch (e) { + errors.push(`Content is not valid JSON`); + valid = false; + } + } else if (schema.content.format === "empty") { + if (event.content !== "") { + errors.push(`Content should be empty, got: "${event.content}"`); + valid = false; + } + } + } + + if (valid) { + logSuccess(`${description}`); + } else { + logError(`${description}`); + errors.forEach(e => console.error(` - ${e}`)); + console.log(" Event:", JSON.stringify(event, null, 2)); + } +} + +async function main() { + logInfo("Starting Event Validation..."); + + // 1. VIDEO_POST + validateEvent(NOTE_TYPES.VIDEO_POST, buildVideoPostEvent({ + pubkey: "pubkey", + created_at: 1234567890, + dTagValue: "video-id", + content: { title: "Test Video", videoRootId: "root-id" } + }), "buildVideoPostEvent (standard)"); + + validateEvent(NOTE_TYPES.VIDEO_POST, buildVideoPostEvent({ + pubkey: "pubkey", + created_at: 1234567890, + dTagValue: "video-id-minimal", + content: { version: 1, title: "Minimal", videoRootId: "root" } + }), "buildVideoPostEvent (minimal)"); + + // 2. VIDEO_MIRROR + validateEvent(NOTE_TYPES.VIDEO_MIRROR, buildVideoMirrorEvent({ + pubkey: "pubkey", + created_at: 1234567890, + content: "Alt text" + }), "buildVideoMirrorEvent"); + + // 3. REPOST + validateEvent(NOTE_TYPES.REPOST, buildRepostEvent({ + pubkey: "pubkey", + created_at: 1234567890, + eventId: "hex-id", + eventRelay: "wss://relay.example.com", + targetKind: 1, + serializedEvent: JSON.stringify({ kind: 1, content: "test" }) + }), "buildRepostEvent (event pointer)"); + + // 4. RELAY_LIST + validateEvent(NOTE_TYPES.RELAY_LIST, buildRelayListEvent({ + pubkey: "pubkey", + created_at: 1234567890, + relays: ["wss://relay.example.com"] + }), "buildRelayListEvent"); + + // 5. DM_RELAY_LIST + validateEvent(NOTE_TYPES.DM_RELAY_LIST, buildDmRelayListEvent({ + pubkey: "pubkey", + created_at: 1234567890, + relays: ["wss://relay.example.com"] + }), "buildDmRelayListEvent"); + + // 6. PROFILE_METADATA + validateEvent(NOTE_TYPES.PROFILE_METADATA, buildProfileMetadataEvent({ + pubkey: "pubkey", + created_at: 1234567890, + metadata: { name: "Test User" } + }), "buildProfileMetadataEvent"); + + // 7. MUTE_LIST + validateEvent(NOTE_TYPES.MUTE_LIST, buildMuteListEvent({ + pubkey: "pubkey", + created_at: 1234567890, + pTags: ["hex-pubkey"] + }), "buildMuteListEvent"); + + // 8. DELETION + validateEvent(NOTE_TYPES.DELETION, buildDeletionEvent({ + pubkey: "pubkey", + created_at: 1234567890, + eventIds: ["hex-id"] + }), "buildDeletionEvent"); + + // 9. LEGACY_DM + validateEvent(NOTE_TYPES.LEGACY_DM, buildLegacyDirectMessageEvent({ + pubkey: "pubkey", + created_at: 1234567890, + recipientPubkey: "hex-recipient", + ciphertext: "encrypted-content" + }), "buildLegacyDirectMessageEvent"); + + // 10. DM_ATTACHMENT + validateEvent(NOTE_TYPES.DM_ATTACHMENT, buildDmAttachmentEvent({ + pubkey: "pubkey", + created_at: 1234567890, + recipientPubkey: "hex-recipient", + attachment: { url: "https://example.com/file", x: "hash" } + }), "buildDmAttachmentEvent"); + + // 11. DM_READ_RECEIPT + validateEvent(NOTE_TYPES.DM_READ_RECEIPT, buildDmReadReceiptEvent({ + pubkey: "pubkey", + created_at: 1234567890, + recipientPubkey: "hex-recipient", + eventId: "hex-event-id" + }), "buildDmReadReceiptEvent"); + + // 12. DM_TYPING + validateEvent(NOTE_TYPES.DM_TYPING, buildDmTypingIndicatorEvent({ + pubkey: "pubkey", + created_at: 1234567890, + recipientPubkey: "hex-recipient", + eventId: "hex-event-id", + expiresAt: 1234567899 + }), "buildDmTypingIndicatorEvent"); + + // 13. VIEW_EVENT + validateEvent(NOTE_TYPES.VIEW_EVENT, buildViewEvent({ + pubkey: "pubkey", + created_at: 1234567890, + pointerValue: "video-d-tag", + dedupeTag: "unique-session-view-id", + includeSessionTag: true + }), "buildViewEvent"); + + // 14. ZAP_REQUEST + validateEvent(NOTE_TYPES.ZAP_REQUEST, buildZapRequestEvent({ + pubkey: "pubkey", + created_at: 1234567890, + recipientPubkey: "hex-recipient", + amountSats: 100, + relays: ["wss://relay.example.com"] + }), "buildZapRequestEvent"); + + // 15. VIDEO_REACTION + validateEvent(NOTE_TYPES.VIDEO_REACTION, buildReactionEvent({ + pubkey: "pubkey", + created_at: 1234567890, + targetPointer: { type: "e", value: "hex-id" }, + content: "+" + }), "buildReactionEvent"); + + // 16. VIDEO_COMMENT + validateEvent(NOTE_TYPES.VIDEO_COMMENT, buildCommentEvent({ + pubkey: "pubkey", + created_at: 1234567890, + content: "This is a comment", + videoEventId: "hex-video-id" + }), "buildCommentEvent (root)"); + + validateEvent(NOTE_TYPES.VIDEO_COMMENT, buildCommentEvent({ + pubkey: "pubkey", + created_at: 1234567890, + content: "This is a reply", + videoEventId: "hex-video-id", + rootIdentifier: "root-id", + parentCommentId: "parent-id" + }), "buildCommentEvent (reply)"); + + // 17. WATCH_HISTORY + validateEvent(NOTE_TYPES.WATCH_HISTORY, buildWatchHistoryEvent({ + pubkey: "pubkey", + created_at: 1234567890, + monthIdentifier: "2023-10", + content: "[]" + }), "buildWatchHistoryEvent"); + + // 18. SUBSCRIPTION_LIST + validateEvent(NOTE_TYPES.SUBSCRIPTION_LIST, buildSubscriptionListEvent({ + pubkey: "pubkey", + created_at: 1234567890, + content: "encrypted-json" + }), "buildSubscriptionListEvent"); + + // 19. USER_BLOCK_LIST + validateEvent(NOTE_TYPES.USER_BLOCK_LIST, buildBlockListEvent({ + pubkey: "pubkey", + created_at: 1234567890, + content: "encrypted-json" + }), "buildBlockListEvent"); + + // 20. HASHTAG_PREFERENCES + validateEvent(NOTE_TYPES.HASHTAG_PREFERENCES, buildHashtagPreferenceEvent({ + pubkey: "pubkey", + created_at: 1234567890, + content: "encrypted-json" + }), "buildHashtagPreferenceEvent"); + + // 21. ADMIN_MODERATION_LIST + validateEvent(NOTE_TYPES.ADMIN_MODERATION_LIST, buildAdminListEvent("moderation", { + pubkey: "pubkey", + created_at: 1234567890, + hexPubkeys: ["hex-pubkey"] + }), "buildAdminListEvent (moderation)"); + + // 22. ADMIN_BLACKLIST + validateEvent(NOTE_TYPES.ADMIN_BLACKLIST, buildAdminListEvent("blacklist", { + pubkey: "pubkey", + created_at: 1234567890, + hexPubkeys: ["hex-pubkey"] + }), "buildAdminListEvent (blacklist)"); + + // 23. ADMIN_WHITELIST + validateEvent(NOTE_TYPES.ADMIN_WHITELIST, buildAdminListEvent("whitelist", { + pubkey: "pubkey", + created_at: 1234567890, + hexPubkeys: ["hex-pubkey"] + }), "buildAdminListEvent (whitelist)"); + + console.log(`\nResults: ${successCount} passed, ${errorCount} failed.`); + if (errorCount > 0) { + process.exit(1); + } +} + +main().catch(err => { + console.error(err); + process.exit(1); +});