From ec03aab0ab1105bf6847bdd443ab2dec88eea255 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sun, 26 Jul 2026 19:05:44 -0400 Subject: [PATCH] feat(core): implement targets module with target validation and hierarchy functions --- packages/core/src/targets.js | 189 ++++++++++++++++ packages/core/test/targets.test.js | 331 +++++++++++++++++++++++++++++ 2 files changed, 520 insertions(+) create mode 100644 packages/core/src/targets.js create mode 100644 packages/core/test/targets.test.js diff --git a/packages/core/src/targets.js b/packages/core/src/targets.js new file mode 100644 index 0000000..c70d2fe --- /dev/null +++ b/packages/core/src/targets.js @@ -0,0 +1,189 @@ +// Target handling for Nostr Governance +// +// This module builds on the identifiers to provide higher-level target +// handling, including target validation, comparison, and utility functions. + +import { + normalizePubkey, + normalizeEventId, + normalizeAddress, + createUserTarget, + createEventTarget, + getTargetKey +} from "./identifiers.js"; + +/** + * @typedef {import('./identifiers.js').UserTarget} UserTarget + * @typedef {import('./identifiers.js').EventTarget} EventTarget + * @typedef {import('./identifiers.js').AddressTarget} AddressTarget + * @typedef {import('./identifiers.js').GovernanceTarget} GovernanceTarget + */ + +/** + * Check if two targets are equal + * @param {GovernanceTarget} a + * @param {GovernanceTarget} b + * @returns {boolean} + */ +export function areTargetsEqual(a, b) { + return getTargetKey(a) === getTargetKey(b); +} + +/** + * Check if a target is valid + * @param {any} target + * @returns {target is GovernanceTarget} + */ +export function isValidTarget(target) { + if (!target || typeof target !== "object") return false; + + switch (target.type) { + case "user": + return typeof target.pubkey === "string" && + /^[0-9a-f]{64}$/i.test(target.pubkey); + case "event": + return typeof target.id === "string" && + /^[0-9a-f]{64}$/i.test(target.id); + case "address": + return typeof target.kind === "string" && + typeof target.pubkey === "string" && + /^[0-9a-f]{64}$/i.test(target.pubkey) && + typeof target.identifier === "string"; + default: + return false; + } +} + +/** + * Create a target from various input formats + * @param {string|{type: string, pubkey?: string, id?: string, kind?: string|number, identifier?: string}|null|undefined} input + * @returns {GovernanceTarget|null} + */ +export function createTarget(input) { + // Handle null/undefined inputs + if (input === null || input === undefined) { + return null; + } + + // If it's already a valid target, return it + if (isValidTarget(input)) { + return input; + } + + // If it's a string, try to determine what kind of target it is + if (typeof input === "string") { + const trimmed = input.trim(); + + // Check if it's a hex pubkey or event ID + if (/^[0-9a-f]{64}$/i.test(trimmed)) { + // Try pubkey first, then event ID + const userTarget = createUserTarget(trimmed); + if (userTarget) return userTarget; + + const eventTarget = createEventTarget(trimmed); + if (eventTarget) return eventTarget; + + return null; + } + + // Try to decode Nostr identifiers (npub, note, etc.) + try { + // In a real implementation, we would use a Nostr library here + // For now, we'll just return null + // const decoded = NostrTools.nip19.decode(trimmed); + // switch (decoded?.type) { + // case "npub": + // return createUserTarget(decoded.data); + // case "note": + // return createEventTarget(decoded.data); + // case "nevent": + // return createEventTarget(decoded.data?.id || decoded.data); + // } + return null; // Placeholder for now + } catch (error) { + return null; + } + } + + // If it's an object, try to create the appropriate target type + if (typeof input === "object") { + // Check if it has a type property + if (!input.type) { + return null; + } + + switch (input.type) { + case "user": + return createUserTarget(/** @type {string} */ (input.pubkey)); + case "event": + return createEventTarget(/** @type {string} */ (input.id)); + case "address": + return normalizeAddress( + /** @type {string|number} */ (input.kind), + /** @type {string} */ (input.pubkey), + /** @type {string} */ (input.identifier) + ); + default: + return null; + } + } + + return null; +} + +/** + * Get the parent target of a target, if applicable + * For event targets, the parent is the user target of the event author + * For address targets, the parent is the user target of the address author + * User targets have no parent + * @param {GovernanceTarget} target + * @param {string} [authorPubkey] Required for event targets to determine parent + * @returns {UserTarget|null} + */ +export function getParentTarget(target, authorPubkey) { + switch (target.type) { + case "user": + // User targets have no parent + return null; + case "event": + // Event targets have the author as parent, but we need the author pubkey + if (!authorPubkey) { + throw new Error("authorPubkey is required for event targets"); + } + return createUserTarget(authorPubkey); + case "address": + // Address targets have the author as parent + return createUserTarget(target.pubkey); + default: + return null; + } +} + +/** + * Check if a target is a descendant of another target + * @param {GovernanceTarget} target + * @param {GovernanceTarget} ancestor + * @param {string} [eventAuthorPubkey] Required if checking event descendants + * @returns {boolean} + */ +export function isDescendantOf(target, ancestor, eventAuthorPubkey) { + // A target cannot be a descendant of itself + if (areTargetsEqual(target, ancestor)) { + return false; + } + + // Get the parent of the target + const parent = getParentTarget(target, eventAuthorPubkey); + if (!parent) { + // No parent means no descendants + return false; + } + + // If the parent is the ancestor, then target is a descendant + if (areTargetsEqual(parent, ancestor)) { + return true; + } + + // Otherwise, recursively check if the parent is a descendant of the ancestor + return isDescendantOf(parent, ancestor, eventAuthorPubkey); +} \ No newline at end of file diff --git a/packages/core/test/targets.test.js b/packages/core/test/targets.test.js new file mode 100644 index 0000000..6aeee8d --- /dev/null +++ b/packages/core/test/targets.test.js @@ -0,0 +1,331 @@ +import { describe, it, expect } from "vitest"; +import { + areTargetsEqual, + isValidTarget, + createTarget, + getParentTarget, + isDescendantOf +} from "../src/targets.js"; + +describe("targets", () => { + describe("areTargetsEqual", () => { + it("should return true for identical user targets", () => { + const target1 = /** @type {import('../src/identifiers.js').UserTarget} */ ({ + type: "user", + pubkey: "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789" + }); + const target2 = /** @type {import('../src/identifiers.js').UserTarget} */ ({ + type: "user", + pubkey: "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789" + }); + expect(areTargetsEqual(target1, target2)).toBe(true); + }); + + it("should return false for different user targets", () => { + const target1 = /** @type {import('../src/identifiers.js').UserTarget} */ ({ + type: "user", + pubkey: "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789" + }); + const target2 = /** @type {import('../src/identifiers.js').UserTarget} */ ({ + type: "user", + pubkey: "1234567890123456789012345678901234567890123456789012345678901234" + }); + expect(areTargetsEqual(target1, target2)).toBe(false); + }); + + it("should return true for identical event targets", () => { + const target1 = /** @type {import('../src/identifiers.js').EventTarget} */ ({ + type: "event", + id: "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789" + }); + const target2 = /** @type {import('../src/identifiers.js').EventTarget} */ ({ + type: "event", + id: "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789" + }); + expect(areTargetsEqual(target1, target2)).toBe(true); + }); + + it("should return false for different event targets", () => { + const target1 = /** @type {import('../src/identifiers.js').EventTarget} */ ({ + type: "event", + id: "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789" + }); + const target2 = /** @type {import('../src/identifiers.js').EventTarget} */ ({ + type: "event", + id: "1234567890123456789012345678901234567890123456789012345678901234" + }); + expect(areTargetsEqual(target1, target2)).toBe(false); + }); + + it("should return true for identical address targets", () => { + const target1 = /** @type {import('../src/identifiers.js').AddressTarget} */ ({ + type: "address", + kind: "30023", + pubkey: "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789", + identifier: "test-identifier" + }); + const target2 = /** @type {import('../src/identifiers.js').AddressTarget} */ ({ + type: "address", + kind: "30023", + pubkey: "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789", + identifier: "test-identifier" + }); + expect(areTargetsEqual(target1, target2)).toBe(true); + }); + + it("should return false for different address targets", () => { + const target1 = /** @type {import('../src/identifiers.js').AddressTarget} */ ({ + type: "address", + kind: "30023", + pubkey: "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789", + identifier: "test-identifier" + }); + const target2 = /** @type {import('../src/identifiers.js').AddressTarget} */ ({ + type: "address", + kind: "30023", + pubkey: "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789", + identifier: "different-identifier" + }); + expect(areTargetsEqual(target1, target2)).toBe(false); + }); + }); + + describe("isValidTarget", () => { + it("should return true for valid user targets", () => { + const target = /** @type {import('../src/identifiers.js').UserTarget} */ ({ + type: "user", + pubkey: "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789" + }); + expect(isValidTarget(target)).toBe(true); + }); + + it("should return false for invalid user targets", () => { + expect(isValidTarget(/** @type {any} */({ type: "user", pubkey: "invalid" }))).toBe(false); + expect(isValidTarget(/** @type {any} */({ type: "user" }))).toBe(false); + expect(isValidTarget(/** @type {any} */({ + pubkey: "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789" + }))).toBe(false); + }); + + it("should return true for valid event targets", () => { + const target = /** @type {import('../src/identifiers.js').EventTarget} */ ({ + type: "event", + id: "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789" + }); + expect(isValidTarget(target)).toBe(true); + }); + + it("should return false for invalid event targets", () => { + expect(isValidTarget(/** @type {any} */({ type: "event", id: "invalid" }))).toBe(false); + expect(isValidTarget(/** @type {any} */({ type: "event" }))).toBe(false); + expect(isValidTarget(/** @type {any} */({ + id: "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789" + }))).toBe(false); + }); + + it("should return true for valid address targets", () => { + const target = /** @type {import('../src/identifiers.js').AddressTarget} */ ({ + type: "address", + kind: "30023", + pubkey: "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789", + identifier: "test-identifier" + }); + expect(isValidTarget(target)).toBe(true); + }); + + it("should return false for invalid address targets", () => { + expect(isValidTarget(/** @type {any} */({ + type: "address", + kind: "30023", + pubkey: "invalid", + identifier: "test-identifier" + }))).toBe(false); + expect(isValidTarget(/** @type {any} */({ + type: "address", + kind: "30023", + identifier: "test-identifier" + }))).toBe(false); + }); + + it("should return false for unknown target types", () => { + expect(isValidTarget(/** @type {any} */({ type: "unknown" }))).toBe(false); + }); + + it("should return false for non-objects", () => { + expect(isValidTarget(null)).toBe(false); + expect(isValidTarget(undefined)).toBe(false); + expect(isValidTarget("string")).toBe(false); + expect(isValidTarget(123)).toBe(false); + }); + }); + + describe("createTarget", () => { + it("should return the same target if already valid", () => { + const target = /** @type {import('../src/identifiers.js').UserTarget} */ ({ + type: "user", + pubkey: "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789" + }); + expect(createTarget(target)).toBe(target); + }); + + it("should create user target from hex pubkey", () => { + const result = createTarget("abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789"); + expect(result).toEqual(/** @type {import('../src/identifiers.js').UserTarget} */ ({ + type: "user", + pubkey: "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789" + })); + }); + + it("should create event target from hex event ID", () => { + // For now, hex strings are treated as user targets first + // In a real implementation, we might need additional context + const result = createTarget("1234567890123456789012345678901234567890123456789012345678901234"); + expect(result).toEqual(/** @type {import('../src/identifiers.js').UserTarget} */ ({ + type: "user", + pubkey: "1234567890123456789012345678901234567890123456789012345678901234" + })); + }); + + it("should create user target from object", () => { + const result = createTarget(/** @type {{type: string, pubkey: string}} */ ({ + type: "user", + pubkey: "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789" + })); + expect(result).toEqual(/** @type {import('../src/identifiers.js').UserTarget} */ ({ + type: "user", + pubkey: "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789" + })); + }); + + it("should create event target from object", () => { + const result = createTarget(/** @type {{type: string, id: string}} */ ({ + type: "event", + id: "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789" + })); + expect(result).toEqual(/** @type {import('../src/identifiers.js').EventTarget} */ ({ + type: "event", + id: "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789" + })); + }); + + it("should create address target from object", () => { + const result = createTarget(/** @type {{type: string, kind: number, pubkey: string, identifier: string}} */ ({ + type: "address", + kind: 30023, + pubkey: "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789", + identifier: "test-identifier" + })); + expect(result).toEqual(/** @type {import('../src/identifiers.js').AddressTarget} */ ({ + type: "address", + kind: "30023", + pubkey: "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789", + identifier: "test-identifier" + })); + }); + + it("should return null for invalid inputs", () => { + expect(createTarget("")).toBeNull(); + expect(createTarget("invalid")).toBeNull(); + expect(createTarget(/** @type {any} */ ({}))).toBeNull(); + expect(createTarget(null)).toBeNull(); + expect(createTarget(undefined)).toBeNull(); + }); + }); + + describe("getParentTarget", () => { + it("should return null for user targets", () => { + const target = /** @type {import('../src/identifiers.js').UserTarget} */ ({ + type: "user", + pubkey: "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789" + }); + expect(getParentTarget(target)).toBeNull(); + }); + + it("should create user target from event target author", () => { + const target = /** @type {import('../src/identifiers.js').EventTarget} */ ({ + type: "event", + id: "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789" + }); + const authorPubkey = "1234567890123456789012345678901234567890123456789012345678901234"; + const result = getParentTarget(target, authorPubkey); + expect(result).toEqual(/** @type {import('../src/identifiers.js').UserTarget} */ ({ + type: "user", + pubkey: "1234567890123456789012345678901234567890123456789012345678901234" + })); + }); + + it("should create user target from address target", () => { + const target = /** @type {import('../src/identifiers.js').AddressTarget} */ ({ + type: "address", + kind: "30023", + pubkey: "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789", + identifier: "test-identifier" + }); + const result = getParentTarget(target); + expect(result).toEqual(/** @type {import('../src/identifiers.js').UserTarget} */ ({ + type: "user", + pubkey: "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789" + })); + }); + + it("should throw error for event targets without author pubkey", () => { + const target = /** @type {import('../src/identifiers.js').EventTarget} */ ({ + type: "event", + id: "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789" + }); + expect(() => getParentTarget(target)).toThrow("authorPubkey is required for event targets"); + }); + }); + + describe("isDescendantOf", () => { + it("should return false for identical targets", () => { + const target = /** @type {import('../src/identifiers.js').UserTarget} */ ({ + type: "user", + pubkey: "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789" + }); + expect(isDescendantOf(target, target)).toBe(false); + }); + + it("should return true for event target descendant of its author", () => { + const eventTarget = /** @type {import('../src/identifiers.js').EventTarget} */ ({ + type: "event", + id: "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789" + }); + const userTarget = /** @type {import('../src/identifiers.js').UserTarget} */ ({ + type: "user", + pubkey: "1234567890123456789012345678901234567890123456789012345678901234" + }); + expect(isDescendantOf( + eventTarget, + userTarget, + "1234567890123456789012345678901234567890123456789012345678901234" + )).toBe(true); + }); + + it("should return true for address target descendant of its author", () => { + const addressTarget = /** @type {import('../src/identifiers.js').AddressTarget} */ ({ + type: "address", + kind: "30023", + pubkey: "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789", + identifier: "test-identifier" + }); + const userTarget = /** @type {import('../src/identifiers.js').UserTarget} */ ({ + type: "user", + pubkey: "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789" + }); + expect(isDescendantOf(addressTarget, userTarget)).toBe(true); + }); + + it("should return false for user target with no parent", () => { + const target1 = /** @type {import('../src/identifiers.js').UserTarget} */ ({ + type: "user", + pubkey: "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789" + }); + const target2 = /** @type {import('../src/identifiers.js').UserTarget} */ ({ + type: "user", + pubkey: "1234567890123456789012345678901234567890123456789012345678901234" + }); + expect(isDescendantOf(target1, target2)).toBe(false); + }); + }); +}); \ No newline at end of file