diff --git a/docs/nostr-event-schemas.md b/docs/nostr-event-schemas.md index 976ec556..d4a51ec1 100644 --- a/docs/nostr-event-schemas.md +++ b/docs/nostr-event-schemas.md @@ -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', ]` 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', , ]` with optional address pointer `['a', , ]`, and `['p', ]` when the origin author is known; inherits schema append tags | Empty content | +| Repost (`NOTE_TYPES.REPOST`) | `6` | `['e', , ]` with optional address pointer `['a', , ]`, and `['p', ]` 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`, , ?], `['K', ]`, and `['P', , ?]` plus parent metadata `['E'\|`I`, , ?, ], `['K', ]`, `['P', , ?]`; 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', ]` entries | Empty content | | Admin whitelist (`NOTE_TYPES.ADMIN_WHITELIST`) | `30000` | `['d', 'bitvid:admin:whitelist']`, repeated `['p', ]` entries | Empty content | | Storage challenge (`NOTE_TYPES.STORAGE_CHALLENGE`) | `22242` | `['challenge', ]`, `['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', ]` 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` diff --git a/js/nostrEventSchemas.js b/js/nostrEventSchemas.js index 32ad9a86..bfb5b76e 100644 --- a/js/nostrEventSchemas.js +++ b/js/nostrEventSchemas.js @@ -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, diff --git a/js/ui/profileModalController.js b/js/ui/profileModalController.js index 1de8b9b7..254be783 100644 --- a/js/ui/profileModalController.js +++ b/js/ui/profileModalController.js @@ -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); diff --git a/js/userBlocks.js b/js/userBlocks.js index 525cb385..59b07033 100644 --- a/js/userBlocks.js +++ b/js/userBlocks.js @@ -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" }); diff --git a/scripts/agent/validate-events.mjs b/scripts/agent/validate-events.mjs new file mode 100644 index 00000000..edb38d1d --- /dev/null +++ b/scripts/agent/validate-events.mjs @@ -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); +});