parsers.ts raw

   1  /**
   2   * Event parsers for the Distributed Directory Consensus Protocol
   3   * 
   4   * This module provides parsers for all directory event kinds (39100-39105)
   5   * matching the Go implementation in pkg/protocol/directory/
   6   */
   7  
   8  import type { NostrEvent } from 'applesauce-core/helpers';
   9  import type {
  10    IdentityTag,
  11    RelayIdentity,
  12    TrustAct,
  13    GroupTagAct,
  14    PublicKeyAdvertisement,
  15    ReplicationRequest,
  16    ReplicationResponse,
  17  } from './types.js';
  18  import {
  19    EventKinds,
  20    TrustLevel,
  21    TrustReason,
  22    KeyPurpose,
  23    ReplicationStatus,
  24  } from './types.js';
  25  import {
  26    ValidationError,
  27    validateHexKey,
  28    validateWebSocketURL,
  29    validateTrustLevel,
  30    validateKeyPurpose,
  31    validateReplicationStatus,
  32    validateIdentityTagStructure,
  33  } from './validation.js';
  34  
  35  /**
  36   * Helper to get a tag value by name
  37   */
  38  function getTagValue(event: NostrEvent, tagName: string): string | undefined {
  39    const tag = event.tags.find(t => t[0] === tagName);
  40    return tag?.[1];
  41  }
  42  
  43  /**
  44   * Helper to get all tag values by name
  45   */
  46  function getTagValues(event: NostrEvent, tagName: string): string[] {
  47    return event.tags.filter(t => t[0] === tagName).map(t => t[1]);
  48  }
  49  
  50  /**
  51   * Helper to parse a timestamp tag
  52   */
  53  function parseTimestamp(value: string | undefined): Date | undefined {
  54    if (!value) return undefined;
  55    const timestamp = parseInt(value, 10);
  56    if (isNaN(timestamp)) return undefined;
  57    return new Date(timestamp * 1000);
  58  }
  59  
  60  /**
  61   * Helper to parse a number tag
  62   */
  63  function parseNumber(value: string | undefined): number | undefined {
  64    if (!value) return undefined;
  65    const num = parseFloat(value);
  66    return isNaN(num) ? undefined : num;
  67  }
  68  
  69  /**
  70   * Parse an Identity Tag (I tag) from an event
  71   * 
  72   * Format: ["I", <identity>, <delegate>, <signature>, <relay_hint>]
  73   */
  74  export function parseIdentityTag(event: NostrEvent): IdentityTag | undefined {
  75    const iTag = event.tags.find(t => t[0] === 'I');
  76    if (!iTag) return undefined;
  77    
  78    const [, identity, delegate, signature, relayHint] = iTag;
  79    
  80    if (!identity || !delegate || !signature) {
  81      throw new ValidationError('invalid I tag format: missing required fields');
  82    }
  83    
  84    const tag: IdentityTag = {
  85      identity,
  86      delegate,
  87      signature,
  88      relayHint: relayHint || undefined,
  89    };
  90    
  91    validateIdentityTagStructure(tag);
  92    
  93    return tag;
  94  }
  95  
  96  /**
  97   * Parse a Relay Identity Declaration (Kind 39100)
  98   */
  99  export function parseRelayIdentity(event: NostrEvent): RelayIdentity {
 100    if (event.kind !== EventKinds.RelayIdentityAnnouncement) {
 101      throw new ValidationError(`invalid event kind: expected ${EventKinds.RelayIdentityAnnouncement}, got ${event.kind}`);
 102    }
 103    
 104    const relayURL = getTagValue(event, 'relay');
 105    if (!relayURL) {
 106      throw new ValidationError('relay tag is required');
 107    }
 108    validateWebSocketURL(relayURL);
 109    
 110    const signingKey = getTagValue(event, 'signing_key');
 111    if (!signingKey) {
 112      throw new ValidationError('signing_key tag is required');
 113    }
 114    validateHexKey(signingKey);
 115    
 116    const encryptionKey = getTagValue(event, 'encryption_key');
 117    if (!encryptionKey) {
 118      throw new ValidationError('encryption_key tag is required');
 119    }
 120    validateHexKey(encryptionKey);
 121    
 122    const version = getTagValue(event, 'version');
 123    if (!version) {
 124      throw new ValidationError('version tag is required');
 125    }
 126    
 127    const nip11URL = getTagValue(event, 'nip11_url');
 128    const identityTag = parseIdentityTag(event);
 129    
 130    return {
 131      event,
 132      relayURL,
 133      signingKey,
 134      encryptionKey,
 135      version,
 136      nip11URL,
 137      identityTag,
 138    };
 139  }
 140  
 141  /**
 142   * Parse a Trust Act (Kind 39101)
 143   */
 144  export function parseTrustAct(event: NostrEvent): TrustAct {
 145    if (event.kind !== EventKinds.TrustAct) {
 146      throw new ValidationError(`invalid event kind: expected ${EventKinds.TrustAct}, got ${event.kind}`);
 147    }
 148    
 149    const targetPubkey = getTagValue(event, 'p');
 150    if (!targetPubkey) {
 151      throw new ValidationError('p tag (target pubkey) is required');
 152    }
 153    validateHexKey(targetPubkey);
 154    
 155    const trustLevelStr = getTagValue(event, 'trust_level');
 156    if (!trustLevelStr) {
 157      throw new ValidationError('trust_level tag is required');
 158    }
 159    validateTrustLevel(trustLevelStr);
 160    const trustLevel = trustLevelStr as TrustLevel;
 161    
 162    const expiry = parseTimestamp(getTagValue(event, 'expiry'));
 163    
 164    const reasonStr = getTagValue(event, 'reason');
 165    const reason = reasonStr ? (reasonStr as TrustReason) : undefined;
 166    
 167    const notes = event.content || undefined;
 168    const identityTag = parseIdentityTag(event);
 169    
 170    return {
 171      event,
 172      targetPubkey,
 173      trustLevel,
 174      expiry,
 175      reason,
 176      notes,
 177      identityTag,
 178    };
 179  }
 180  
 181  /**
 182   * Parse a Group Tag Act (Kind 39102)
 183   */
 184  export function parseGroupTagAct(event: NostrEvent): GroupTagAct {
 185    if (event.kind !== EventKinds.GroupTagAct) {
 186      throw new ValidationError(`invalid event kind: expected ${EventKinds.GroupTagAct}, got ${event.kind}`);
 187    }
 188    
 189    const targetPubkey = getTagValue(event, 'p');
 190    if (!targetPubkey) {
 191      throw new ValidationError('p tag (target pubkey) is required');
 192    }
 193    validateHexKey(targetPubkey);
 194    
 195    const groupTag = getTagValue(event, 'group_tag');
 196    if (!groupTag) {
 197      throw new ValidationError('group_tag tag is required');
 198    }
 199    
 200    const actor = getTagValue(event, 'actor');
 201    if (!actor) {
 202      throw new ValidationError('actor tag is required');
 203    }
 204    validateHexKey(actor);
 205    
 206    const confidence = parseNumber(getTagValue(event, 'confidence'));
 207    const expiry = parseTimestamp(getTagValue(event, 'expiry'));
 208    const notes = event.content || undefined;
 209    const identityTag = parseIdentityTag(event);
 210    
 211    return {
 212      event,
 213      targetPubkey,
 214      groupTag,
 215      actor,
 216      confidence,
 217      expiry,
 218      notes,
 219      identityTag,
 220    };
 221  }
 222  
 223  /**
 224   * Parse a Public Key Advertisement (Kind 39103)
 225   */
 226  export function parsePublicKeyAdvertisement(event: NostrEvent): PublicKeyAdvertisement {
 227    if (event.kind !== EventKinds.PublicKeyAdvertisement) {
 228      throw new ValidationError(`invalid event kind: expected ${EventKinds.PublicKeyAdvertisement}, got ${event.kind}`);
 229    }
 230    
 231    const keyID = getTagValue(event, 'd');
 232    if (!keyID) {
 233      throw new ValidationError('d tag (key ID) is required');
 234    }
 235    
 236    const publicKey = getTagValue(event, 'p');
 237    if (!publicKey) {
 238      throw new ValidationError('p tag (public key) is required');
 239    }
 240    validateHexKey(publicKey);
 241    
 242    const purposeStr = getTagValue(event, 'purpose');
 243    if (!purposeStr) {
 244      throw new ValidationError('purpose tag is required');
 245    }
 246    validateKeyPurpose(purposeStr);
 247    const purpose = purposeStr as KeyPurpose;
 248    
 249    const expiry = parseTimestamp(getTagValue(event, 'expiration'));
 250    
 251    const algorithm = getTagValue(event, 'algorithm');
 252    if (!algorithm) {
 253      throw new ValidationError('algorithm tag is required');
 254    }
 255    
 256    const derivationPath = getTagValue(event, 'derivation_path');
 257    if (!derivationPath) {
 258      throw new ValidationError('derivation_path tag is required');
 259    }
 260    
 261    const keyIndexStr = getTagValue(event, 'key_index');
 262    if (!keyIndexStr) {
 263      throw new ValidationError('key_index tag is required');
 264    }
 265    const keyIndex = parseInt(keyIndexStr, 10);
 266    if (isNaN(keyIndex)) {
 267      throw new ValidationError('key_index must be a valid integer');
 268    }
 269    
 270    const identityTag = parseIdentityTag(event);
 271    
 272    return {
 273      event,
 274      keyID,
 275      publicKey,
 276      purpose,
 277      expiry,
 278      algorithm,
 279      derivationPath,
 280      keyIndex,
 281      identityTag,
 282    };
 283  }
 284  
 285  /**
 286   * Parse a Replication Request (Kind 39104)
 287   */
 288  export function parseReplicationRequest(event: NostrEvent): ReplicationRequest {
 289    if (event.kind !== EventKinds.DirectoryEventReplicationRequest) {
 290      throw new ValidationError(`invalid event kind: expected ${EventKinds.DirectoryEventReplicationRequest}, got ${event.kind}`);
 291    }
 292    
 293    const requestID = getTagValue(event, 'request_id');
 294    if (!requestID) {
 295      throw new ValidationError('request_id tag is required');
 296    }
 297    
 298    const requestorRelay = getTagValue(event, 'relay');
 299    if (!requestorRelay) {
 300      throw new ValidationError('relay tag (requestor) is required');
 301    }
 302    validateWebSocketURL(requestorRelay);
 303    
 304    // Parse content as JSON for filter parameters
 305    let content: any = {};
 306    if (event.content) {
 307      try {
 308        content = JSON.parse(event.content);
 309      } catch (err) {
 310        throw new ValidationError('invalid JSON content in replication request');
 311      }
 312    }
 313    
 314    const targetRelay = content.target_relay || getTagValue(event, 'target_relay');
 315    if (!targetRelay) {
 316      throw new ValidationError('target_relay is required');
 317    }
 318    validateWebSocketURL(targetRelay);
 319    
 320    const kinds = content.kinds || [];
 321    if (!Array.isArray(kinds) || kinds.length === 0) {
 322      throw new ValidationError('kinds array is required and must not be empty');
 323    }
 324    
 325    const authors = content.authors;
 326    const since = content.since ? new Date(content.since * 1000) : undefined;
 327    const until = content.until ? new Date(content.until * 1000) : undefined;
 328    const limit = content.limit;
 329    
 330    const identityTag = parseIdentityTag(event);
 331    
 332    return {
 333      event,
 334      requestID,
 335      requestorRelay,
 336      targetRelay,
 337      kinds,
 338      authors,
 339      since,
 340      until,
 341      limit,
 342      identityTag,
 343    };
 344  }
 345  
 346  /**
 347   * Parse a Replication Response (Kind 39105)
 348   */
 349  export function parseReplicationResponse(event: NostrEvent): ReplicationResponse {
 350    if (event.kind !== EventKinds.DirectoryEventReplicationResponse) {
 351      throw new ValidationError(`invalid event kind: expected ${EventKinds.DirectoryEventReplicationResponse}, got ${event.kind}`);
 352    }
 353    
 354    const requestID = getTagValue(event, 'request_id');
 355    if (!requestID) {
 356      throw new ValidationError('request_id tag is required');
 357    }
 358    
 359    const statusStr = getTagValue(event, 'status');
 360    if (!statusStr) {
 361      throw new ValidationError('status tag is required');
 362    }
 363    validateReplicationStatus(statusStr);
 364    const status = statusStr as ReplicationStatus;
 365    
 366    const eventIDs = getTagValues(event, 'event_id');
 367    const error = getTagValue(event, 'error');
 368    const identityTag = parseIdentityTag(event);
 369    
 370    return {
 371      event,
 372      requestID,
 373      status,
 374      eventIDs,
 375      error,
 376      identityTag,
 377    };
 378  }
 379  
 380  /**
 381   * Parse any directory event based on its kind
 382   */
 383  export function parseDirectoryEvent(event: NostrEvent): 
 384    | RelayIdentity 
 385    | TrustAct 
 386    | GroupTagAct 
 387    | PublicKeyAdvertisement 
 388    | ReplicationRequest 
 389    | ReplicationResponse {
 390    switch (event.kind) {
 391      case EventKinds.RelayIdentityAnnouncement:
 392        return parseRelayIdentity(event);
 393      case EventKinds.TrustAct:
 394        return parseTrustAct(event);
 395      case EventKinds.GroupTagAct:
 396        return parseGroupTagAct(event);
 397      case EventKinds.PublicKeyAdvertisement:
 398        return parsePublicKeyAdvertisement(event);
 399      case EventKinds.DirectoryEventReplicationRequest:
 400        return parseReplicationRequest(event);
 401      case EventKinds.DirectoryEventReplicationResponse:
 402        return parseReplicationResponse(event);
 403      default:
 404        throw new ValidationError(`unknown directory event kind: ${event.kind}`);
 405    }
 406  }
 407  
 408