Merge pull request #1372 from PR0M3TH3AN/ai/schema-validate-20250222-1045837655123295994

chore(ai): event schema validation tool + fixes
This commit is contained in:
thePR0M3TH3AN authored and GitHub committed 2026-01-19 09:20:48 -05:00
commit e9ce138e71
5 files changed
+406 -26

No files matched your search

+4 -1
View File
@@ -16,6 +16,7 @@ import {
setNostrEventSchemaOverrides,
buildVideoPostEvent,
buildRepostEvent,
buildProfileMetadataEvent,
} from "./nostrEventSchemas.js";
// Inspect the current schema
@@ -142,7 +143,7 @@ import { updateWatchHistoryListWithDefaultClient } from "./nostrWatchHistoryFaca
| --- | --- | --- | --- |
| Video post (`NOTE_TYPES.VIDEO_POST`) | `30078` | `['t','video']`, `['d', <stable video identifier>]` plus optional schema append tags | JSON payload using Content Schema v3 (`version`, `title`, optional `url`, `magnet`, `thumbnail`, `description`, `mode`, `videoRootId`, `deleted`, `isPrivate`, `isNsfw`, `isForKids`, `enableComments`, `ws`, `xs`) |
| NIP-94 mirror (`NOTE_TYPES.VIDEO_MIRROR`) | `1063` | Tags forwarded from `publishVideo` (URL, mime type, thumbnail, alt text, magnet) | Plain text alt description |
| Repost (`NOTE_TYPES.REPOST`) | `6` | `['e', <event id>, <relay?>]` with optional address pointer `['a', <kind:pubkey:identifier>, <relay?>]`, and `['p', <pubkey>]` when the origin author is known; inherits schema append tags | Empty content |
| Repost (`NOTE_TYPES.REPOST`) | `6` | `['e', <event id>, <relay?>]` with optional address pointer `['a', <kind:pubkey:identifier>, <relay?>]`, and `['p', <pubkey>]` when the origin author is known; inherits schema append tags | JSON-serialized event being reposted |
| Video comment (`NOTE_TYPES.VIDEO_COMMENT`) | `1111` | NIP-22 root scope tags `['A'\|`E`\|`I`, <pointer>, <relay?>?], `['K', <root kind>]`, and `['P', <root author pubkey>, <relay?>?]` plus parent metadata `['E'\|`I`, <parent pointer>, <relay?>?, <author?>], `['K', <parent kind>]`, `['P', <parent author>, <relay?>?]`; inherits schema append tags | Plain text body sanitized to valid UTF-8 |
> **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).
@@ -159,6 +160,8 @@ import { updateWatchHistoryListWithDefaultClient } from "./nostrWatchHistoryFaca
| Admin blacklist (`NOTE_TYPES.ADMIN_BLACKLIST`) | `30000` | `['d', 'bitvid:admin:blacklist']`, repeated `['p', <pubkey>]` entries | Empty content |
| Admin whitelist (`NOTE_TYPES.ADMIN_WHITELIST`) | `30000` | `['d', 'bitvid:admin:whitelist']`, repeated `['p', <pubkey>]` entries | Empty content |
| Storage challenge (`NOTE_TYPES.STORAGE_CHALLENGE`) | `22242` | `['challenge', <hex>]`, `['purpose', 'bitvid-storage-key']` | Empty content. This event is **ephemeral** and never published to relays. It serves as a challenge for the signer; the deterministic signature produced is used to derive the local encryption Master Key for the Storage Service. |
| Profile metadata (`NOTE_TYPES.PROFILE_METADATA`) | `0` | `['d', ...]` is not used | JSON payload with NIP-01 fields (`name`, `about`, `picture`, `nip05`, etc.) |
| Mute list (`NOTE_TYPES.MUTE_LIST`) | `10000` | Repeated `['p', <pubkey>]` tags for blocked/muted users | Optional content (often encrypted) |
Subscription lists therefore match the
[NIP-51 follow-set specification](./nips/51.md#sets) by emitting kind `30000`
+91 -2
View File
@@ -28,6 +28,7 @@ export const NOTE_TYPES = Object.freeze({
ADMIN_BLACKLIST: "adminBlacklist",
ADMIN_WHITELIST: "adminWhitelist",
PROFILE_METADATA: "profileMetadata",
MUTE_LIST: "muteList",
});
export const SUBSCRIPTION_LIST_IDENTIFIER = "subscriptions";
@@ -270,8 +271,8 @@ const BASE_SCHEMAS = {
kind: 6,
appendTags: DEFAULT_APPEND_TAGS,
content: {
format: "empty",
description: "Content field intentionally empty for pure repost events.",
format: "json",
description: "Content field contains the JSON-serialized event being reposted.",
},
},
[NOTE_TYPES.NIP71_VIDEO]: {
@@ -458,6 +459,17 @@ const BASE_SCHEMAS = {
description: "Standard NIP-01 profile metadata (name, about, picture, etc.).",
},
},
[NOTE_TYPES.MUTE_LIST]: {
type: NOTE_TYPES.MUTE_LIST,
label: "Mute list",
kind: 10000,
participantTagName: "p",
appendTags: DEFAULT_APPEND_TAGS,
content: {
format: "text",
description: "Optional content (often encrypted) with public p tags.",
},
},
};
let schemaOverrides = {};
@@ -929,6 +941,83 @@ export function buildRelayListEvent(params) {
};
}
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 = "{}";
}
}
return {
kind: schema?.kind ?? 0,
pubkey,
created_at,
tags,
content: serializedContent,
};
}
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()));
}
return {
kind: schema?.kind ?? 10000,
pubkey,
created_at,
tags,
content: typeof content === "string" ? content : "",
};
}
export function buildViewEvent(params) {
const {
pubkey,
+5 -7
View File
@@ -22,6 +22,7 @@ import {
} from "../utils/hashtagNormalization.js";
import { getActiveSigner } from "../nostr/client.js";
import { buildPublicUrl, buildR2Key } from "../r2.js";
import { buildProfileMetadataEvent } from "../nostrEventSchemas.js";
const noop = () => {};
@@ -6376,14 +6377,11 @@ export class ProfileModalController {
}
try {
const content = JSON.stringify(profile);
const event = {
kind: 0,
created_at: Math.floor(Date.now() / 1000),
const event = buildProfileMetadataEvent({
pubkey,
tags: [],
content,
};
created_at: Math.floor(Date.now() / 1000),
metadata: profile,
});
const result =
await this.services.nostrClient.signAndPublishEvent(event);
+11 -16
View File
@@ -6,7 +6,7 @@ import {
import { getActiveSigner } from "./nostr/index.js";
import { isSessionActor } from "./nostr/sessionActor.js";
import { normalizeNostrPubkey } from "./nostr/nip46Client.js";
import { buildBlockListEvent, BLOCK_LIST_IDENTIFIER, NOTE_TYPES } from "./nostrEventSchemas.js";
import { buildBlockListEvent, buildMuteListEvent, BLOCK_LIST_IDENTIFIER, NOTE_TYPES } from "./nostrEventSchemas.js";
import { CACHE_POLICIES, STORAGE_TIERS } from "./nostr/cachePolicies.js";
import { devLogger, userLogger } from "./utils/logger.js";
import {
@@ -1536,20 +1536,13 @@ class UserBlockListManager {
return null;
}
const tags = sanitizeMuteTags(blockedPubkeys, owner);
const sanitizedTags = sanitizeMuteTags(blockedPubkeys, owner);
const pTags = sanitizedTags.map((tag) => tag[1]);
const timestamp = Number.isFinite(createdAt)
? Math.max(0, Math.floor(createdAt))
: Math.floor(Date.now() / 1000);
const event = {
kind: 10000,
pubkey: owner,
created_at: timestamp,
tags,
content: "",
};
const payloadText = typeof plaintext === "string" ? plaintext : "";
let encryptedContent = "";
let encryptionTagValue = "";
@@ -1579,12 +1572,14 @@ class UserBlockListManager {
}
}
if (encryptedContent) {
event.content = encryptedContent;
if (encryptionTagValue) {
event.tags.push(["encrypted", encryptionTagValue]);
}
}
const event = buildMuteListEvent({
pubkey: owner,
created_at: timestamp,
pTags,
content: encryptedContent,
encrypted: !!encryptionTagValue,
encryptionTag: encryptionTagValue,
});
onStatus?.({ status: "mute-publishing" });
+295
View File
@@ -0,0 +1,295 @@
import {
getNostrEventSchema,
buildVideoPostEvent,
buildVideoMirrorEvent,
buildRepostEvent,
buildRelayListEvent,
buildViewEvent,
buildReactionEvent,
buildCommentEvent,
buildWatchHistoryEvent,
buildSubscriptionListEvent,
buildBlockListEvent,
buildHashtagPreferenceEvent,
buildAdminListEvent,
buildProfileMetadataEvent,
buildMuteListEvent,
NOTE_TYPES,
ADMIN_LIST_IDENTIFIERS
} from "../../js/nostrEventSchemas.js";
const TEST_PUBKEY = "0000000000000000000000000000000000000000000000000000000000000001";
const TEST_TIMESTAMP = 1700000000;
function validateEvent(event, schema, label) {
const errors = [];
// Check Kind
if (schema.kind !== undefined && event.kind !== schema.kind) {
errors.push(`Kind mismatch: expected ${schema.kind}, got ${event.kind}`);
}
// Check Content Format
if (schema.content) {
if (schema.content.format === "json") {
try {
const parsed = JSON.parse(event.content);
if (schema.content.fields) {
for (const field of schema.content.fields) {
if (field.required && parsed[field.key] === undefined) {
errors.push(`Missing required content field: ${field.key}`);
}
if (parsed[field.key] !== undefined) {
// Basic type checking
if (field.type === 'number' && typeof parsed[field.key] !== 'number') {
errors.push(`Field ${field.key} expected number, got ${typeof parsed[field.key]}`);
}
if (field.type === 'string' && typeof parsed[field.key] !== 'string') {
errors.push(`Field ${field.key} expected string, got ${typeof parsed[field.key]}`);
}
if (field.type === 'boolean' && typeof parsed[field.key] !== 'boolean') {
errors.push(`Field ${field.key} expected boolean, got ${typeof parsed[field.key]}`);
}
}
}
}
} catch (e) {
errors.push(`Content is not valid JSON: ${e.message}`);
}
} else if (schema.content.format === "empty") {
if (event.content !== "") {
errors.push(`Content expected to be empty, got length ${event.content.length}`);
}
}
}
// Check Tags
if (schema.topicTag) {
const hasTag = event.tags.some(t => t[0] === schema.topicTag.name && t[1] === schema.topicTag.value);
if (!hasTag) {
errors.push(`Missing topic tag: ${schema.topicTag.name}=${schema.topicTag.value}`);
}
}
if (schema.identifierTag) {
const hasTag = event.tags.some(t => t[0] === schema.identifierTag.name);
if (!hasTag) {
errors.push(`Missing identifier tag: ${schema.identifierTag.name}`);
}
if (schema.identifierTag.value) {
const hasValue = event.tags.some(t => t[0] === schema.identifierTag.name && t[1] === schema.identifierTag.value);
if (!hasValue) {
errors.push(`Missing identifier tag value: ${schema.identifierTag.name}=${schema.identifierTag.value}`);
}
}
}
// Append Tags Check (Simple existence check for fixed tags)
if (schema.appendTags) {
schema.appendTags.forEach(expectedTag => {
if (Array.isArray(expectedTag)) {
const found = event.tags.some(t =>
t.length >= expectedTag.length &&
expectedTag.every((val, i) => t[i] === val)
);
if (!found) {
errors.push(`Missing appended tag: ${JSON.stringify(expectedTag)}`);
}
}
});
}
if (errors.length > 0) {
console.error(`❌ ${label} validation failed:`);
errors.forEach(e => console.error(` - ${e}`));
return false;
} else {
console.log(`✅ ${label} validated successfully.`);
return true;
}
}
async function runValidation() {
console.log("Starting Event Schema Validation...\n");
let allPassed = true;
// 1. Video Post
{
const schema = getNostrEventSchema(NOTE_TYPES.VIDEO_POST);
const event = buildVideoPostEvent({
pubkey: TEST_PUBKEY,
created_at: TEST_TIMESTAMP,
dTagValue: "test-video",
content: {
version: 1,
title: "Test Video",
videoRootId: "root-id"
}
});
if (!validateEvent(event, schema, "Video Post")) allPassed = false;
}
// 2. Video Mirror
{
const schema = getNostrEventSchema(NOTE_TYPES.VIDEO_MIRROR);
const event = buildVideoMirrorEvent({
pubkey: TEST_PUBKEY,
created_at: TEST_TIMESTAMP,
content: "mirror description"
});
if (!validateEvent(event, schema, "Video Mirror")) allPassed = false;
}
// 3. Repost
{
const schema = getNostrEventSchema(NOTE_TYPES.REPOST);
const event = buildRepostEvent({
pubkey: TEST_PUBKEY,
created_at: TEST_TIMESTAMP,
eventId: "event-id",
eventRelay: "wss://relay.example.com",
serializedEvent: JSON.stringify({ id: "event-id", kind: 1, tags: [], content: "test" })
});
// Schema updated to expect JSON content, so this should pass now.
if (!validateEvent(event, schema, "Repost")) allPassed = false;
}
// 4. Relay List
{
const schema = getNostrEventSchema(NOTE_TYPES.RELAY_LIST);
const event = buildRelayListEvent({
pubkey: TEST_PUBKEY,
created_at: TEST_TIMESTAMP,
relays: ["wss://relay.one", { url: "wss://relay.two", mode: "read" }]
});
if (!validateEvent(event, schema, "Relay List")) allPassed = false;
}
// 5. View Event
{
const schema = getNostrEventSchema(NOTE_TYPES.VIEW_EVENT);
const event = buildViewEvent({
pubkey: TEST_PUBKEY,
created_at: TEST_TIMESTAMP,
dedupeTag: "dedupe-val",
includeSessionTag: true,
content: "view log"
});
if (!validateEvent(event, schema, "View Event")) allPassed = false;
}
// 6. Reaction Event
{
const schema = getNostrEventSchema(NOTE_TYPES.VIDEO_REACTION);
const event = buildReactionEvent({
pubkey: TEST_PUBKEY,
created_at: TEST_TIMESTAMP,
targetPointer: { type: "e", value: "target-id", relay: "wss://r.com" },
content: "+"
});
if (!validateEvent(event, schema, "Reaction Event")) allPassed = false;
}
// 7. Comment Event
{
const schema = getNostrEventSchema(NOTE_TYPES.VIDEO_COMMENT);
const event = buildCommentEvent({
pubkey: TEST_PUBKEY,
created_at: TEST_TIMESTAMP,
videoEventId: "vid-id",
content: "Nice video!"
});
if (!validateEvent(event, schema, "Comment Event")) allPassed = false;
}
// 8. Watch History
{
const schema = getNostrEventSchema(NOTE_TYPES.WATCH_HISTORY);
const event = buildWatchHistoryEvent({
pubkey: TEST_PUBKEY,
created_at: TEST_TIMESTAMP,
monthIdentifier: "2023-10",
content: JSON.stringify(["watched-id-1"])
});
if (!validateEvent(event, schema, "Watch History")) allPassed = false;
}
// 9. Subscription List
{
const schema = getNostrEventSchema(NOTE_TYPES.SUBSCRIPTION_LIST);
const event = buildSubscriptionListEvent({
pubkey: TEST_PUBKEY,
created_at: TEST_TIMESTAMP,
content: "encrypted-stuff"
});
if (!validateEvent(event, schema, "Subscription List")) allPassed = false;
}
// 10. Block List
{
const schema = getNostrEventSchema(NOTE_TYPES.USER_BLOCK_LIST);
const event = buildBlockListEvent({
pubkey: TEST_PUBKEY,
created_at: TEST_TIMESTAMP,
content: "encrypted-stuff"
});
if (!validateEvent(event, schema, "Block List")) allPassed = false;
}
// 11. Hashtag Preference
{
const schema = getNostrEventSchema(NOTE_TYPES.HASHTAG_PREFERENCES);
const event = buildHashtagPreferenceEvent({
pubkey: TEST_PUBKEY,
created_at: TEST_TIMESTAMP,
content: JSON.stringify({ version: 1, interests: [], disinterests: [] })
});
if (!validateEvent(event, schema, "Hashtag Preferences")) allPassed = false;
}
// 12. Admin List (Moderation)
{
const schema = getNostrEventSchema(NOTE_TYPES.ADMIN_MODERATION_LIST);
const event = buildAdminListEvent("moderation", {
pubkey: TEST_PUBKEY,
created_at: TEST_TIMESTAMP,
hexPubkeys: ["0000000000000000000000000000000000000000000000000000000000000002"]
});
if (!validateEvent(event, schema, "Admin Moderation List")) allPassed = false;
}
// 13. Profile Metadata
{
const schema = getNostrEventSchema(NOTE_TYPES.PROFILE_METADATA);
const event = buildProfileMetadataEvent({
pubkey: TEST_PUBKEY,
created_at: TEST_TIMESTAMP,
metadata: { name: "Test User", about: "Testing" }
});
if (!validateEvent(event, schema, "Profile Metadata")) allPassed = false;
}
// 14. Mute List
{
const schema = getNostrEventSchema(NOTE_TYPES.MUTE_LIST);
const event = buildMuteListEvent({
pubkey: TEST_PUBKEY,
created_at: TEST_TIMESTAMP,
pTags: ["0000000000000000000000000000000000000000000000000000000000000002"]
});
if (!validateEvent(event, schema, "Mute List")) allPassed = false;
}
if (!allPassed) {
console.error("\n❌ Some validations failed.");
process.exit(1);
} else {
console.log("\n✅ All validations passed.");
}
}
runValidation().catch(e => {
console.error(e);
process.exit(1);
});