diff --git a/docs/nostr-event-schemas.md b/docs/nostr-event-schemas.md index 228615d4..0e28b61d 100644 --- a/docs/nostr-event-schemas.md +++ b/docs/nostr-event-schemas.md @@ -167,6 +167,9 @@ import { updateWatchHistoryListWithDefaultClient } from "./nostrWatchHistoryFaca | DM attachment (`NOTE_TYPES.DM_ATTACHMENT`) | `15` | `['p', ]`, `['x', ]`, `['url', ]`, optional `['name', ]`, `['type', ]`, `['size', ]`, optional `['k', ]`; inherits schema append tags | Empty content; attachment metadata is represented as tags | | DM read receipt (`NOTE_TYPES.DM_READ_RECEIPT`) | `20001` | `['p', ]`, `['e', ]`, optional `['k', ]`; inherits schema append tags | Empty content; ephemeral receipt for message activity | | DM typing indicator (`NOTE_TYPES.DM_TYPING`) | `20002` | `['p', ]`, optional `['e', ]`, `['t','typing']`, `['expiration', ]`; inherits schema append tags | Empty content; ephemeral typing indicator that expires quickly | +| Gift Wrap (`NOTE_TYPES.GIFT_WRAP`) | `1059` | `['p', ]` | NIP-44 encrypted seal | +| Seal (`NOTE_TYPES.SEAL`) | `13` | Empty tags usually | NIP-44 encrypted rumor | +| Chat Message (`NOTE_TYPES.CHAT_MESSAGE`) | `14` | `['p', ]` | 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). diff --git a/js/nostr/client.js b/js/nostr/client.js index bc8e700c..6230bb25 100644 --- a/js/nostr/client.js +++ b/js/nostr/client.js @@ -68,6 +68,9 @@ import { buildDmAttachmentEvent, buildDeletionEvent, buildLegacyDirectMessageEvent, + buildGiftWrapEvent, + buildSealEvent, + buildChatMessageEvent, getNostrEventSchema, NOTE_TYPES, } from "../nostrEventSchemas.js"; @@ -4851,13 +4854,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) { @@ -4896,16 +4898,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") { @@ -4928,17 +4928,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), diff --git a/js/nostrEventSchemas.js b/js/nostrEventSchemas.js index adbe6b06..307b6985 100644 --- a/js/nostrEventSchemas.js +++ b/js/nostrEventSchemas.js @@ -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, diff --git a/scripts/agent/validate-events.mjs b/scripts/agent/validate-events.mjs index fb96ebaf..add4c996 100644 --- a/scripts/agent/validate-events.mjs +++ b/scripts/agent/validate-events.mjs @@ -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) {