mirror of
https://github.com/PR0M3TH3AN/bitgate.git
synced 2026-10-27 08:31:00 +00:00
feat(core): implement targets module with target validation and hierarchy functions
This commit is contained in:
1 parent
c6b93aed82
commit
ec03aab0ab
2 files changed
+520
No files matched your search
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user