chore(ai): event schema validation tool + fixes

- Created `scripts/agent/validate-events.mjs` to validate event builders against schemas, covering standard and specific usage patterns.
- Updated `js/nostrEventSchemas.js` to include NIP-17 event types (`GIFT_WRAP`, `SEAL`, `CHAT_MESSAGE`) and their builders.
- Refactored `js/nostr/client.js` to use NIP-17 builders in `sendDirectMessage`, enabling runtime schema validation.
- Updated `docs/nostr-event-schemas.md` to document the new event types.

Co-authored-by: PR0M3TH3AN <[email protected]>
This commit is contained in:
google-labs-jules[bot]andPR0M3TH3AN committed 2026-02-08 05:09:54 +00:00
1 parent 900e58714f
commit 0f04245440
4 files changed
+252 -44

No files matched your search

+3
View File
@@ -167,6 +167,9 @@ import { updateWatchHistoryListWithDefaultClient } from "./nostrWatchHistoryFaca
| DM attachment (`NOTE_TYPES.DM_ATTACHMENT`) | `15` | `['p', <recipient pubkey>]`, `['x', <sha256 of uploaded bytes>]`, `['url', <download url>]`, optional `['name', <filename>]`, `['type', <mime>]`, `['size', <bytes>]`, optional `['k', <base64 key>]`; inherits schema append tags | Empty content; attachment metadata is represented as tags |
| DM read receipt (`NOTE_TYPES.DM_READ_RECEIPT`) | `20001` | `['p', <recipient pubkey>]`, `['e', <message event id>]`, optional `['k', <message kind>]`; inherits schema append tags | Empty content; ephemeral receipt for message activity |
| DM typing indicator (`NOTE_TYPES.DM_TYPING`) | `20002` | `['p', <recipient pubkey>]`, optional `['e', <conversation event id>]`, `['t','typing']`, `['expiration', <unix seconds>]`; inherits schema append tags | Empty content; ephemeral typing indicator that expires quickly |
| Gift Wrap (`NOTE_TYPES.GIFT_WRAP`) | `1059` | `['p', <recipient pubkey>]` | NIP-44 encrypted seal |
| Seal (`NOTE_TYPES.SEAL`) | `13` | Empty tags usually | NIP-44 encrypted rumor |
| Chat Message (`NOTE_TYPES.CHAT_MESSAGE`) | `14` | `['p', <recipient pubkey>]` | Plain text chat message |
> **Publishing note:** Session actors only emit passive telemetry (for example, view counters) and must **not** sign video comments. Require a logged-in Nostr signer for comment publishing via [`commentEvents`](../js/nostr/commentEvents.js).
+18 -21
View File
@@ -67,6 +67,9 @@ import {
buildDmAttachmentEvent,
buildDeletionEvent,
buildLegacyDirectMessageEvent,
buildGiftWrapEvent,
buildSealEvent,
buildChatMessageEvent,
getNostrEventSchema,
NOTE_TYPES,
} from "../nostrEventSchemas.js";
@@ -5876,13 +5879,12 @@ export class NostrClient {
const createdAt = Math.floor(Date.now() / 1000);
if (trimmedMessage) {
rumorEvents.push({
kind: 14,
created_at: createdAt,
tags: [["p", targetHex]],
content: trimmedMessage,
rumorEvents.push(buildChatMessageEvent({
pubkey: actorHex,
});
created_at: createdAt,
recipientPubkey: targetHex,
content: trimmedMessage,
}));
}
if (hasAttachments) {
@@ -5921,16 +5923,14 @@ export class NostrClient {
relayHint,
rumorEvent,
) => {
const sealPayload = {
kind: 13,
const sealPayload = buildSealEvent({
pubkey: actorHex,
created_at: randomPastTimestamp(),
tags: [],
content: await signer.nip44Encrypt(
ciphertext: await signer.nip44Encrypt(
recipientPubkey,
JSON.stringify(rumorEvent),
),
pubkey: actorHex,
};
});
const signedSeal = await signingAdapter.signEvent(sealPayload);
if (!signedSeal || typeof signedSeal.id !== "string") {
@@ -5953,17 +5953,14 @@ export class NostrClient {
recipientPubkey,
JSON.stringify(signedSeal),
);
const wrapTags = relayHint
? [["p", recipientPubkey, relayHint]]
: [["p", recipientPubkey]];
const wrapEvent = {
kind: 1059,
created_at: randomPastTimestamp(),
tags: wrapTags,
content: wrapCiphertext,
const wrapEvent = buildGiftWrapEvent({
pubkey: wrapperKeys.pubkey,
};
created_at: randomPastTimestamp(),
recipientPubkey,
relayHint,
ciphertext: wrapCiphertext,
});
return {
wrap: signEventWithPrivateKey(wrapEvent, wrapperKeys.privateKey),
+145
View File
@@ -45,6 +45,9 @@ export const NOTE_TYPES = Object.freeze({
LEGACY_DM: "legacyDm",
HTTP_AUTH: "httpAuth",
REPORT: "report",
GIFT_WRAP: "giftWrap",
SEAL: "seal",
CHAT_MESSAGE: "chatMessage",
});
export const SUBSCRIPTION_LIST_IDENTIFIER = "subscriptions";
@@ -665,6 +668,38 @@ const BASE_SCHEMAS = {
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 = {};
@@ -1097,6 +1132,116 @@ export function buildReportEvent(params) {
return 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;
}
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;
}
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;
}
export function buildVideoMirrorEvent(params) {
const {
pubkey,
+86 -23
View File
@@ -65,7 +65,12 @@ import {
buildSubscriptionListEvent,
buildBlockListEvent,
buildHashtagPreferenceEvent,
buildAdminListEvent
buildAdminListEvent,
buildGiftWrapEvent,
buildSealEvent,
buildChatMessageEvent,
getNostrEventSchema,
sanitizeAdditionalTags
} from '../../js/nostrEventSchemas.js';
import { buildNip71VideoEvent } from '../../js/nostr/nip71.js';
@@ -73,7 +78,7 @@ import { buildNip71VideoEvent } from '../../js/nostr/nip71.js';
const TEST_PUBKEY = '0000000000000000000000000000000000000000000000000000000000000001';
const TEST_TIMESTAMP = 1672531200; // 2023-01-01
function runTest(name, builderFn, input, expectedType) {
function runTest(name, builderFn, input, expectedType, additionalCheck) {
console.log(`\nTesting: ${name}`);
try {
const event = builderFn(input);
@@ -84,6 +89,14 @@ function runTest(name, builderFn, input, expectedType) {
const { valid, errors } = validateEventStructure(expectedType, event);
if (valid) {
if (additionalCheck) {
try {
additionalCheck(event);
} catch (checkError) {
console.error(`❌ Additional check failed: ${checkError.message}`);
return false;
}
}
console.log(`✅ Valid ${expectedType} (Kind: ${event.kind})`);
return true;
} else {
@@ -101,47 +114,70 @@ async function main() {
console.log('Starting Event Schema Validation...');
let failures = 0;
// 1. Video Post
const videoInput = {
// --- Real-world usage patterns ---
// 1. Video Post (mimicking js/nostr/client.js publishVideo)
// Testing auto-generation of 's' tag from URL
const videoInputReal = {
pubkey: TEST_PUBKEY,
created_at: TEST_TIMESTAMP,
dTagValue: 'test-video-id',
dTagValue: 'test-video-id-123',
content: {
version: 3,
title: 'Test Video',
videoRootId: 'test-video-id',
title: 'Real World Video',
videoRootId: 'test-video-id-123',
url: 'https://example.com/video.mp4',
magnet: 'magnet:?xt=urn:btih:1234567890abcdef1234567890abcdef12345678',
thumbnail: 'https://example.com/thumb.jpg',
description: 'A test video',
mode: 'live',
deleted: false,
isPrivate: false,
isNsfw: false,
isForKids: true,
isForKids: false,
enableComments: true
}
},
// No explicit 's' tag passed, should be generated
additionalTags: [['t', 'art']]
};
if (!runTest('buildVideoPostEvent', buildVideoPostEvent, videoInput, NOTE_TYPES.VIDEO_POST)) failures++;
// 2. Relay List
const relayListInput = {
if (!runTest('buildVideoPostEvent (Real Usage - Auto Storage Pointer)', buildVideoPostEvent, videoInputReal, NOTE_TYPES.VIDEO_POST, (event) => {
const sTag = event.tags.find(t => t[0] === 's');
if (!sTag) throw new Error("Missing 's' tag (Storage Pointer) which should have been auto-generated from URL");
if (!sTag[1].startsWith('url:')) throw new Error(`'s' tag should start with 'url:', got ${sTag[1]}`);
})) failures++;
// 2. Relay List (mimicking js/relayManager.js)
const relayListInputReal = {
pubkey: TEST_PUBKEY,
created_at: TEST_TIMESTAMP,
relays: [
{ url: 'wss://relay.example.com', mode: 'read' },
{ url: 'wss://relay.other.com', mode: 'write' },
'wss://relay.both.com'
{ url: 'wss://relay.damus.io', mode: 'read' },
{ url: 'wss://nos.lol', mode: 'write' },
{ url: 'wss://relay.primal.net', mode: 'both' }
]
};
if (!runTest('buildRelayListEvent', buildRelayListEvent, relayListInput, NOTE_TYPES.RELAY_LIST)) failures++;
if (!runTest('buildRelayListEvent (Real Usage)', buildRelayListEvent, relayListInputReal, NOTE_TYPES.RELAY_LIST, (event) => {
const rTags = event.tags.filter(t => t[0] === 'r');
if (rTags.length !== 3) throw new Error(`Expected 3 'r' tags, got ${rTags.length}`);
const damus = rTags.find(t => t[1] === 'wss://relay.damus.io');
if (!damus || damus[2] !== 'read') throw new Error("Damus relay missing or incorrect marker");
})) failures++;
// 3. Hashtag Preferences
const hashtagInput = {
// 3. Hashtag Preferences (mimicking js/services/hashtagPreferencesService.js)
// Content is encrypted string (ciphertext)
const hashtagInputReal = {
pubkey: TEST_PUBKEY,
created_at: TEST_TIMESTAMP,
content: JSON.stringify({ interests: ['art', 'code'], disinterests: ['spam'] })
content: "nip44:ciphertext-blob-simulated",
// Service adds encrypted tag
additionalTags: [['encrypted', 'nip44']]
};
if (!runTest('buildHashtagPreferenceEvent', buildHashtagPreferenceEvent, hashtagInput, NOTE_TYPES.HASHTAG_PREFERENCES)) failures++;
if (!runTest('buildHashtagPreferenceEvent (Real Usage - Encrypted)', buildHashtagPreferenceEvent, hashtagInputReal, NOTE_TYPES.HASHTAG_PREFERENCES, (event) => {
if (event.content !== "nip44:ciphertext-blob-simulated") throw new Error("Content mismtach");
const dTag = event.tags.find(t => t[0] === 'd');
if (!dTag || dTag[1] !== 'bitvid:tag-preferences') throw new Error("Incorrect 'd' tag identifier");
})) failures++;
// --- Standard Checks ---
// 4. HTTP Auth
const httpAuthInput = {
@@ -339,6 +375,33 @@ async function main() {
};
if (!runTest('buildNip71VideoEvent', buildNip71VideoEvent, nip71Input, NOTE_TYPES.NIP71_VIDEO)) failures++;
// 26. NIP-17 Gift Wrap
const giftWrapInput = {
pubkey: TEST_PUBKEY,
created_at: TEST_TIMESTAMP,
recipientPubkey: TEST_PUBKEY,
ciphertext: 'encrypted-seal',
relayHint: 'wss://relay.example.com'
};
if (!runTest('buildGiftWrapEvent', buildGiftWrapEvent, giftWrapInput, NOTE_TYPES.GIFT_WRAP)) failures++;
// 27. NIP-17 Seal
const sealInput = {
pubkey: TEST_PUBKEY,
created_at: TEST_TIMESTAMP,
ciphertext: 'encrypted-rumor'
};
if (!runTest('buildSealEvent', buildSealEvent, sealInput, NOTE_TYPES.SEAL)) failures++;
// 28. NIP-17 Chat Message
const chatMessageInput = {
pubkey: TEST_PUBKEY,
created_at: TEST_TIMESTAMP,
recipientPubkey: TEST_PUBKEY,
content: 'Hello World'
};
if (!runTest('buildChatMessageEvent', buildChatMessageEvent, chatMessageInput, NOTE_TYPES.CHAT_MESSAGE)) failures++;
console.log('\n-----------------------------------');
if (failures === 0) {