Files
next.orly.dev/pkg/protocol/directory-client/src/parsers.ts
mleku 8e15ca7e2f Enhance Directory Client Library for NIP-XX Protocol
- Introduced a TypeScript client library for the Distributed Directory Consensus Protocol (NIP-XX), providing a high-level API for managing directory events, identity resolution, and trust calculations.
- Implemented core functionalities including event parsing, trust score aggregation, and replication filtering, mirroring the Go implementation.
- Added comprehensive documentation and development guides for ease of use and integration.
- Updated the `.gitignore` to include additional dependencies and build artifacts for the TypeScript client.
- Enhanced validation mechanisms for group tag names and trust levels, ensuring robust input handling and security.
- Created a new `bun.lock` file to manage package dependencies effectively.
2025-10-25 14:12:09 +01:00

408 lines
11 KiB
TypeScript

/**
* Event parsers for the Distributed Directory Consensus Protocol
*
* This module provides parsers for all directory event kinds (39100-39105)
* matching the Go implementation in pkg/protocol/directory/
*/
import type { NostrEvent } from 'applesauce-core/helpers';
import type {
IdentityTag,
RelayIdentity,
TrustAct,
GroupTagAct,
PublicKeyAdvertisement,
ReplicationRequest,
ReplicationResponse,
} from './types.js';
import {
EventKinds,
TrustLevel,
TrustReason,
KeyPurpose,
ReplicationStatus,
} from './types.js';
import {
ValidationError,
validateHexKey,
validateWebSocketURL,
validateTrustLevel,
validateKeyPurpose,
validateReplicationStatus,
validateIdentityTagStructure,
} from './validation.js';
/**
* Helper to get a tag value by name
*/
function getTagValue(event: NostrEvent, tagName: string): string | undefined {
const tag = event.tags.find(t => t[0] === tagName);
return tag?.[1];
}
/**
* Helper to get all tag values by name
*/
function getTagValues(event: NostrEvent, tagName: string): string[] {
return event.tags.filter(t => t[0] === tagName).map(t => t[1]);
}
/**
* Helper to parse a timestamp tag
*/
function parseTimestamp(value: string | undefined): Date | undefined {
if (!value) return undefined;
const timestamp = parseInt(value, 10);
if (isNaN(timestamp)) return undefined;
return new Date(timestamp * 1000);
}
/**
* Helper to parse a number tag
*/
function parseNumber(value: string | undefined): number | undefined {
if (!value) return undefined;
const num = parseFloat(value);
return isNaN(num) ? undefined : num;
}
/**
* Parse an Identity Tag (I tag) from an event
*
* Format: ["I", <identity>, <delegate>, <signature>, <relay_hint>]
*/
export function parseIdentityTag(event: NostrEvent): IdentityTag | undefined {
const iTag = event.tags.find(t => t[0] === 'I');
if (!iTag) return undefined;
const [, identity, delegate, signature, relayHint] = iTag;
if (!identity || !delegate || !signature) {
throw new ValidationError('invalid I tag format: missing required fields');
}
const tag: IdentityTag = {
identity,
delegate,
signature,
relayHint: relayHint || undefined,
};
validateIdentityTagStructure(tag);
return tag;
}
/**
* Parse a Relay Identity Declaration (Kind 39100)
*/
export function parseRelayIdentity(event: NostrEvent): RelayIdentity {
if (event.kind !== EventKinds.RelayIdentityAnnouncement) {
throw new ValidationError(`invalid event kind: expected ${EventKinds.RelayIdentityAnnouncement}, got ${event.kind}`);
}
const relayURL = getTagValue(event, 'relay');
if (!relayURL) {
throw new ValidationError('relay tag is required');
}
validateWebSocketURL(relayURL);
const signingKey = getTagValue(event, 'signing_key');
if (!signingKey) {
throw new ValidationError('signing_key tag is required');
}
validateHexKey(signingKey);
const encryptionKey = getTagValue(event, 'encryption_key');
if (!encryptionKey) {
throw new ValidationError('encryption_key tag is required');
}
validateHexKey(encryptionKey);
const version = getTagValue(event, 'version');
if (!version) {
throw new ValidationError('version tag is required');
}
const nip11URL = getTagValue(event, 'nip11_url');
const identityTag = parseIdentityTag(event);
return {
event,
relayURL,
signingKey,
encryptionKey,
version,
nip11URL,
identityTag,
};
}
/**
* Parse a Trust Act (Kind 39101)
*/
export function parseTrustAct(event: NostrEvent): TrustAct {
if (event.kind !== EventKinds.TrustAct) {
throw new ValidationError(`invalid event kind: expected ${EventKinds.TrustAct}, got ${event.kind}`);
}
const targetPubkey = getTagValue(event, 'p');
if (!targetPubkey) {
throw new ValidationError('p tag (target pubkey) is required');
}
validateHexKey(targetPubkey);
const trustLevelStr = getTagValue(event, 'trust_level');
if (!trustLevelStr) {
throw new ValidationError('trust_level tag is required');
}
validateTrustLevel(trustLevelStr);
const trustLevel = trustLevelStr as TrustLevel;
const expiry = parseTimestamp(getTagValue(event, 'expiry'));
const reasonStr = getTagValue(event, 'reason');
const reason = reasonStr ? (reasonStr as TrustReason) : undefined;
const notes = event.content || undefined;
const identityTag = parseIdentityTag(event);
return {
event,
targetPubkey,
trustLevel,
expiry,
reason,
notes,
identityTag,
};
}
/**
* Parse a Group Tag Act (Kind 39102)
*/
export function parseGroupTagAct(event: NostrEvent): GroupTagAct {
if (event.kind !== EventKinds.GroupTagAct) {
throw new ValidationError(`invalid event kind: expected ${EventKinds.GroupTagAct}, got ${event.kind}`);
}
const targetPubkey = getTagValue(event, 'p');
if (!targetPubkey) {
throw new ValidationError('p tag (target pubkey) is required');
}
validateHexKey(targetPubkey);
const groupTag = getTagValue(event, 'group_tag');
if (!groupTag) {
throw new ValidationError('group_tag tag is required');
}
const actor = getTagValue(event, 'actor');
if (!actor) {
throw new ValidationError('actor tag is required');
}
validateHexKey(actor);
const confidence = parseNumber(getTagValue(event, 'confidence'));
const expiry = parseTimestamp(getTagValue(event, 'expiry'));
const notes = event.content || undefined;
const identityTag = parseIdentityTag(event);
return {
event,
targetPubkey,
groupTag,
actor,
confidence,
expiry,
notes,
identityTag,
};
}
/**
* Parse a Public Key Advertisement (Kind 39103)
*/
export function parsePublicKeyAdvertisement(event: NostrEvent): PublicKeyAdvertisement {
if (event.kind !== EventKinds.PublicKeyAdvertisement) {
throw new ValidationError(`invalid event kind: expected ${EventKinds.PublicKeyAdvertisement}, got ${event.kind}`);
}
const keyID = getTagValue(event, 'd');
if (!keyID) {
throw new ValidationError('d tag (key ID) is required');
}
const publicKey = getTagValue(event, 'p');
if (!publicKey) {
throw new ValidationError('p tag (public key) is required');
}
validateHexKey(publicKey);
const purposeStr = getTagValue(event, 'purpose');
if (!purposeStr) {
throw new ValidationError('purpose tag is required');
}
validateKeyPurpose(purposeStr);
const purpose = purposeStr as KeyPurpose;
const expiry = parseTimestamp(getTagValue(event, 'expiration'));
const algorithm = getTagValue(event, 'algorithm');
if (!algorithm) {
throw new ValidationError('algorithm tag is required');
}
const derivationPath = getTagValue(event, 'derivation_path');
if (!derivationPath) {
throw new ValidationError('derivation_path tag is required');
}
const keyIndexStr = getTagValue(event, 'key_index');
if (!keyIndexStr) {
throw new ValidationError('key_index tag is required');
}
const keyIndex = parseInt(keyIndexStr, 10);
if (isNaN(keyIndex)) {
throw new ValidationError('key_index must be a valid integer');
}
const identityTag = parseIdentityTag(event);
return {
event,
keyID,
publicKey,
purpose,
expiry,
algorithm,
derivationPath,
keyIndex,
identityTag,
};
}
/**
* Parse a Replication Request (Kind 39104)
*/
export function parseReplicationRequest(event: NostrEvent): ReplicationRequest {
if (event.kind !== EventKinds.DirectoryEventReplicationRequest) {
throw new ValidationError(`invalid event kind: expected ${EventKinds.DirectoryEventReplicationRequest}, got ${event.kind}`);
}
const requestID = getTagValue(event, 'request_id');
if (!requestID) {
throw new ValidationError('request_id tag is required');
}
const requestorRelay = getTagValue(event, 'relay');
if (!requestorRelay) {
throw new ValidationError('relay tag (requestor) is required');
}
validateWebSocketURL(requestorRelay);
// Parse content as JSON for filter parameters
let content: any = {};
if (event.content) {
try {
content = JSON.parse(event.content);
} catch (err) {
throw new ValidationError('invalid JSON content in replication request');
}
}
const targetRelay = content.target_relay || getTagValue(event, 'target_relay');
if (!targetRelay) {
throw new ValidationError('target_relay is required');
}
validateWebSocketURL(targetRelay);
const kinds = content.kinds || [];
if (!Array.isArray(kinds) || kinds.length === 0) {
throw new ValidationError('kinds array is required and must not be empty');
}
const authors = content.authors;
const since = content.since ? new Date(content.since * 1000) : undefined;
const until = content.until ? new Date(content.until * 1000) : undefined;
const limit = content.limit;
const identityTag = parseIdentityTag(event);
return {
event,
requestID,
requestorRelay,
targetRelay,
kinds,
authors,
since,
until,
limit,
identityTag,
};
}
/**
* Parse a Replication Response (Kind 39105)
*/
export function parseReplicationResponse(event: NostrEvent): ReplicationResponse {
if (event.kind !== EventKinds.DirectoryEventReplicationResponse) {
throw new ValidationError(`invalid event kind: expected ${EventKinds.DirectoryEventReplicationResponse}, got ${event.kind}`);
}
const requestID = getTagValue(event, 'request_id');
if (!requestID) {
throw new ValidationError('request_id tag is required');
}
const statusStr = getTagValue(event, 'status');
if (!statusStr) {
throw new ValidationError('status tag is required');
}
validateReplicationStatus(statusStr);
const status = statusStr as ReplicationStatus;
const eventIDs = getTagValues(event, 'event_id');
const error = getTagValue(event, 'error');
const identityTag = parseIdentityTag(event);
return {
event,
requestID,
status,
eventIDs,
error,
identityTag,
};
}
/**
* Parse any directory event based on its kind
*/
export function parseDirectoryEvent(event: NostrEvent):
| RelayIdentity
| TrustAct
| GroupTagAct
| PublicKeyAdvertisement
| ReplicationRequest
| ReplicationResponse {
switch (event.kind) {
case EventKinds.RelayIdentityAnnouncement:
return parseRelayIdentity(event);
case EventKinds.TrustAct:
return parseTrustAct(event);
case EventKinds.GroupTagAct:
return parseGroupTagAct(event);
case EventKinds.PublicKeyAdvertisement:
return parsePublicKeyAdvertisement(event);
case EventKinds.DirectoryEventReplicationRequest:
return parseReplicationRequest(event);
case EventKinds.DirectoryEventReplicationResponse:
return parseReplicationResponse(event);
default:
throw new ValidationError(`unknown directory event kind: ${event.kind}`);
}
}