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