diff --git a/nip58.test.ts b/nip58.test.ts new file mode 100644 index 0000000..55e7db4 --- /dev/null +++ b/nip58.test.ts @@ -0,0 +1,357 @@ +import { expect, test } from 'bun:test' + +import { EventTemplate } from './core.ts' +import { + BadgeAward as BadgeAwardKind, + BadgeDefinition as BadgeDefinitionKind, + ProfileBadges as ProfileBadgesKind, +} from './kinds.ts' +import { finalizeEvent, generateSecretKey } from './pure.ts' + +import { + BadgeAward, + BadgeDefinition, + ProfileBadges, + generateBadgeAwardEventTemplate, + generateBadgeDefinitionEventTemplate, + generateProfileBadgesEventTemplate, + validateBadgeAwardEvent, + validateBadgeDefinitionEvent, + validateProfileBadgesEvent, +} from './nip58.ts' + +test('BadgeDefinition has required property "d"', () => { + const badge: BadgeDefinition = { + d: 'badge-id', + } + expect(badge.d).toEqual('badge-id') +}) + +test('BadgeDefinition has optional property "name"', () => { + const badge: BadgeDefinition = { + d: 'badge-id', + name: 'Badge Name', + } + expect(badge.name).toEqual('Badge Name') +}) + +test('BadgeDefinition has optional property "description"', () => { + const badge: BadgeDefinition = { + d: 'badge-id', + description: 'Badge Description', + } + expect(badge.description).toEqual('Badge Description') +}) + +test('BadgeDefinition has optional property "image"', () => { + const badge: BadgeDefinition = { + d: 'badge-id', + image: ['https://example.com/badge.png', '1024x1024'], + } + expect(badge.image).toEqual(['https://example.com/badge.png', '1024x1024']) +}) + +test('BadgeDefinition has optional property "thumbs"', () => { + const badge: BadgeDefinition = { + d: 'badge-id', + thumbs: [ + ['https://example.com/thumb.png', '100x100'], + ['https://example.com/thumb2.png', '200x200'], + ], + } + expect(badge.thumbs).toEqual([ + ['https://example.com/thumb.png', '100x100'], + ['https://example.com/thumb2.png', '200x200'], + ]) +}) + +test('BadgeAward has required property "a"', () => { + const badgeAward: BadgeAward = { + a: 'badge-definition-address', + p: [ + ['pubkey1', 'relay1'], + ['pubkey2', 'relay2'], + ], + } + expect(badgeAward.a).toEqual('badge-definition-address') +}) + +test('BadgeAward has required property "p"', () => { + const badgeAward: BadgeAward = { + a: 'badge-definition-address', + p: [ + ['pubkey1', 'relay1'], + ['pubkey2', 'relay2'], + ], + } + expect(badgeAward.p).toEqual([ + ['pubkey1', 'relay1'], + ['pubkey2', 'relay2'], + ]) +}) + +test('ProfileBadges has required property "d"', () => { + const profileBadges: ProfileBadges = { + d: 'profile_badges', + badges: [], + } + expect(profileBadges.d).toEqual('profile_badges') +}) + +test('ProfileBadges has required property "badges"', () => { + const profileBadges: ProfileBadges = { + d: 'profile_badges', + badges: [], + } + expect(profileBadges.badges).toEqual([]) +}) + +test('ProfileBadges badges array contains objects with required properties "a" and "e"', () => { + const profileBadges: ProfileBadges = { + d: 'profile_badges', + badges: [ + { + a: 'badge-definition-address', + e: ['badge-award-event-id'], + }, + ], + } + expect(profileBadges.badges[0].a).toEqual('badge-definition-address') + expect(profileBadges.badges[0].e).toEqual(['badge-award-event-id']) +}) + +test('generateBadgeDefinitionEventTemplate generates EventTemplate with mandatory tags', () => { + const badge: BadgeDefinition = { + d: 'badge-id', + } + const eventTemplate = generateBadgeDefinitionEventTemplate(badge) + expect(eventTemplate.tags).toEqual([['d', 'badge-id']]) +}) + +test('generateBadgeDefinitionEventTemplate generates EventTemplate with optional tags', () => { + const badge: BadgeDefinition = { + d: 'badge-id', + name: 'Badge Name', + description: 'Badge Description', + image: ['https://example.com/badge.png', '1024x1024'], + thumbs: [ + ['https://example.com/thumb.png', '100x100'], + ['https://example.com/thumb2.png', '200x200'], + ], + } + const eventTemplate = generateBadgeDefinitionEventTemplate(badge) + expect(eventTemplate.tags).toEqual([ + ['d', 'badge-id'], + ['name', 'Badge Name'], + ['description', 'Badge Description'], + ['image', 'https://example.com/badge.png', '1024x1024'], + ['thumb', 'https://example.com/thumb.png', '100x100'], + ['thumb', 'https://example.com/thumb2.png', '200x200'], + ]) +}) + +test('generateBadgeDefinitionEventTemplate generates EventTemplate without optional tags', () => { + const badge: BadgeDefinition = { + d: 'badge-id', + } + const eventTemplate = generateBadgeDefinitionEventTemplate(badge) + expect(eventTemplate.tags).toEqual([['d', 'badge-id']]) +}) + +test('validateBadgeDefinitionEvent returns true for valid BadgeDefinition event', () => { + const sk = generateSecretKey() + const eventTemplate: EventTemplate = { + content: '', + created_at: Math.floor(Date.now() / 1000), + kind: BadgeDefinitionKind, + tags: [ + ['d', 'badge-id'], + ['name', 'Badge Name'], + ['description', 'Badge Description'], + ['image', 'https://example.com/badge.png', '1024x1024'], + ['thumb', 'https://example.com/thumb.png', '100x100'], + ['thumb', 'https://example.com/thumb2.png', '200x200'], + ], + } + const event = finalizeEvent(eventTemplate, sk) + const isValid = validateBadgeDefinitionEvent(event) + + expect(isValid).toBe(true) +}) + +test('validateBadgeDefinitionEvent returns false for invalid BadgeDefinition event', () => { + const sk = generateSecretKey() + const eventTemplate: EventTemplate = { + content: '', + created_at: Math.floor(Date.now() / 1000), + kind: BadgeDefinitionKind, + tags: [], + } + const event = finalizeEvent(eventTemplate, sk) + const isValid = validateBadgeDefinitionEvent(event) + + expect(isValid).toBe(false) +}) + +test('generateBadgeAwardEventTemplate generates EventTemplate with mandatory tags', () => { + const badgeAward: BadgeAward = { + a: 'badge-definition-address', + p: [ + ['pubkey1', 'relay1'], + ['pubkey2', 'relay2'], + ], + } + const eventTemplate = generateBadgeAwardEventTemplate(badgeAward) + expect(eventTemplate.tags).toEqual([ + ['a', 'badge-definition-address'], + ['p', 'pubkey1', 'relay1'], + ['p', 'pubkey2', 'relay2'], + ]) +}) + +test('generateBadgeAwardEventTemplate generates EventTemplate without optional tags', () => { + const badgeAward: BadgeAward = { + a: 'badge-definition-address', + p: [ + ['pubkey1', 'relay1'], + ['pubkey2', 'relay2'], + ], + } + const eventTemplate = generateBadgeAwardEventTemplate(badgeAward) + expect(eventTemplate.tags).toEqual([ + ['a', 'badge-definition-address'], + ['p', 'pubkey1', 'relay1'], + ['p', 'pubkey2', 'relay2'], + ]) +}) + +test('generateBadgeAwardEventTemplate generates EventTemplate with optional tags', () => { + const badgeAward: BadgeAward = { + a: 'badge-definition-address', + p: [ + ['pubkey1', 'relay1'], + ['pubkey2', 'relay2'], + ], + } + const eventTemplate = generateBadgeAwardEventTemplate(badgeAward) + expect(eventTemplate.tags).toEqual([ + ['a', 'badge-definition-address'], + ['p', 'pubkey1', 'relay1'], + ['p', 'pubkey2', 'relay2'], + ]) +}) + +test('validateBadgeAwardEvent returns true for valid BadgeAward event', () => { + const sk = generateSecretKey() + const eventTemplate: EventTemplate = { + content: '', + created_at: Math.floor(Date.now() / 1000), + kind: BadgeAwardKind, + tags: [ + ['a', 'badge-definition-address'], + ['p', 'pubkey1', 'relay1'], + ['p', 'pubkey2', 'relay2'], + ], + } + const event = finalizeEvent(eventTemplate, sk) + const isValid = validateBadgeAwardEvent(event) + + expect(isValid).toBe(true) +}) + +test('validateBadgeAwardEvent returns false for invalid BadgeAward event', () => { + const sk = generateSecretKey() + const eventTemplate: EventTemplate = { + content: '', + created_at: Math.floor(Date.now() / 1000), + kind: BadgeAwardKind, + tags: [], + } + const event = finalizeEvent(eventTemplate, sk) + const isValid = validateBadgeAwardEvent(event) + + expect(isValid).toBe(false) +}) + +test('generateProfileBadgesEventTemplate generates EventTemplate with mandatory tags', () => { + const profileBadges: ProfileBadges = { + d: 'profile_badges', + badges: [], + } + const eventTemplate = generateProfileBadgesEventTemplate(profileBadges) + expect(eventTemplate.tags).toEqual([['d', 'profile_badges']]) +}) + +test('generateProfileBadgesEventTemplate generates EventTemplate with optional tags', () => { + const profileBadges: ProfileBadges = { + d: 'profile_badges', + badges: [ + { + a: 'badge-definition-address', + e: ['badge-award-event-id'], + }, + ], + } + const eventTemplate = generateProfileBadgesEventTemplate(profileBadges) + expect(eventTemplate.tags).toEqual([ + ['d', 'profile_badges'], + ['a', 'badge-definition-address'], + ['e', 'badge-award-event-id'], + ]) +}) + +test('generateProfileBadgesEventTemplate generates EventTemplate with multiple optional tags', () => { + const profileBadges: ProfileBadges = { + d: 'profile_badges', + badges: [ + { + a: 'badge-definition-address1', + e: ['badge-award-event-id1', 'badge-award-event-id2'], + }, + { + a: 'badge-definition-address2', + e: ['badge-award-event-id3'], + }, + ], + } + const eventTemplate = generateProfileBadgesEventTemplate(profileBadges) + expect(eventTemplate.tags).toEqual([ + ['d', 'profile_badges'], + ['a', 'badge-definition-address1'], + ['e', 'badge-award-event-id1', 'badge-award-event-id2'], + ['a', 'badge-definition-address2'], + ['e', 'badge-award-event-id3'], + ]) +}) + +test('validateProfileBadgesEvent returns true for valid ProfileBadges event', () => { + const sk = generateSecretKey() + const eventTemplate: EventTemplate = { + content: '', + created_at: Math.floor(Date.now() / 1000), + kind: ProfileBadgesKind, + tags: [ + ['d', 'profile_badges'], + ['a', 'badge-definition-address'], + ['e', 'badge-award-event-id'], + ], + } + const event = finalizeEvent(eventTemplate, sk) + const isValid = validateProfileBadgesEvent(event) + + expect(isValid).toBe(true) +}) + +test('validateProfileBadgesEvent returns false for invalid ProfileBadges event', () => { + const sk = generateSecretKey() + const eventTemplate: EventTemplate = { + content: '', + created_at: Math.floor(Date.now() / 1000), + kind: ProfileBadgesKind, + tags: [], + } + const event = finalizeEvent(eventTemplate, sk) + const isValid = validateProfileBadgesEvent(event) + + expect(isValid).toBe(false) +}) diff --git a/nip58.ts b/nip58.ts new file mode 100644 index 0000000..3ddb85e --- /dev/null +++ b/nip58.ts @@ -0,0 +1,245 @@ +import { Event, EventTemplate } from './core' +import { + BadgeAward as BadgeAwardKind, + BadgeDefinition as BadgeDefinitionKind, + ProfileBadges as ProfileBadgesKind, +} from './kinds' + +/** + * Represents the structure for defining a badge within the Nostr network. + * This structure is used to create templates for badge definition events, + * facilitating the recognition and awarding of badges to users for various achievements. + */ +export type BadgeDefinition = { + /** + * A unique identifier for the badge. This is used to distinguish badges + * from one another and should be unique across all badge definitions. + * Typically, this could be a short, descriptive string. + */ + d: string + + /** + * An optional short name for the badge. This provides a human-readable + * title for the badge, making it easier to recognize and refer to. + */ + name?: string + + /** + * An optional description for the badge. This field can be used to + * provide more detailed information about the badge, such as the criteria + * for its awarding or its significance. + */ + description?: string + + /** + * An optional image URL and dimensions for the badge. The first element + * of the tuple is the URL pointing to a high-resolution image representing + * the badge, and the second element specifies the image's dimensions in + * the format "widthxheight". The recommended dimensions are 1024x1024 pixels. + */ + image?: [string, string] + + /** + * An optional list of thumbnail images for the badge. Each element in the + * array is a tuple, where the first element is the URL pointing to a thumbnail + * version of the badge image, and the second element specifies the thumbnail's + * dimensions in the format "widthxheight". Multiple thumbnails can be provided + * to support different display sizes. + */ + thumbs?: Array<[string, string]> +} + +/** + * Represents the structure for awarding a badge to one or more recipients + * within the Nostr network. This structure is used to create templates for + * badge award events, which are immutable and signify the recognition of + * individuals' achievements or contributions. + */ +export type BadgeAward = { + /** + * A reference to the Badge Definition event. This is typically composed + * of the event ID of the badge definition. It establishes a clear linkage + * between the badge being awarded and its original definition, ensuring + * that recipients are awarded the correct badge. + */ + a: string + + /** + * An array of p tags, each containing a pubkey and its associated relays. + */ + p: string[][] +} + +/** + * Represents the collection of badges a user chooses to display on their profile. + * This structure is crucial for applications that allow users to showcase achievements + * or recognitions in the form of badges, following the specifications of NIP-58. + */ +export type ProfileBadges = { + /** + * A unique identifier for the profile badges collection. According to NIP-58, + * this should be set to "profile_badges" to differentiate it from other event types. + */ + d: 'profile_badges' + + /** + * A list of badges that the user has elected to display on their profile. Each item + * in the array represents a specific badge, including references to both its definition + * and the award event. + */ + badges: Array<{ + /** + * The event address of the badge definition. This is a reference to the specific badge + * being displayed, linking back to the badge's original definition event. It allows + * clients to fetch and display the badge's details, such as its name, description, + * and image. + */ + a: string + + /** + * The event id of the badge award with corresponding relays. This references the event + * in which the badge was awarded to the user. It is crucial for verifying the + * authenticity of the badge display, ensuring that the user was indeed awarded the + * badge they are choosing to display. + */ + e: string[] + }> +} + +/** + * Generates an EventTemplate based on the provided BadgeDefinition. + * + * @param {BadgeDefinition} badgeDefinition - The BadgeDefinition object. + * @returns {EventTemplate} - The generated EventTemplate object. + */ +export function generateBadgeDefinitionEventTemplate({ + d, + description, + image, + name, + thumbs, +}: BadgeDefinition): EventTemplate { + // Mandatory tags + const tags: string[][] = [['d', d]] + + // Append optional tags + name && tags.push(['name', name]) + description && tags.push(['description', description]) + image && tags.push(['image', ...image]) + if (thumbs) { + for (const thumb of thumbs) { + tags.push(['thumb', ...thumb]) + } + } + + // Construct the EventTemplate object + const eventTemplate: EventTemplate = { + content: '', + created_at: Math.floor(Date.now() / 1000), + kind: BadgeDefinitionKind, + tags, + } + + return eventTemplate +} + +/** + * Validates a badge definition event. + * + * @param event - The event to validate. + * @returns A boolean indicating whether the event is a valid badge definition event. + */ +export function validateBadgeDefinitionEvent(event: Event): boolean { + if (event.kind !== BadgeDefinitionKind) return false + + const requiredTags = ['d'] as const + for (const tag of requiredTags) { + if (!event.tags.find(([t]) => t == tag)) return false + } + + return true +} + +/** + * Generates an EventTemplate based on the provided BadgeAward. + * + * @param {BadgeAward} badgeAward - The BadgeAward object. + * @returns {EventTemplate} - The generated EventTemplate object. + */ +export function generateBadgeAwardEventTemplate({ a, p }: BadgeAward): EventTemplate { + // Mandatory tags + const tags: string[][] = [['a', a]] + for (const _p of p) { + tags.push(['p', ..._p]) + } + + // Construct the EventTemplate object + const eventTemplate: EventTemplate = { + content: '', + created_at: Math.floor(Date.now() / 1000), + kind: BadgeAwardKind, + tags, + } + + return eventTemplate +} + +/** + * Validates a badge award event. + * + * @param event - The event to validate. + * @returns A boolean indicating whether the event is a valid badge award event. + */ +export function validateBadgeAwardEvent(event: Event): boolean { + if (event.kind !== BadgeAwardKind) return false + + const requiredTags = ['a', 'p'] as const + for (const tag of requiredTags) { + if (!event.tags.find(([t]) => t == tag)) return false + } + + return true +} + +/** + * Generates an EventTemplate based on the provided ProfileBadges. + * + * @param {ProfileBadges} profileBadges - The ProfileBadges object. + * @returns {EventTemplate} - The generated EventTemplate object. + */ +export function generateProfileBadgesEventTemplate({ badges }: ProfileBadges): EventTemplate { + // Mandatory tags + const tags: string[][] = [['d', 'profile_badges']] + + // Append optional tags + for (const badge of badges) { + tags.push(['a', badge.a], ['e', ...badge.e]) + } + + // Construct the EventTemplate object + const eventTemplate: EventTemplate = { + content: '', + created_at: Math.floor(Date.now() / 1000), + kind: ProfileBadgesKind, + tags, + } + + return eventTemplate +} + +/** + * Validates a profile badges event. + * + * @param event - The event to validate. + * @returns A boolean indicating whether the event is a valid profile badges event. + */ +export function validateProfileBadgesEvent(event: Event): boolean { + if (event.kind !== ProfileBadgesKind) return false + + const requiredTags = ['d'] as const + for (const tag of requiredTags) { + if (!event.tags.find(([t]) => t == tag)) return false + } + + return true +} diff --git a/package.json b/package.json index ffd2a3d..8d8b61e 100644 --- a/package.json +++ b/package.json @@ -170,6 +170,11 @@ "require": "./lib/cjs/nip57.js", "types": "./lib/types/nip57.d.ts" }, + "./nip58": { + "import": "./lib/esm/nip58.js", + "require": "./lib/cjs/nip58.js", + "types": "./lib/types/nip58.d.ts" + }, "./nip75": { "import": "./lib/esm/nip75.js", "require": "./lib/cjs/nip75.js",