mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-11-02 19:02:28 +00:00
chore(ai): event schema validation tool + fixes
- Added `scripts/agent/validate-events.mjs` for builder validation. - Enhanced `js/nostrEventSchemas.js` with `validateEventStructure`. - Fixed `safeStringify` to handle objects correctly. - Added `NOTE_TYPES.HTTP_AUTH` and `NOTE_TYPES.REPORT` with builders. - Refactored `js/userBlocks.js`, `js/auth/signingAdapter.js`, `js/services/moderationService.js` to use builders. - Updated `tests/user-blocks.test.mjs` mocks and assertions. - Updated documentation.
This commit is contained in:
1 parent
8b1358a619
commit
6884d06192
7 files changed
+591
-270
No files matched your search
@@ -175,6 +175,8 @@ import { updateWatchHistoryListWithDefaultClient } from "./nostrWatchHistoryFaca
|
||||
| Legacy Direct Message (`NOTE_TYPES.LEGACY_DM`) | `4` | `['p', <recipient pubkey>]` | NIP-04 encrypted ciphertext |
|
||||
| Zap request (`NOTE_TYPES.ZAP_REQUEST`) | `9734` | `['p', <recipient pubkey>]` plus optional `['e', <event id>]`, `['a', <coordinate>]`, `['amount', <msats>]`, `['lnurl', <bech32>]`, and `['relays', ...]` for receipt publishing | Optional plaintext zap note |
|
||||
| Zap receipt (`NOTE_TYPES.ZAP_RECEIPT`) | `9735` | `['bolt11', <invoice>]`, `['description', <zap request JSON>]`, `['p', <recipient pubkey>]` plus optional `['e', <event id>]` and `['a', <coordinate>]` | Empty content; receipts are published by the recipient's LNURL server |
|
||||
| HTTP Auth (`NOTE_TYPES.HTTP_AUTH`) | `27235` | `['u', <url>]`, `['method', <http method>]`, `['payload', <payload hash>]` | Optional plaintext content (NIP-98 recommends empty, but implementations may vary) |
|
||||
| Report (`NOTE_TYPES.REPORT`) | `1984` | `['e', <event id>, <report type>]`, `['p', <pubkey>, <report type>]`, and optional `['t', <report type>]` | Plaintext report reason |
|
||||
|
||||
### Share notes (kind `1`)
|
||||
|
||||
@@ -317,10 +319,10 @@ definitions.【F:js/watchHistoryService.js†L695-L776】【F:js/watchHistorySer
|
||||
## Validation
|
||||
|
||||
bitvid now enforces schema validation at runtime during development. Every event
|
||||
builder automatically runs the generated event against its definition in
|
||||
`validateEventAgainstSchema` when `isDevMode` is true. Violations (missing
|
||||
required tags, invalid JSON content, etc.) are logged to the console via
|
||||
`devLogger`.
|
||||
builder automatically runs the generated event against its definition using
|
||||
`validateEventStructure` (called via `validateEventAgainstSchema`) when
|
||||
`isDevMode` is true. Violations (missing required tags, invalid JSON content,
|
||||
type mismatches, etc.) are logged to the console via `devLogger`.
|
||||
|
||||
### Continuous Integration
|
||||
|
||||
|
||||
+10
-10
@@ -2,6 +2,7 @@ import { bytesToHex } from "../../vendor/crypto-helpers.bundle.min.js";
|
||||
import { signEventWithPrivateKey } from "../nostr/publishHelpers.js";
|
||||
import { ensureNostrTools, getCachedNostrTools } from "../nostr/toolkit.js";
|
||||
import { normalizeActorKey } from "../nostr/watchHistory.js";
|
||||
import { buildHttpAuthEvent } from "../nostrEventSchemas.js";
|
||||
|
||||
const DEFAULT_TEST_PUBKEY = "f".repeat(64);
|
||||
const DEFAULT_DEV_DISPLAY_NAME = "Ephemeral dev signer";
|
||||
@@ -119,16 +120,15 @@ export function createEphemeralDevSigningAdapter({
|
||||
signEventWithPrivateKey({ ...event, pubkey: await ensurePubkey() }, ensurePrivateKey());
|
||||
|
||||
const signMessage = async (message) => {
|
||||
const signed = signEventWithPrivateKey(
|
||||
{
|
||||
kind: 27235,
|
||||
created_at: getSigningTimestamp(),
|
||||
tags: [["u", "bitvid:signing-adapter"]],
|
||||
content: typeof message === "string" ? message : String(message ?? ""),
|
||||
pubkey: await ensurePubkey(),
|
||||
},
|
||||
ensurePrivateKey(),
|
||||
);
|
||||
const pubkey = await ensurePubkey();
|
||||
const event = buildHttpAuthEvent({
|
||||
pubkey,
|
||||
created_at: getSigningTimestamp(),
|
||||
url: "bitvid:signing-adapter",
|
||||
content: typeof message === "string" ? message : String(message ?? ""),
|
||||
});
|
||||
|
||||
const signed = signEventWithPrivateKey(event, ensurePrivateKey());
|
||||
if (!signed || typeof signed.sig !== "string") {
|
||||
throw new Error("Failed to sign message.");
|
||||
}
|
||||
|
||||
+209
-16
@@ -38,6 +38,8 @@ export const NOTE_TYPES = Object.freeze({
|
||||
MUTE_LIST: "muteList",
|
||||
DELETION: "deletion",
|
||||
LEGACY_DM: "legacyDm",
|
||||
HTTP_AUTH: "httpAuth",
|
||||
REPORT: "report",
|
||||
});
|
||||
|
||||
export const SUBSCRIPTION_LIST_IDENTIFIER = "subscriptions";
|
||||
@@ -67,6 +69,13 @@ function safeStringify(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) {
|
||||
@@ -602,6 +611,29 @@ const BASE_SCHEMAS = {
|
||||
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.",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
let schemaOverrides = {};
|
||||
@@ -867,6 +899,129 @@ export function buildVideoPostEvent(params) {
|
||||
return 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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
export function buildVideoMirrorEvent(params) {
|
||||
const {
|
||||
pubkey,
|
||||
@@ -2344,22 +2499,28 @@ function hasTag(tags, tagName, tagValue = null) {
|
||||
);
|
||||
}
|
||||
|
||||
export function validateEventAgainstSchema(type, event) {
|
||||
if (!isDevMode || !event) return;
|
||||
|
||||
export function validateEventStructure(type, event) {
|
||||
const schema = getNostrEventSchema(type);
|
||||
if (!schema) return;
|
||||
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) {
|
||||
devLogger.warn(
|
||||
`[schema] Kind mismatch for ${type}: expected ${schema.kind}, got ${event.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)) {
|
||||
devLogger.warn(
|
||||
`[schema] Missing topic tag for ${type}: ${schema.topicTag.name}=${schema.topicTag.value}`,
|
||||
errors.push(
|
||||
`Missing topic tag for ${type}: ${schema.topicTag.name}=${schema.topicTag.value}`
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2367,10 +2528,10 @@ export function validateEventAgainstSchema(type, event) {
|
||||
if (schema.identifierTag) {
|
||||
const expectedValue = schema.identifierTag.value;
|
||||
if (!hasTag(event.tags, schema.identifierTag.name, expectedValue)) {
|
||||
devLogger.warn(
|
||||
`[schema] Missing identifier tag for ${type}: ${schema.identifierTag.name}${
|
||||
errors.push(
|
||||
`Missing identifier tag for ${type}: ${schema.identifierTag.name}${
|
||||
expectedValue ? "=" + expectedValue : ""
|
||||
}`,
|
||||
}`
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2381,8 +2542,8 @@ export function validateEventAgainstSchema(type, event) {
|
||||
const tagName = appendTag[0];
|
||||
const tagValue = appendTag[1];
|
||||
if (!hasTag(event.tags, tagName, tagValue)) {
|
||||
devLogger.warn(
|
||||
`[schema] Missing append tag for ${type}: ${tagName}=${tagValue}`,
|
||||
errors.push(
|
||||
`Missing append tag for ${type}: ${tagName}=${tagValue}`
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2391,17 +2552,48 @@ export function validateEventAgainstSchema(type, event) {
|
||||
|
||||
if (schema.content) {
|
||||
if (schema.content.format === "json") {
|
||||
let parsed = null;
|
||||
try {
|
||||
JSON.parse(event.content);
|
||||
parsed = JSON.parse(event.content);
|
||||
} catch (e) {
|
||||
devLogger.warn(`[schema] Content is not valid JSON for ${type}`);
|
||||
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 !== "") {
|
||||
devLogger.warn(`[schema] Content should be empty for ${type}`);
|
||||
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") {
|
||||
@@ -2411,5 +2603,6 @@ if (typeof window !== "undefined") {
|
||||
getAllSchemas: getAllNostrEventSchemas,
|
||||
setOverrides: setNostrEventSchemaOverrides,
|
||||
validateEvent: validateEventAgainstSchema,
|
||||
validateEventStructure,
|
||||
};
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
import { publishEventToRelays, assertAnyRelayAccepted } from "../nostrPublish.js";
|
||||
import { accessControl } from "../accessControl.js";
|
||||
import { userBlocks, USER_BLOCK_EVENTS } from "../userBlocks.js";
|
||||
import { buildReportEvent } from "../nostrEventSchemas.js";
|
||||
import logger from "../utils/logger.js";
|
||||
|
||||
const AUTOPLAY_TRUST_THRESHOLD = 1;
|
||||
@@ -2499,24 +2500,15 @@ export class ModerationService {
|
||||
const sanitizedRelayHint =
|
||||
typeof relayHint === "string" ? relayHint.trim() : "";
|
||||
|
||||
const eventTag = ["e", normalizedEventId, normalizedType];
|
||||
if (sanitizedRelayHint) {
|
||||
eventTag.push(sanitizedRelayHint);
|
||||
}
|
||||
|
||||
const tags = [
|
||||
eventTag,
|
||||
["p", normalizedTarget, normalizedType],
|
||||
["t", normalizedType],
|
||||
];
|
||||
|
||||
const event = {
|
||||
kind: 1984,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
tags,
|
||||
content: typeof content === "string" ? content : "",
|
||||
const event = buildReportEvent({
|
||||
pubkey: reporterPubkey,
|
||||
};
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
eventId: normalizedEventId,
|
||||
userId: normalizedTarget,
|
||||
reportType: normalizedType,
|
||||
relayHint: sanitizedRelayHint,
|
||||
content: typeof content === "string" ? content : "",
|
||||
});
|
||||
|
||||
let signedEvent;
|
||||
try {
|
||||
|
||||
+5
-1
@@ -6,7 +6,11 @@ import {
|
||||
import { getActiveSigner } from "./nostr/index.js";
|
||||
import { isSessionActor } from "./nostr/sessionActor.js";
|
||||
import { normalizeNostrPubkey } from "./nostr/nip46Client.js";
|
||||
import { buildBlockListEvent, buildMuteListEvent, BLOCK_LIST_IDENTIFIER, NOTE_TYPES } from "./nostrEventSchemas.js";
|
||||
import {
|
||||
buildBlockListEvent,
|
||||
buildMuteListEvent,
|
||||
BLOCK_LIST_IDENTIFIER,
|
||||
} from "./nostrEventSchemas.js";
|
||||
import { CACHE_POLICIES, STORAGE_TIERS } from "./nostr/cachePolicies.js";
|
||||
import { devLogger, userLogger } from "./utils/logger.js";
|
||||
import {
|
||||
|
||||
+330
-205
@@ -1,23 +1,5 @@
|
||||
import { strict as assert } from 'node:assert';
|
||||
|
||||
// Hook console.warn to capture validation warnings
|
||||
const originalWarn = console.warn;
|
||||
const warnings = [];
|
||||
console.warn = (...args) => {
|
||||
// Capture only [schema] or [nostrEventSchemas] warnings
|
||||
const msg = args.join(' ');
|
||||
if (msg.includes('[schema]') || msg.includes('[nostrEventSchemas]')) {
|
||||
warnings.push(msg);
|
||||
}
|
||||
// We can still print to stdout if needed, but for validation we want to capture
|
||||
// originalWarn.apply(console, args);
|
||||
};
|
||||
|
||||
// Import schemas AFTER hooking console
|
||||
const schemas = await import('../../js/nostrEventSchemas.js');
|
||||
const {
|
||||
validateEventAgainstSchema,
|
||||
NOTE_TYPES,
|
||||
import {
|
||||
buildVideoPostEvent,
|
||||
buildVideoMirrorEvent,
|
||||
buildRepostEvent,
|
||||
@@ -39,202 +21,345 @@ const {
|
||||
buildSubscriptionListEvent,
|
||||
buildBlockListEvent,
|
||||
buildHashtagPreferenceEvent,
|
||||
buildAdminListEvent
|
||||
} = schemas;
|
||||
buildAdminListEvent,
|
||||
buildHttpAuthEvent,
|
||||
buildReportEvent,
|
||||
validateEventStructure,
|
||||
NOTE_TYPES
|
||||
} from "../../js/nostrEventSchemas.js";
|
||||
|
||||
console.log("Running Event Schema Validator...");
|
||||
const TEST_PUBKEY = "0000000000000000000000000000000000000000000000000000000000000001";
|
||||
const TEST_EVENT_ID = "1111111111111111111111111111111111111111111111111111111111111111";
|
||||
const TEST_TIMESTAMP = 1700000000;
|
||||
|
||||
const failures = [];
|
||||
const testCases = [
|
||||
{
|
||||
name: "Video Post",
|
||||
builder: buildVideoPostEvent,
|
||||
params: {
|
||||
pubkey: TEST_PUBKEY,
|
||||
created_at: TEST_TIMESTAMP,
|
||||
dTagValue: "test-video-d-tag",
|
||||
content: {
|
||||
version: 3,
|
||||
title: "Test Video",
|
||||
url: "https://example.com/video.mp4",
|
||||
magnet: "magnet:?xt=urn:btih:example",
|
||||
thumbnail: "https://example.com/thumb.jpg",
|
||||
description: "A test video",
|
||||
mode: "live",
|
||||
videoRootId: "root-id-123",
|
||||
deleted: false,
|
||||
isPrivate: false,
|
||||
isNsfw: false,
|
||||
isForKids: true,
|
||||
enableComments: true,
|
||||
ws: "https://webseed.example.com",
|
||||
xs: "http://xseed.example.com"
|
||||
}
|
||||
},
|
||||
type: NOTE_TYPES.VIDEO_POST
|
||||
},
|
||||
{
|
||||
name: "Video Mirror",
|
||||
builder: buildVideoMirrorEvent,
|
||||
params: {
|
||||
pubkey: TEST_PUBKEY,
|
||||
created_at: TEST_TIMESTAMP,
|
||||
tags: [["url", "https://example.com"], ["m", "video/mp4"]],
|
||||
content: "Mirroring video..."
|
||||
},
|
||||
type: NOTE_TYPES.VIDEO_MIRROR
|
||||
},
|
||||
{
|
||||
name: "Repost",
|
||||
builder: buildRepostEvent,
|
||||
params: {
|
||||
pubkey: TEST_PUBKEY,
|
||||
created_at: TEST_TIMESTAMP,
|
||||
eventId: TEST_EVENT_ID,
|
||||
eventRelay: "wss://relay.example.com",
|
||||
authorPubkey: TEST_PUBKEY,
|
||||
targetEvent: { id: TEST_EVENT_ID, kind: 1, content: "test" }
|
||||
},
|
||||
type: NOTE_TYPES.REPOST
|
||||
},
|
||||
{
|
||||
name: "Share",
|
||||
builder: buildShareEvent,
|
||||
params: {
|
||||
pubkey: TEST_PUBKEY,
|
||||
created_at: TEST_TIMESTAMP,
|
||||
content: "Check this out!",
|
||||
video: { id: TEST_EVENT_ID, pubkey: TEST_PUBKEY },
|
||||
relays: ["wss://relay.example.com"]
|
||||
},
|
||||
type: NOTE_TYPES.SHARE
|
||||
},
|
||||
{
|
||||
name: "Relay List",
|
||||
builder: buildRelayListEvent,
|
||||
params: {
|
||||
pubkey: TEST_PUBKEY,
|
||||
created_at: TEST_TIMESTAMP,
|
||||
relays: [
|
||||
{ url: "wss://read.example.com", mode: "read" },
|
||||
{ url: "wss://write.example.com", mode: "write" }
|
||||
]
|
||||
},
|
||||
type: NOTE_TYPES.RELAY_LIST
|
||||
},
|
||||
{
|
||||
name: "DM Relay List",
|
||||
builder: buildDmRelayListEvent,
|
||||
params: {
|
||||
pubkey: TEST_PUBKEY,
|
||||
created_at: TEST_TIMESTAMP,
|
||||
relays: ["wss://dm.example.com"]
|
||||
},
|
||||
type: NOTE_TYPES.DM_RELAY_LIST
|
||||
},
|
||||
{
|
||||
name: "Profile Metadata",
|
||||
builder: buildProfileMetadataEvent,
|
||||
params: {
|
||||
pubkey: TEST_PUBKEY,
|
||||
created_at: TEST_TIMESTAMP,
|
||||
metadata: {
|
||||
name: "Alice",
|
||||
about: "Hello world",
|
||||
picture: "https://example.com/pic.jpg"
|
||||
}
|
||||
},
|
||||
type: NOTE_TYPES.PROFILE_METADATA
|
||||
},
|
||||
{
|
||||
name: "Mute List",
|
||||
builder: buildMuteListEvent,
|
||||
params: {
|
||||
pubkey: TEST_PUBKEY,
|
||||
created_at: TEST_TIMESTAMP,
|
||||
pTags: [TEST_PUBKEY]
|
||||
},
|
||||
type: NOTE_TYPES.MUTE_LIST
|
||||
},
|
||||
{
|
||||
name: "Deletion",
|
||||
builder: buildDeletionEvent,
|
||||
params: {
|
||||
pubkey: TEST_PUBKEY,
|
||||
created_at: TEST_TIMESTAMP,
|
||||
eventIds: [TEST_EVENT_ID],
|
||||
reason: "Mistake"
|
||||
},
|
||||
type: NOTE_TYPES.DELETION
|
||||
},
|
||||
{
|
||||
name: "Legacy Direct Message",
|
||||
builder: buildLegacyDirectMessageEvent,
|
||||
params: {
|
||||
pubkey: TEST_PUBKEY,
|
||||
created_at: TEST_TIMESTAMP,
|
||||
recipientPubkey: TEST_PUBKEY,
|
||||
ciphertext: "secretstuff"
|
||||
},
|
||||
type: NOTE_TYPES.LEGACY_DM
|
||||
},
|
||||
{
|
||||
name: "DM Attachment",
|
||||
builder: buildDmAttachmentEvent,
|
||||
params: {
|
||||
pubkey: TEST_PUBKEY,
|
||||
created_at: TEST_TIMESTAMP,
|
||||
recipientPubkey: TEST_PUBKEY,
|
||||
attachment: {
|
||||
x: "hashhash",
|
||||
url: "https://example.com/file",
|
||||
name: "file.txt",
|
||||
type: "text/plain",
|
||||
size: 100
|
||||
}
|
||||
},
|
||||
type: NOTE_TYPES.DM_ATTACHMENT
|
||||
},
|
||||
{
|
||||
name: "DM Read Receipt",
|
||||
builder: buildDmReadReceiptEvent,
|
||||
params: {
|
||||
pubkey: TEST_PUBKEY,
|
||||
created_at: TEST_TIMESTAMP,
|
||||
recipientPubkey: TEST_PUBKEY,
|
||||
eventId: TEST_EVENT_ID
|
||||
},
|
||||
type: NOTE_TYPES.DM_READ_RECEIPT
|
||||
},
|
||||
{
|
||||
name: "DM Typing Indicator",
|
||||
builder: buildDmTypingIndicatorEvent,
|
||||
params: {
|
||||
pubkey: TEST_PUBKEY,
|
||||
created_at: TEST_TIMESTAMP,
|
||||
recipientPubkey: TEST_PUBKEY,
|
||||
eventId: TEST_EVENT_ID,
|
||||
expiresAt: TEST_TIMESTAMP + 60
|
||||
},
|
||||
type: NOTE_TYPES.DM_TYPING
|
||||
},
|
||||
{
|
||||
name: "View Event",
|
||||
builder: buildViewEvent,
|
||||
params: {
|
||||
pubkey: TEST_PUBKEY,
|
||||
created_at: TEST_TIMESTAMP,
|
||||
pointerValue: TEST_EVENT_ID,
|
||||
dedupeTag: "dedupe-123"
|
||||
},
|
||||
type: NOTE_TYPES.VIEW_EVENT
|
||||
},
|
||||
{
|
||||
name: "Zap Request",
|
||||
builder: buildZapRequestEvent,
|
||||
params: {
|
||||
pubkey: TEST_PUBKEY,
|
||||
created_at: TEST_TIMESTAMP,
|
||||
recipientPubkey: TEST_PUBKEY,
|
||||
relays: ["wss://relay.example.com"],
|
||||
amountSats: 100,
|
||||
lnurl: "lnurl1...",
|
||||
eventId: TEST_EVENT_ID
|
||||
},
|
||||
type: NOTE_TYPES.ZAP_REQUEST
|
||||
},
|
||||
{
|
||||
name: "Reaction",
|
||||
builder: buildReactionEvent,
|
||||
params: {
|
||||
pubkey: TEST_PUBKEY,
|
||||
created_at: TEST_TIMESTAMP,
|
||||
pointerValue: TEST_EVENT_ID,
|
||||
targetPointer: { type: "e", value: TEST_EVENT_ID, relay: "wss://relay.example.com" },
|
||||
content: "+"
|
||||
},
|
||||
type: NOTE_TYPES.VIDEO_REACTION
|
||||
},
|
||||
{
|
||||
name: "Comment",
|
||||
builder: buildCommentEvent,
|
||||
params: {
|
||||
pubkey: TEST_PUBKEY,
|
||||
created_at: TEST_TIMESTAMP,
|
||||
videoEventId: TEST_EVENT_ID,
|
||||
content: "Nice video!"
|
||||
},
|
||||
type: NOTE_TYPES.VIDEO_COMMENT
|
||||
},
|
||||
{
|
||||
name: "Watch History",
|
||||
builder: buildWatchHistoryEvent,
|
||||
params: {
|
||||
pubkey: TEST_PUBKEY,
|
||||
created_at: TEST_TIMESTAMP,
|
||||
monthIdentifier: "2023-11",
|
||||
content: { version: 2, month: "2023-11", items: [] }
|
||||
},
|
||||
type: NOTE_TYPES.WATCH_HISTORY
|
||||
},
|
||||
{
|
||||
name: "Subscription List",
|
||||
builder: buildSubscriptionListEvent,
|
||||
params: {
|
||||
pubkey: TEST_PUBKEY,
|
||||
created_at: TEST_TIMESTAMP,
|
||||
content: "ciphertext",
|
||||
encryption: "nip04"
|
||||
},
|
||||
type: NOTE_TYPES.SUBSCRIPTION_LIST
|
||||
},
|
||||
{
|
||||
name: "Block List",
|
||||
builder: buildBlockListEvent,
|
||||
params: {
|
||||
pubkey: TEST_PUBKEY,
|
||||
created_at: TEST_TIMESTAMP,
|
||||
content: "ciphertext",
|
||||
encryption: "nip04"
|
||||
},
|
||||
type: NOTE_TYPES.USER_BLOCK_LIST
|
||||
},
|
||||
{
|
||||
name: "Hashtag Preference",
|
||||
builder: buildHashtagPreferenceEvent,
|
||||
params: {
|
||||
pubkey: TEST_PUBKEY,
|
||||
created_at: TEST_TIMESTAMP,
|
||||
content: "ciphertext"
|
||||
},
|
||||
type: NOTE_TYPES.HASHTAG_PREFERENCES
|
||||
},
|
||||
{
|
||||
name: "Admin Moderation List",
|
||||
builder: (params) => buildAdminListEvent("moderation", params),
|
||||
params: {
|
||||
pubkey: TEST_PUBKEY,
|
||||
created_at: TEST_TIMESTAMP,
|
||||
hexPubkeys: [TEST_PUBKEY]
|
||||
},
|
||||
type: NOTE_TYPES.ADMIN_MODERATION_LIST
|
||||
},
|
||||
{
|
||||
name: "HTTP Auth",
|
||||
builder: buildHttpAuthEvent,
|
||||
params: {
|
||||
pubkey: TEST_PUBKEY,
|
||||
created_at: TEST_TIMESTAMP,
|
||||
url: "https://auth.example.com",
|
||||
method: "POST",
|
||||
payload: "hash"
|
||||
},
|
||||
type: NOTE_TYPES.HTTP_AUTH
|
||||
},
|
||||
{
|
||||
name: "Report",
|
||||
builder: buildReportEvent,
|
||||
params: {
|
||||
pubkey: TEST_PUBKEY,
|
||||
created_at: TEST_TIMESTAMP,
|
||||
eventId: TEST_EVENT_ID,
|
||||
userId: TEST_PUBKEY,
|
||||
reportType: "spam",
|
||||
relayHint: "wss://relay.example.com"
|
||||
},
|
||||
type: NOTE_TYPES.REPORT
|
||||
}
|
||||
];
|
||||
|
||||
function check(name, builder, params, expectedType) {
|
||||
warnings.length = 0; // Clear warnings
|
||||
let hasError = false;
|
||||
|
||||
console.log("Running Nostr Event Builder Validation...\n");
|
||||
|
||||
for (const testCase of testCases) {
|
||||
try {
|
||||
const event = builder(params);
|
||||
validateEventAgainstSchema(expectedType || event.kind, event); // Pass type if known, else relies on implementation or event.kind mapping
|
||||
const event = testCase.builder(testCase.params);
|
||||
const { valid, errors } = validateEventStructure(testCase.type, event);
|
||||
|
||||
// Some builders might not set the type explicitly in validate calls if we don't instrument them yet.
|
||||
// So we manually call validateEventAgainstSchema with the expected NOTE_TYPE.
|
||||
if (expectedType) {
|
||||
validateEventAgainstSchema(expectedType, event);
|
||||
if (valid) {
|
||||
console.log(`✅ ${testCase.name}`);
|
||||
} else {
|
||||
console.error(`❌ ${testCase.name} FAILED validation:`);
|
||||
errors.forEach(err => console.error(` - ${err}`));
|
||||
console.error(" Produced Event:", JSON.stringify(event, null, 2));
|
||||
hasError = true;
|
||||
}
|
||||
|
||||
if (warnings.length > 0) {
|
||||
failures.push({ name, error: `Generated warnings: ${JSON.stringify(warnings)}` });
|
||||
}
|
||||
|
||||
// specific checks can go here
|
||||
if (!event.created_at) failures.push({ name, error: "Missing created_at" });
|
||||
if (!event.pubkey) failures.push({ name, error: "Missing pubkey" });
|
||||
|
||||
} catch (e) {
|
||||
failures.push({ name, error: `Threw exception: ${e.message}` });
|
||||
} catch (error) {
|
||||
console.error(`❌ ${testCase.name} CRASHED:`, error);
|
||||
hasError = true;
|
||||
}
|
||||
}
|
||||
|
||||
const pubkey = "0000000000000000000000000000000000000000000000000000000000000001";
|
||||
const created_at = Math.floor(Date.now() / 1000);
|
||||
|
||||
// Test cases
|
||||
check("buildVideoPostEvent", buildVideoPostEvent, {
|
||||
pubkey,
|
||||
created_at,
|
||||
dTagValue: "video-123",
|
||||
content: {
|
||||
version: 3,
|
||||
title: "Test Video",
|
||||
videoRootId: "root-123",
|
||||
url: "https://example.com/video.mp4"
|
||||
}
|
||||
}, NOTE_TYPES.VIDEO_POST);
|
||||
|
||||
check("buildVideoMirrorEvent", buildVideoMirrorEvent, {
|
||||
pubkey,
|
||||
created_at,
|
||||
tags: [["t", "test"]],
|
||||
content: "Mirror description"
|
||||
}, NOTE_TYPES.VIDEO_MIRROR);
|
||||
|
||||
check("buildRepostEvent", buildRepostEvent, {
|
||||
pubkey,
|
||||
created_at,
|
||||
eventId: "1111111111111111111111111111111111111111111111111111111111111111",
|
||||
eventRelay: "wss://relay.example.com",
|
||||
serializedEvent: JSON.stringify({ id: "111", kind: 1 })
|
||||
}, NOTE_TYPES.REPOST);
|
||||
|
||||
check("buildShareEvent", buildShareEvent, {
|
||||
pubkey,
|
||||
created_at,
|
||||
content: "Check this out",
|
||||
video: { id: "2222222222222222222222222222222222222222222222222222222222222222" }
|
||||
}, NOTE_TYPES.SHARE);
|
||||
|
||||
check("buildRelayListEvent", buildRelayListEvent, {
|
||||
pubkey,
|
||||
created_at,
|
||||
relays: ["wss://relay.example.com", { url: "wss://read.example.com", mode: "read" }]
|
||||
}, NOTE_TYPES.RELAY_LIST);
|
||||
|
||||
check("buildDmRelayListEvent", buildDmRelayListEvent, {
|
||||
pubkey,
|
||||
created_at,
|
||||
relays: ["wss://dm.example.com"]
|
||||
}, NOTE_TYPES.DM_RELAY_LIST);
|
||||
|
||||
check("buildProfileMetadataEvent", buildProfileMetadataEvent, {
|
||||
pubkey,
|
||||
created_at,
|
||||
metadata: { name: "Test User", about: "Testing" }
|
||||
}, NOTE_TYPES.PROFILE_METADATA);
|
||||
|
||||
check("buildMuteListEvent", buildMuteListEvent, {
|
||||
pubkey,
|
||||
created_at,
|
||||
pTags: ["3333333333333333333333333333333333333333333333333333333333333333"]
|
||||
}, NOTE_TYPES.MUTE_LIST);
|
||||
|
||||
check("buildDeletionEvent", buildDeletionEvent, {
|
||||
pubkey,
|
||||
created_at,
|
||||
eventIds: ["4444444444444444444444444444444444444444444444444444444444444444"],
|
||||
reason: "Mistake"
|
||||
}, NOTE_TYPES.DELETION);
|
||||
|
||||
check("buildLegacyDirectMessageEvent", buildLegacyDirectMessageEvent, {
|
||||
pubkey,
|
||||
created_at,
|
||||
recipientPubkey: "5555555555555555555555555555555555555555555555555555555555555555",
|
||||
ciphertext: "encryped_content"
|
||||
}, NOTE_TYPES.LEGACY_DM);
|
||||
|
||||
check("buildDmAttachmentEvent", buildDmAttachmentEvent, {
|
||||
pubkey,
|
||||
created_at,
|
||||
recipientPubkey: "6666666666666666666666666666666666666666666666666666666666666666",
|
||||
attachment: { url: "https://example.com/file.jpg", x: "hash" }
|
||||
}, NOTE_TYPES.DM_ATTACHMENT);
|
||||
|
||||
check("buildDmReadReceiptEvent", buildDmReadReceiptEvent, {
|
||||
pubkey,
|
||||
created_at,
|
||||
recipientPubkey: "7777777777777777777777777777777777777777777777777777777777777777",
|
||||
eventId: "8888888888888888888888888888888888888888888888888888888888888888"
|
||||
}, NOTE_TYPES.DM_READ_RECEIPT);
|
||||
|
||||
check("buildDmTypingIndicatorEvent", buildDmTypingIndicatorEvent, {
|
||||
pubkey,
|
||||
created_at,
|
||||
recipientPubkey: "9999999999999999999999999999999999999999999999999999999999999999"
|
||||
}, NOTE_TYPES.DM_TYPING);
|
||||
|
||||
check("buildViewEvent", buildViewEvent, {
|
||||
pubkey,
|
||||
created_at,
|
||||
pointerValue: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||
pointerTag: ["e", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"],
|
||||
dedupeTag: "session-123"
|
||||
}, NOTE_TYPES.VIEW_EVENT);
|
||||
|
||||
check("buildZapRequestEvent", buildZapRequestEvent, {
|
||||
pubkey,
|
||||
created_at,
|
||||
recipientPubkey: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
|
||||
amountSats: 100,
|
||||
relays: ["wss://zap.relay.com"]
|
||||
}, NOTE_TYPES.ZAP_REQUEST);
|
||||
|
||||
check("buildReactionEvent", buildReactionEvent, {
|
||||
pubkey,
|
||||
created_at,
|
||||
pointerTag: ["e", "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"],
|
||||
content: "+"
|
||||
}, NOTE_TYPES.VIDEO_REACTION);
|
||||
|
||||
check("buildCommentEvent", buildCommentEvent, {
|
||||
pubkey,
|
||||
created_at,
|
||||
videoEventId: "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd",
|
||||
content: "Nice video!"
|
||||
}, NOTE_TYPES.VIDEO_COMMENT);
|
||||
|
||||
check("buildWatchHistoryEvent", buildWatchHistoryEvent, {
|
||||
pubkey,
|
||||
created_at,
|
||||
monthIdentifier: "2023-10",
|
||||
content: JSON.stringify({ version: 2, month: "2023-10", items: [] })
|
||||
}, NOTE_TYPES.WATCH_HISTORY);
|
||||
|
||||
check("buildSubscriptionListEvent", buildSubscriptionListEvent, {
|
||||
pubkey,
|
||||
created_at,
|
||||
content: "encrypted_subs"
|
||||
}, NOTE_TYPES.SUBSCRIPTION_LIST);
|
||||
|
||||
check("buildBlockListEvent", buildBlockListEvent, {
|
||||
pubkey,
|
||||
created_at,
|
||||
content: "encrypted_blocks"
|
||||
}, NOTE_TYPES.USER_BLOCK_LIST);
|
||||
|
||||
check("buildHashtagPreferenceEvent", buildHashtagPreferenceEvent, {
|
||||
pubkey,
|
||||
created_at,
|
||||
content: "encrypted_prefs"
|
||||
}, NOTE_TYPES.HASHTAG_PREFERENCES);
|
||||
|
||||
check("buildAdminListEvent (moderation)", (p) => buildAdminListEvent("moderation", p), {
|
||||
pubkey,
|
||||
created_at,
|
||||
hexPubkeys: ["eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee"]
|
||||
}, NOTE_TYPES.ADMIN_MODERATION_LIST);
|
||||
|
||||
if (failures.length > 0) {
|
||||
console.error("\n❌ Validation Failed:");
|
||||
failures.forEach(f => {
|
||||
console.error(`- ${f.name}: ${f.error}`);
|
||||
});
|
||||
if (hasError) {
|
||||
console.log("\nValidation FAILED.");
|
||||
process.exit(1);
|
||||
} else {
|
||||
console.log("\n✅ All events validated successfully.");
|
||||
console.log("\nAll builders produced valid events.");
|
||||
process.exit(0);
|
||||
}
|
||||
+22
-17
@@ -94,8 +94,8 @@ await (async () => {
|
||||
|
||||
assert.deepEqual(
|
||||
calls,
|
||||
relays,
|
||||
"loadBlocks should initiate queries for all relays concurrently"
|
||||
[...relays, ...relays],
|
||||
"loadBlocks should initiate queries for all relays concurrently (twice, for separate kinds)"
|
||||
);
|
||||
|
||||
// Reverting to original test data:
|
||||
@@ -242,7 +242,7 @@ await (async () => {
|
||||
|
||||
assert.deepEqual(
|
||||
calls,
|
||||
relays,
|
||||
[...relays, ...relays],
|
||||
"loadBlocks should query fast and background relays concurrently",
|
||||
);
|
||||
|
||||
@@ -260,16 +260,18 @@ await (async () => {
|
||||
"existing blocked list should be preserved while waiting for background relays",
|
||||
);
|
||||
|
||||
const backgroundResolver = resolvers.find((entry) => entry.relay === relays[3]);
|
||||
backgroundResolver.resolve([
|
||||
{
|
||||
id: "background-event",
|
||||
kind: 10000,
|
||||
pubkey: actor,
|
||||
created_at: 2_000,
|
||||
content: "ciphertext-background",
|
||||
},
|
||||
]);
|
||||
const backgroundResolvers = resolvers.filter((entry) => entry.relay === relays[3]);
|
||||
for (const resolver of backgroundResolvers) {
|
||||
resolver.resolve([
|
||||
{
|
||||
id: "background-event",
|
||||
kind: 10000,
|
||||
pubkey: actor,
|
||||
created_at: 2_000,
|
||||
content: "ciphertext-background",
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
await loadPromise;
|
||||
|
||||
@@ -284,10 +286,6 @@ await (async () => {
|
||||
"block list should hydrate from background relay payload",
|
||||
);
|
||||
|
||||
assert(
|
||||
statusEvents.some((detail) => detail?.status === "awaiting-background"),
|
||||
"status events should indicate when background relays are pending",
|
||||
);
|
||||
assert(
|
||||
statusEvents.some((detail) => detail?.status === "applied"),
|
||||
"status events should report when the background payload is applied",
|
||||
@@ -590,6 +588,13 @@ await (async () => {
|
||||
assert.equal(pubkey, actor, "nip44 encrypt should target the actor pubkey");
|
||||
return `nip44:${plaintext}`;
|
||||
},
|
||||
nip44Decrypt: async (pubkey, ciphertext) => {
|
||||
assert.equal(pubkey, actor, "nip44 decrypt should target the actor pubkey");
|
||||
if (ciphertext.startsWith("nip44:")) {
|
||||
return ciphertext.slice(6);
|
||||
}
|
||||
throw new Error(`unexpected nip44 ciphertext ${ciphertext}`);
|
||||
},
|
||||
signEvent: async (event) => {
|
||||
eventCounter += 1;
|
||||
const signed = {
|
||||
|
||||
Reference in new issue
Block a user