diff --git a/js/nostr/client.js b/js/nostr/client.js index 7f048343..f9534e41 100644 --- a/js/nostr/client.js +++ b/js/nostr/client.js @@ -58,6 +58,8 @@ import { buildRepostEvent, buildWatchHistoryEvent, buildDmAttachmentEvent, + buildDeletionEvent, + buildLegacyDirectMessageEvent, getNostrEventSchema, NOTE_TYPES, } from "../nostrEventSchemas.js"; @@ -4986,13 +4988,12 @@ export class NostrClient { return { ok: false, error: "encryption-failed", details }; } - const event = { - kind: 4, - created_at: Math.floor(Date.now() / 1000), - tags: [["p", targetHex]], - content: ciphertext, + const event = buildLegacyDirectMessageEvent({ pubkey: actorHex, - }; + created_at: Math.floor(Date.now() / 1000), + recipientPubkey: targetHex, + ciphertext, + }); let signedEvent; try { @@ -5831,21 +5832,12 @@ export class NostrClient { mode: oldContent.mode || "live", }; - const tags = [["t", "video"]]; - if (stableDTag) { - // NIP-78 requires replaceable events to publish a stable "d" tag. - // Legacy videos may not carry one, so reuse the prior event id to - // guarantee the revert stays addressable. - tags.push(["d", stableDTag]); - } - - const event = { - kind: 30078, + const event = buildVideoPostEvent({ pubkey, created_at: Math.floor(Date.now() / 1000), - tags, - content: JSON.stringify(contentObject), - }; + dTagValue: stableDTag, + content: contentObject, + }); await this.ensureActiveSignerForPubkey(pubkey); @@ -6181,17 +6173,22 @@ export class NostrClient { const chunkSize = 100; for (let index = 0; index < identifierRecords.length; index += chunkSize) { const chunk = identifierRecords.slice(index, index + chunkSize); - const deleteTags = chunk.map((record) => [record.type, record.value]); + const eventIds = chunk + .filter((record) => record.type === "e") + .map((record) => record.value); + const addresses = chunk + .filter((record) => record.type === "a") + .map((record) => record.value); - const deleteEvent = { - kind: 5, + const deleteEvent = buildDeletionEvent({ pubkey, created_at: Math.floor(Date.now() / 1000), - tags: deleteTags, - content: inferredRoot + eventIds, + addresses, + reason: inferredRoot ? `Delete video root ${inferredRoot}` : "Delete published video events", - }; + }); const signedDelete = await queueSignEvent(signer, deleteEvent); const publishResults = await publishEventToRelays( diff --git a/js/nostrEventSchemas.js b/js/nostrEventSchemas.js index e3586e84..117ce77f 100644 --- a/js/nostrEventSchemas.js +++ b/js/nostrEventSchemas.js @@ -35,6 +35,8 @@ export const NOTE_TYPES = Object.freeze({ ADMIN_WHITELIST: "adminWhitelist", PROFILE_METADATA: "profileMetadata", MUTE_LIST: "muteList", + DELETION: "deletion", + LEGACY_DM: "legacyDm", }); export const SUBSCRIPTION_LIST_IDENTIFIER = "subscriptions"; @@ -551,6 +553,27 @@ const BASE_SCHEMAS = { description: "Optional content (often encrypted) with public p tags.", }, }, + [NOTE_TYPES.DELETION]: { + type: NOTE_TYPES.DELETION, + label: "Deletion", + kind: 5, + appendTags: DEFAULT_APPEND_TAGS, + content: { + format: "text", + description: "Reason for deletion.", + }, + }, + [NOTE_TYPES.LEGACY_DM]: { + type: NOTE_TYPES.LEGACY_DM, + label: "Legacy Direct Message", + kind: 4, + appendTags: DEFAULT_APPEND_TAGS, + recipientTagName: "p", + content: { + format: "text", + description: "NIP-04 encrypted ciphertext.", + }, + }, }; let schemaOverrides = {}; @@ -1142,6 +1165,82 @@ export function buildMuteListEvent(params) { }; } +export function buildDeletionEvent(params) { + const { + pubkey, + created_at, + eventIds = [], + addresses = [], + reason = "", + additionalTags = [], + } = params || {}; + const schema = getNostrEventSchema(NOTE_TYPES.DELETION); + const tags = []; + + if (Array.isArray(eventIds)) { + eventIds.forEach((id) => { + const normalized = normalizePointerIdentifier(id); + if (normalized) { + tags.push(["e", normalized]); + } + }); + } + + if (Array.isArray(addresses)) { + addresses.forEach((addr) => { + const normalized = typeof addr === "string" ? addr.trim() : ""; + if (normalized) { + tags.push(["a", normalized]); + } + }); + } + + appendSchemaTags(tags, schema); + const sanitizedAdditionalTags = sanitizeAdditionalTags(additionalTags); + if (sanitizedAdditionalTags.length) { + tags.push(...sanitizedAdditionalTags.map((tag) => tag.slice())); + } + + return { + kind: schema?.kind ?? 5, + pubkey, + created_at, + tags, + content: typeof reason === "string" ? reason : "", + }; +} + +export function buildLegacyDirectMessageEvent(params) { + const { + pubkey, + created_at, + recipientPubkey, + ciphertext = "", + additionalTags = [], + } = params || {}; + const schema = getNostrEventSchema(NOTE_TYPES.LEGACY_DM); + const tags = []; + + const recipient = normalizePointerIdentifier(recipientPubkey); + if (recipient) { + tags.push([schema?.recipientTagName || "p", recipient]); + } + + appendSchemaTags(tags, schema); + const sanitizedAdditionalTags = sanitizeAdditionalTags(additionalTags); + if (sanitizedAdditionalTags.length) { + tags.push(...sanitizedAdditionalTags.map((tag) => tag.slice())); + } + + return { + kind: schema?.kind ?? 4, + pubkey, + created_at, + tags, + content: typeof ciphertext === "string" ? ciphertext : "", + }; +} + export function buildDmAttachmentEvent(params) { const { pubkey, diff --git a/scripts/agent/validate-events.mjs b/scripts/agent/validate-events.mjs deleted file mode 100644 index 14f7cc70..00000000 --- a/scripts/agent/validate-events.mjs +++ /dev/null @@ -1,430 +0,0 @@ -import { - NOTE_TYPES, - getNostrEventSchema, - buildVideoPostEvent, - buildVideoMirrorEvent, - buildRepostEvent, - buildRelayListEvent, - buildDmRelayListEvent, - buildProfileMetadataEvent, - buildMuteListEvent, - buildDmAttachmentEvent, - buildDmReadReceiptEvent, - buildDmTypingIndicatorEvent, - buildViewEvent, - buildZapRequestEvent, - buildReactionEvent, - buildCommentEvent, - buildWatchHistoryEvent, - buildSubscriptionListEvent, - buildBlockListEvent, - buildHashtagPreferenceEvent, - buildAdminListEvent, -} from "../../js/nostrEventSchemas.js"; - -import { buildNip71VideoEvent } from "../../js/nostr/nip71.js"; - -// Mock data -const MOCK_PUBKEY = "0000000000000000000000000000000000000000000000000000000000000001"; -const MOCK_EVENT_ID = "0000000000000000000000000000000000000000000000000000000000000002"; -const MOCK_TIMESTAMP = 1700000000; -const MOCK_URL = "https://example.com/video.mp4"; -const MOCK_THUMBNAIL = "https://example.com/thumb.jpg"; -const MOCK_MAGNET = "magnet:?xt=urn:btih:example"; -const MOCK_RELAY = "wss://relay.example.com"; -const MOCK_D_TAG = "mock-d-tag"; - -// Helper to check if a tag exists -function hasTag(tags, tagName, tagValue = null) { - return tags.some( - (tag) => - Array.isArray(tag) && - tag[0] === tagName && - (tagValue === null || tag[1] === tagValue) - ); -} - -// Validation logic -function validateEvent(type, event) { - const schema = getNostrEventSchema(type); - const errors = []; - - if (!schema) { - return [`Schema not found for type: ${type}`]; - } - - // 1. Validate Kind - if (event.kind !== schema.kind) { - errors.push(`Kind mismatch: expected ${schema.kind}, got ${event.kind}`); - } - - // 2. Validate Required Tags - // 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}` - ); - } - } - - // Identifier tag (d tag) - if (schema.identifierTag) { - // For lists with fixed identifier values (like subscription list), check the value - const expectedValue = schema.identifierTag.value; - if (!hasTag(event.tags, schema.identifierTag.name, expectedValue)) { - errors.push( - `Missing identifier tag: ${schema.identifierTag.name}${ - expectedValue ? "=" + expectedValue : "" - }` - ); - } - } - - // Append tags - if (schema.appendTags) { - schema.appendTags.forEach((appendTag) => { - const tagName = appendTag[0]; - const tagValue = appendTag[1]; - if (!hasTag(event.tags, tagName, tagValue)) { - errors.push(`Missing append tag: ${tagName}=${tagValue}`); - } - }); - } - - // Specific schema tags - if (schema.relayTagName) { - // Check if at least one relay tag exists if we added relays in mock data - // This is slightly context dependent, but we can check if the builder respects the tag name - // Logic: if we expect the builder to use 'r' or 'relay', we can't easily verify generally unless we check specific output - // But we can check if any tag with that name exists IF we passed relays. - } - - // 3. Validate Content Format - if (schema.content) { - if (schema.content.format === "json") { - try { - const parsed = JSON.parse(event.content); - if (schema.content.fields) { - schema.content.fields.forEach(field => { - if (field.required && parsed[field.key] === undefined) { - errors.push(`Missing required content field: ${field.key}`); - } - if (parsed[field.key] !== undefined) { - if (field.type === 'number' && typeof parsed[field.key] !== 'number') { - errors.push(`Invalid type for content field ${field.key}: expected number, got ${typeof parsed[field.key]}`); - } - if (field.type === 'string' && typeof parsed[field.key] !== 'string') { - errors.push(`Invalid type for content field ${field.key}: expected string, got ${typeof parsed[field.key]}`); - } - if (field.type === 'boolean' && typeof parsed[field.key] !== 'boolean') { - errors.push(`Invalid type for content field ${field.key}: expected boolean, got ${typeof parsed[field.key]}`); - } - } - }); - } - } catch (e) { - errors.push("Content is not valid JSON"); - } - } else if (schema.content.format === "empty") { - if (event.content !== "") { - errors.push("Content should be empty"); - } - } else if ( - schema.content.format === "text" || - schema.content.format === "encrypted-tag-list" || - schema.content.format === "nip44-json" - ) { - if (typeof event.content !== "string") { - errors.push("Content must be a string"); - } - } - } - - return errors; -} - -// Test cases -const tests = [ - { - name: "Video Post", - type: NOTE_TYPES.VIDEO_POST, - builder: buildVideoPostEvent, - params: { - pubkey: MOCK_PUBKEY, - created_at: MOCK_TIMESTAMP, - dTagValue: MOCK_D_TAG, - content: { - version: 3, - title: "Test Video", - videoRootId: MOCK_EVENT_ID, - }, - }, - }, - { - name: "Video Mirror", - type: NOTE_TYPES.VIDEO_MIRROR, - builder: buildVideoMirrorEvent, - params: { - pubkey: MOCK_PUBKEY, - created_at: MOCK_TIMESTAMP, - content: "Alt text", - tags: [["magnet", MOCK_MAGNET]], - }, - }, - { - name: "Repost", - type: NOTE_TYPES.REPOST, - builder: buildRepostEvent, - params: { - pubkey: MOCK_PUBKEY, - created_at: MOCK_TIMESTAMP, - eventId: MOCK_EVENT_ID, - eventRelay: MOCK_RELAY, - targetEvent: { id: MOCK_EVENT_ID, pubkey: MOCK_PUBKEY, kind: 1, content: "test", tags: [], created_at: MOCK_TIMESTAMP }, - }, - }, - { - name: "Relay List", - type: NOTE_TYPES.RELAY_LIST, - builder: buildRelayListEvent, - params: { - pubkey: MOCK_PUBKEY, - created_at: MOCK_TIMESTAMP, - relays: [ - { url: "wss://r1.com", read: true, write: true }, - { url: "wss://r2.com", read: true, write: false }, - ], - }, - }, - { - name: "DM Relay List", - type: NOTE_TYPES.DM_RELAY_LIST, - builder: buildDmRelayListEvent, - params: { - pubkey: MOCK_PUBKEY, - created_at: MOCK_TIMESTAMP, - relays: ["wss://dm.relay.com"], - }, - }, - { - name: "Profile Metadata", - type: NOTE_TYPES.PROFILE_METADATA, - builder: buildProfileMetadataEvent, - params: { - pubkey: MOCK_PUBKEY, - created_at: MOCK_TIMESTAMP, - metadata: { name: "TestUser", about: "Testing" }, - }, - }, - { - name: "Mute List", - type: NOTE_TYPES.MUTE_LIST, - builder: buildMuteListEvent, - params: { - pubkey: MOCK_PUBKEY, - created_at: MOCK_TIMESTAMP, - pTags: [MOCK_PUBKEY], - }, - }, - { - name: "DM Attachment", - type: NOTE_TYPES.DM_ATTACHMENT, - builder: buildDmAttachmentEvent, - params: { - pubkey: MOCK_PUBKEY, - created_at: MOCK_TIMESTAMP, - recipientPubkey: MOCK_PUBKEY, - attachment: { x: "hash", url: MOCK_URL, type: "image/jpeg" }, - }, - }, - { - name: "DM Read Receipt", - type: NOTE_TYPES.DM_READ_RECEIPT, - builder: buildDmReadReceiptEvent, - params: { - pubkey: MOCK_PUBKEY, - created_at: MOCK_TIMESTAMP, - recipientPubkey: MOCK_PUBKEY, - eventId: MOCK_EVENT_ID, - messageKind: 4, - }, - }, - { - name: "DM Typing Indicator", - type: NOTE_TYPES.DM_TYPING, - builder: buildDmTypingIndicatorEvent, - params: { - pubkey: MOCK_PUBKEY, - created_at: MOCK_TIMESTAMP, - recipientPubkey: MOCK_PUBKEY, - eventId: MOCK_EVENT_ID, - expiresAt: MOCK_TIMESTAMP + 60, - }, - }, - { - name: "View Event", - type: NOTE_TYPES.VIEW_EVENT, - builder: buildViewEvent, - params: { - pubkey: MOCK_PUBKEY, - created_at: MOCK_TIMESTAMP, - pointerTag: ["e", MOCK_EVENT_ID], - dedupeTag: "unique-view-id", - }, - }, - { - name: "Zap Request", - type: NOTE_TYPES.ZAP_REQUEST, - builder: buildZapRequestEvent, - params: { - pubkey: MOCK_PUBKEY, - created_at: MOCK_TIMESTAMP, - recipientPubkey: MOCK_PUBKEY, - amountSats: 100, - relays: [MOCK_RELAY], - eventId: MOCK_EVENT_ID, - }, - }, - { - name: "Reaction", - type: NOTE_TYPES.VIDEO_REACTION, - builder: buildReactionEvent, - params: { - pubkey: MOCK_PUBKEY, - created_at: MOCK_TIMESTAMP, - targetPointer: { type: "e", value: MOCK_EVENT_ID, relay: MOCK_RELAY }, - content: "+", - }, - }, - { - name: "Video Comment", - type: NOTE_TYPES.VIDEO_COMMENT, - builder: buildCommentEvent, - params: { - pubkey: MOCK_PUBKEY, - created_at: MOCK_TIMESTAMP, - videoEventId: MOCK_EVENT_ID, - videoEventRelay: MOCK_RELAY, - content: "Great video!", - }, - }, - { - name: "Watch History", - type: NOTE_TYPES.WATCH_HISTORY, - builder: buildWatchHistoryEvent, - params: { - pubkey: MOCK_PUBKEY, - created_at: MOCK_TIMESTAMP, - monthIdentifier: "2023-11", - pointerTags: [["e", MOCK_EVENT_ID]], - content: JSON.stringify({ version: 2, month: "2023-11", items: [] }), - }, - }, - { - name: "Subscription List", - type: NOTE_TYPES.SUBSCRIPTION_LIST, - builder: buildSubscriptionListEvent, - params: { - pubkey: MOCK_PUBKEY, - created_at: MOCK_TIMESTAMP, - content: "encrypted-content", - encryption: "nip44", - }, - }, - { - name: "Block List", - type: NOTE_TYPES.USER_BLOCK_LIST, - builder: buildBlockListEvent, - params: { - pubkey: MOCK_PUBKEY, - created_at: MOCK_TIMESTAMP, - content: "encrypted-content", - encryption: "nip44", - }, - }, - { - name: "Hashtag Preferences", - type: NOTE_TYPES.HASHTAG_PREFERENCES, - builder: buildHashtagPreferenceEvent, - params: { - pubkey: MOCK_PUBKEY, - created_at: MOCK_TIMESTAMP, - content: "encrypted-content", - }, - }, - { - name: "Admin Moderation List", - type: NOTE_TYPES.ADMIN_MODERATION_LIST, - builder: (params) => buildAdminListEvent("moderation", params), - params: { - pubkey: MOCK_PUBKEY, - created_at: MOCK_TIMESTAMP, - hexPubkeys: [MOCK_PUBKEY], - }, - }, - { - name: "Admin Blacklist", - type: NOTE_TYPES.ADMIN_BLACKLIST, - builder: (params) => buildAdminListEvent("blacklist", params), - params: { - pubkey: MOCK_PUBKEY, - created_at: MOCK_TIMESTAMP, - hexPubkeys: [MOCK_PUBKEY], - }, - }, - { - name: "Admin Whitelist", - type: NOTE_TYPES.ADMIN_WHITELIST, - builder: (params) => buildAdminListEvent("whitelist", params), - params: { - pubkey: MOCK_PUBKEY, - created_at: MOCK_TIMESTAMP, - hexPubkeys: [MOCK_PUBKEY], - }, - }, - { - name: "NIP-71 Video", - type: NOTE_TYPES.NIP71_VIDEO, - builder: buildNip71VideoEvent, - params: { - metadata: { - kind: 21, - title: "Test NIP-71 Video", - summary: "This is a test summary", - }, - pubkey: MOCK_PUBKEY, - title: "Test NIP-71 Video", - }, - }, -]; - -// Execution -console.log("Starting Event Validation..."); -let failCount = 0; - -tests.forEach((test) => { - try { - const event = test.builder(test.params); - const errors = validateEvent(test.type, event); - - if (errors.length > 0) { - console.error(`❌ ${test.name} Failed:`); - errors.forEach((err) => console.error(` - ${err}`)); - failCount++; - } else { - console.log(`✅ ${test.name} Passed`); - } - } catch (e) { - console.error(`❌ ${test.name} threw exception:`, e); - failCount++; - } -}); - -if (failCount > 0) { - console.log(`\nValidation failed with ${failCount} errors.`); - process.exit(1); -} else { - console.log("\nAll events validated successfully!"); - process.exit(0); -}