@fluxpointstudios/orynq-sdk-process-trace 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/bundle.ts ADDED
@@ -0,0 +1,810 @@
1
+ /**
2
+ * @fileoverview Bundle creation, extraction, verification, and signing for trace bundles.
3
+ *
4
+ * Location: packages/process-trace/src/bundle.ts
5
+ *
6
+ * This module provides the core functionality for working with trace bundles:
7
+ * - Creating bundles from finalized trace runs
8
+ * - Extracting public views for safe external sharing
9
+ * - Verifying bundle integrity (hashes, sequences, merkle proofs)
10
+ * - Signing and verifying bundle signatures
11
+ *
12
+ * A TraceBundle is the finalized, immutable form of a trace run that includes:
13
+ * - The complete private trace run data
14
+ * - A public view with redacted sensitive information
15
+ * - Cryptographic commitments (rootHash, merkleRoot)
16
+ * - Optional signature for authenticity verification
17
+ *
18
+ * Visibility Rules:
19
+ * - "public": Events/spans are included in the publicView
20
+ * - "private": Hash included in redactedSpanHashes, data not disclosed
21
+ * - "secret": Hash included in redactedSpanHashes, data never disclosed
22
+ *
23
+ * Used by:
24
+ * - TraceBuilder: Creates bundles when finalizing traces
25
+ * - TraceVerifier: Validates bundle integrity
26
+ * - TraceStorage: Prepares bundles for storage/transmission
27
+ * - Disclosure workflows: Extracts public views for sharing
28
+ *
29
+ * @example
30
+ * ```typescript
31
+ * // Create a bundle from a finalized run
32
+ * const bundle = await createBundle(finalizedRun);
33
+ *
34
+ * // Extract public view for sharing
35
+ * const publicView = extractPublicView(bundle);
36
+ *
37
+ * // Verify bundle integrity
38
+ * const result = await verifyBundle(bundle);
39
+ * if (!result.valid) {
40
+ * console.error("Bundle verification failed:", result.errors);
41
+ * }
42
+ *
43
+ * // Sign a bundle
44
+ * const signedBundle = await signBundle(bundle, signatureProvider);
45
+ * ```
46
+ */
47
+
48
+ import {
49
+ canonicalize,
50
+ bytesToHex,
51
+ hexToBytes,
52
+ } from "@fluxpointstudios/orynq-sdk-core/utils";
53
+
54
+ import type {
55
+ TraceBundle,
56
+ TraceBundlePublicView,
57
+ TraceRun,
58
+ TraceSpan,
59
+ TraceEvent,
60
+ AnnotatedSpan,
61
+ TraceVerificationResult,
62
+ SignatureProvider,
63
+ Visibility,
64
+ } from "./types.js";
65
+
66
+ import {
67
+ computeEventHash,
68
+ computeRollingHash,
69
+ computeRootHash,
70
+ } from "./rolling-hash.js";
71
+
72
+ import { buildSpanMerkleTree, computeSpanHash } from "./merkle.js";
73
+
74
+ // =============================================================================
75
+ // VISIBILITY HELPERS
76
+ // =============================================================================
77
+
78
+ /**
79
+ * Check if a span should be included in public view.
80
+ *
81
+ * Only spans with visibility "public" are included in the public view.
82
+ * Private and secret spans are redacted (only their hashes are included).
83
+ *
84
+ * @param span - The span to check
85
+ * @returns true if the span is public and should be included in publicView
86
+ *
87
+ * @example
88
+ * ```typescript
89
+ * if (isPublicSpan(span)) {
90
+ * publicSpans.push(span);
91
+ * } else {
92
+ * redactedSpanHashes.push({ spanId: span.id, hash: span.hash });
93
+ * }
94
+ * ```
95
+ */
96
+ export function isPublicSpan(span: TraceSpan): boolean {
97
+ return span.visibility === "public";
98
+ }
99
+
100
+ /**
101
+ * Check if an event should be included in public view.
102
+ *
103
+ * Only events with visibility "public" are included in the public view.
104
+ * Private and secret events are not disclosed.
105
+ *
106
+ * @param event - The event to check
107
+ * @returns true if the event is public and should be included in publicView
108
+ *
109
+ * @example
110
+ * ```typescript
111
+ * const publicEvents = events.filter(isPublicEvent);
112
+ * ```
113
+ */
114
+ export function isPublicEvent(event: TraceEvent): boolean {
115
+ return event.visibility === "public";
116
+ }
117
+
118
+ /**
119
+ * Filter events by visibility, returning only public events.
120
+ *
121
+ * This function creates a new array containing only events with
122
+ * visibility === "public". The original array is not modified.
123
+ *
124
+ * @param events - Array of trace events to filter
125
+ * @returns Array containing only public events
126
+ *
127
+ * @example
128
+ * ```typescript
129
+ * const allEvents = getSpanEvents(span, run.events);
130
+ * const publicEvents = filterPublicEvents(allEvents);
131
+ * ```
132
+ */
133
+ export function filterPublicEvents(events: TraceEvent[]): TraceEvent[] {
134
+ return events.filter(isPublicEvent);
135
+ }
136
+
137
+ // =============================================================================
138
+ // BUNDLE CREATION
139
+ // =============================================================================
140
+
141
+ /**
142
+ * Create a bundle from a finalized trace run.
143
+ *
144
+ * The run should already have rootHash computed (i.e., be finalized).
145
+ * This function:
146
+ * 1. Validates the run is finalized
147
+ * 2. Builds the Merkle tree if not already computed
148
+ * 3. Creates the public view with redacted sensitive data
149
+ * 4. Returns the complete bundle
150
+ *
151
+ * @param run - The finalized trace run (must have rootHash)
152
+ * @returns Promise resolving to the complete TraceBundle
153
+ * @throws Error if the run is not finalized (missing rootHash)
154
+ *
155
+ * @example
156
+ * ```typescript
157
+ * // Finalize the run first
158
+ * const finalizedRun = await finalizeTraceRun(run);
159
+ *
160
+ * // Create the bundle
161
+ * const bundle = await createBundle(finalizedRun);
162
+ * console.log(bundle.rootHash); // Cryptographic commitment
163
+ * console.log(bundle.merkleRoot); // Merkle root for selective disclosure
164
+ * ```
165
+ */
166
+ export async function createBundle(run: TraceRun): Promise<TraceBundle> {
167
+ // Validate run is finalized
168
+ if (!run.rootHash) {
169
+ throw new Error(
170
+ "Cannot create bundle from non-finalized run: rootHash is missing. " +
171
+ "Call finalizeTraceRun() before creating a bundle."
172
+ );
173
+ }
174
+
175
+ if (run.status === "running") {
176
+ throw new Error(
177
+ "Cannot create bundle from running trace. " +
178
+ "The trace must be completed, failed, or cancelled."
179
+ );
180
+ }
181
+
182
+ // Ensure all events have hashes computed
183
+ for (const event of run.events) {
184
+ if (!event.hash) {
185
+ throw new Error(
186
+ `Event ${event.id} (seq ${event.seq}) is missing hash. ` +
187
+ "All events must have hashes computed before creating a bundle."
188
+ );
189
+ }
190
+ }
191
+
192
+ // Ensure all spans have hashes computed
193
+ for (const span of run.spans) {
194
+ if (!span.hash) {
195
+ throw new Error(
196
+ `Span ${span.id} (spanSeq ${span.spanSeq}) is missing hash. ` +
197
+ "All spans must have hashes computed before creating a bundle."
198
+ );
199
+ }
200
+ }
201
+
202
+ // Build Merkle tree from spans
203
+ const merkleTree = await buildSpanMerkleTree(run.spans, run.events);
204
+
205
+ // Create the public view
206
+ const publicView = createPublicView(run, merkleTree.rootHash);
207
+
208
+ // Construct the bundle
209
+ const bundle: TraceBundle = {
210
+ formatVersion: run.schemaVersion,
211
+ publicView,
212
+ privateRun: run,
213
+ merkleRoot: merkleTree.rootHash,
214
+ rootHash: run.rootHash,
215
+ };
216
+
217
+ return bundle;
218
+ }
219
+
220
+ /**
221
+ * Internal helper to create the public view from a run.
222
+ *
223
+ * @param run - The finalized trace run
224
+ * @param merkleRoot - The computed Merkle root
225
+ * @returns The TraceBundlePublicView
226
+ */
227
+ function createPublicView(
228
+ run: TraceRun,
229
+ merkleRoot: string
230
+ ): TraceBundlePublicView {
231
+ // Create event lookup map
232
+ const eventMap = new Map<string, TraceEvent>();
233
+ for (const event of run.events) {
234
+ eventMap.set(event.id, event);
235
+ }
236
+
237
+ // Separate public and non-public spans
238
+ const publicSpans: AnnotatedSpan[] = [];
239
+ const redactedSpanHashes: Array<{ spanId: string; hash: string }> = [];
240
+
241
+ for (const span of run.spans) {
242
+ if (isPublicSpan(span)) {
243
+ // Get events for this span and filter to public only
244
+ const spanEvents = span.eventIds
245
+ .map((id) => eventMap.get(id))
246
+ .filter((e): e is TraceEvent => e !== undefined)
247
+ .filter(isPublicEvent)
248
+ .sort((a, b) => a.seq - b.seq);
249
+
250
+ // Create annotated span with embedded events
251
+ const annotatedSpan: AnnotatedSpan = {
252
+ ...span,
253
+ events: spanEvents,
254
+ };
255
+
256
+ publicSpans.push(annotatedSpan);
257
+ } else {
258
+ // Non-public span: include only hash reference
259
+ redactedSpanHashes.push({
260
+ spanId: span.id,
261
+ hash: span.hash ?? "",
262
+ });
263
+ }
264
+ }
265
+
266
+ // Sort public spans by spanSeq for deterministic ordering
267
+ publicSpans.sort((a, b) => a.spanSeq - b.spanSeq);
268
+
269
+ // Sort redacted hashes by spanId for deterministic ordering
270
+ redactedSpanHashes.sort((a, b) => a.spanId.localeCompare(b.spanId));
271
+
272
+ return {
273
+ runId: run.id,
274
+ agentId: run.agentId,
275
+ schemaVersion: run.schemaVersion,
276
+ startedAt: run.startedAt,
277
+ endedAt: run.endedAt ?? new Date().toISOString(),
278
+ durationMs: run.durationMs ?? 0,
279
+ status: run.status,
280
+ totalEvents: run.events.length,
281
+ totalSpans: run.spans.length,
282
+ rootHash: run.rootHash ?? "",
283
+ merkleRoot,
284
+ publicSpans,
285
+ redactedSpanHashes,
286
+ };
287
+ }
288
+
289
+ // =============================================================================
290
+ // PUBLIC VIEW EXTRACTION
291
+ // =============================================================================
292
+
293
+ /**
294
+ * Extract the public view from a bundle.
295
+ *
296
+ * Returns only public spans with their public events.
297
+ * This is a convenience function that returns the pre-computed public view
298
+ * from the bundle. Use this for sharing trace information externally.
299
+ *
300
+ * Note: The public view is computed when the bundle is created, so this
301
+ * function simply returns the existing public view. If you need to
302
+ * re-compute the public view (e.g., with different redaction rules),
303
+ * you should create a new bundle.
304
+ *
305
+ * @param bundle - The trace bundle
306
+ * @returns The TraceBundlePublicView (safe to share externally)
307
+ *
308
+ * @example
309
+ * ```typescript
310
+ * const bundle = await createBundle(run);
311
+ * const publicView = extractPublicView(bundle);
312
+ *
313
+ * // Safe to share externally
314
+ * await sendToAuditSystem(publicView);
315
+ * ```
316
+ */
317
+ export function extractPublicView(bundle: TraceBundle): TraceBundlePublicView {
318
+ return bundle.publicView;
319
+ }
320
+
321
+ // =============================================================================
322
+ // BUNDLE VERIFICATION
323
+ // =============================================================================
324
+
325
+ /**
326
+ * Verify a bundle's integrity.
327
+ *
328
+ * Performs comprehensive validation including:
329
+ * - Event hashes are correct (recomputed and compared)
330
+ * - Span hashes are correct (recomputed and compared)
331
+ * - Rolling hash matches (recomputed from events)
332
+ * - Root hash matches (recomputed from rolling hash + span hashes)
333
+ * - Merkle root matches (recomputed from span tree)
334
+ * - Event sequence is monotonic (0, 1, 2, ...)
335
+ * - Span sequence is monotonic (0, 1, 2, ...)
336
+ *
337
+ * @param bundle - The trace bundle to verify
338
+ * @returns Promise resolving to comprehensive verification result
339
+ *
340
+ * @example
341
+ * ```typescript
342
+ * const result = await verifyBundle(bundle);
343
+ *
344
+ * if (!result.valid) {
345
+ * console.error("Bundle verification failed!");
346
+ * console.error("Errors:", result.errors);
347
+ * console.error("Warnings:", result.warnings);
348
+ * console.error("Checks:", result.checks);
349
+ * }
350
+ * ```
351
+ */
352
+ export async function verifyBundle(
353
+ bundle: TraceBundle
354
+ ): Promise<TraceVerificationResult> {
355
+ const errors: string[] = [];
356
+ const warnings: string[] = [];
357
+ const checks = {
358
+ rollingHashValid: false,
359
+ rootHashValid: false,
360
+ merkleRootValid: false,
361
+ spanHashesValid: false,
362
+ eventHashesValid: false,
363
+ sequenceValid: false,
364
+ };
365
+
366
+ const run = bundle.privateRun;
367
+
368
+ // ---------------------------------------------------------------------------
369
+ // Verify Event Sequence
370
+ // ---------------------------------------------------------------------------
371
+
372
+ const sequenceErrors = verifySequences(run);
373
+ if (sequenceErrors.length === 0) {
374
+ checks.sequenceValid = true;
375
+ } else {
376
+ errors.push(...sequenceErrors);
377
+ }
378
+
379
+ // ---------------------------------------------------------------------------
380
+ // Verify Event Hashes
381
+ // ---------------------------------------------------------------------------
382
+
383
+ const eventHashErrors = await verifyEventHashes(run.events);
384
+ if (eventHashErrors.length === 0) {
385
+ checks.eventHashesValid = true;
386
+ } else {
387
+ errors.push(...eventHashErrors);
388
+ }
389
+
390
+ // ---------------------------------------------------------------------------
391
+ // Verify Span Hashes
392
+ // ---------------------------------------------------------------------------
393
+
394
+ const spanHashErrors = await verifySpanHashes(run.spans, run.events);
395
+ if (spanHashErrors.length === 0) {
396
+ checks.spanHashesValid = true;
397
+ } else {
398
+ errors.push(...spanHashErrors);
399
+ }
400
+
401
+ // ---------------------------------------------------------------------------
402
+ // Verify Rolling Hash
403
+ // ---------------------------------------------------------------------------
404
+
405
+ try {
406
+ const computedRollingHash = await computeRollingHash(run.events);
407
+ if (computedRollingHash === run.rollingHash) {
408
+ checks.rollingHashValid = true;
409
+ } else {
410
+ errors.push(
411
+ `Rolling hash mismatch: expected ${run.rollingHash}, computed ${computedRollingHash}`
412
+ );
413
+ }
414
+ } catch (error) {
415
+ errors.push(
416
+ `Failed to compute rolling hash: ${error instanceof Error ? error.message : String(error)}`
417
+ );
418
+ }
419
+
420
+ // ---------------------------------------------------------------------------
421
+ // Verify Root Hash
422
+ // ---------------------------------------------------------------------------
423
+
424
+ try {
425
+ const computedRootHash = await computeRootHash(run.rollingHash, run.spans);
426
+ if (computedRootHash === bundle.rootHash) {
427
+ checks.rootHashValid = true;
428
+ } else {
429
+ errors.push(
430
+ `Root hash mismatch: expected ${bundle.rootHash}, computed ${computedRootHash}`
431
+ );
432
+ }
433
+ } catch (error) {
434
+ errors.push(
435
+ `Failed to compute root hash: ${error instanceof Error ? error.message : String(error)}`
436
+ );
437
+ }
438
+
439
+ // ---------------------------------------------------------------------------
440
+ // Verify Merkle Root
441
+ // ---------------------------------------------------------------------------
442
+
443
+ try {
444
+ const merkleTree = await buildSpanMerkleTree(run.spans, run.events);
445
+ if (merkleTree.rootHash === bundle.merkleRoot) {
446
+ checks.merkleRootValid = true;
447
+ } else {
448
+ errors.push(
449
+ `Merkle root mismatch: expected ${bundle.merkleRoot}, computed ${merkleTree.rootHash}`
450
+ );
451
+ }
452
+ } catch (error) {
453
+ errors.push(
454
+ `Failed to compute Merkle root: ${error instanceof Error ? error.message : String(error)}`
455
+ );
456
+ }
457
+
458
+ // ---------------------------------------------------------------------------
459
+ // Additional Warnings
460
+ // ---------------------------------------------------------------------------
461
+
462
+ // Warn if there are no public spans
463
+ if (bundle.publicView.publicSpans.length === 0 && run.spans.length > 0) {
464
+ warnings.push(
465
+ "No public spans in bundle. The public view will be empty. " +
466
+ "Consider marking some spans as public for transparency."
467
+ );
468
+ }
469
+
470
+ // Warn if run status doesn't match public view status
471
+ if (bundle.publicView.status !== run.status) {
472
+ warnings.push(
473
+ `Status mismatch between publicView (${bundle.publicView.status}) and privateRun (${run.status})`
474
+ );
475
+ }
476
+
477
+ // Determine overall validity
478
+ const valid =
479
+ checks.rollingHashValid &&
480
+ checks.rootHashValid &&
481
+ checks.merkleRootValid &&
482
+ checks.spanHashesValid &&
483
+ checks.eventHashesValid &&
484
+ checks.sequenceValid;
485
+
486
+ return {
487
+ valid,
488
+ errors,
489
+ warnings,
490
+ checks,
491
+ };
492
+ }
493
+
494
+ /**
495
+ * Verify event and span sequences are monotonic.
496
+ *
497
+ * @param run - The trace run to verify
498
+ * @returns Array of error messages (empty if valid)
499
+ */
500
+ function verifySequences(run: TraceRun): string[] {
501
+ const errors: string[] = [];
502
+
503
+ // Sort events by seq to check monotonicity
504
+ const sortedEvents = [...run.events].sort((a, b) => a.seq - b.seq);
505
+
506
+ // Check event sequence is monotonic starting from 0
507
+ for (let i = 0; i < sortedEvents.length; i++) {
508
+ const event = sortedEvents[i];
509
+ // Handle noUncheckedIndexedAccess - event is guaranteed to exist after loop bounds check
510
+ if (event !== undefined && event.seq !== i) {
511
+ errors.push(
512
+ `Event sequence gap: expected seq ${i}, found ${event.seq} for event ${event.id}`
513
+ );
514
+ }
515
+ }
516
+
517
+ // Sort spans by spanSeq to check monotonicity
518
+ const sortedSpans = [...run.spans].sort((a, b) => a.spanSeq - b.spanSeq);
519
+
520
+ // Check span sequence is monotonic starting from 0
521
+ for (let i = 0; i < sortedSpans.length; i++) {
522
+ const span = sortedSpans[i];
523
+ // Handle noUncheckedIndexedAccess - span is guaranteed to exist after loop bounds check
524
+ if (span !== undefined && span.spanSeq !== i) {
525
+ errors.push(
526
+ `Span sequence gap: expected spanSeq ${i}, found ${span.spanSeq} for span ${span.id}`
527
+ );
528
+ }
529
+ }
530
+
531
+ return errors;
532
+ }
533
+
534
+ /**
535
+ * Verify all event hashes are correct.
536
+ *
537
+ * @param events - Array of events to verify
538
+ * @returns Promise resolving to array of error messages (empty if valid)
539
+ */
540
+ async function verifyEventHashes(events: TraceEvent[]): Promise<string[]> {
541
+ const errors: string[] = [];
542
+
543
+ for (const event of events) {
544
+ if (!event.hash) {
545
+ errors.push(`Event ${event.id} (seq ${event.seq}) is missing hash`);
546
+ continue;
547
+ }
548
+
549
+ try {
550
+ const computedHash = await computeEventHash(event);
551
+ if (computedHash !== event.hash) {
552
+ errors.push(
553
+ `Event hash mismatch for ${event.id} (seq ${event.seq}): ` +
554
+ `expected ${event.hash}, computed ${computedHash}`
555
+ );
556
+ }
557
+ } catch (error) {
558
+ errors.push(
559
+ `Failed to compute hash for event ${event.id}: ` +
560
+ `${error instanceof Error ? error.message : String(error)}`
561
+ );
562
+ }
563
+ }
564
+
565
+ return errors;
566
+ }
567
+
568
+ /**
569
+ * Verify all span hashes are correct.
570
+ *
571
+ * @param spans - Array of spans to verify
572
+ * @param events - Array of all events (for looking up event hashes)
573
+ * @returns Promise resolving to array of error messages (empty if valid)
574
+ */
575
+ async function verifySpanHashes(
576
+ spans: TraceSpan[],
577
+ events: TraceEvent[]
578
+ ): Promise<string[]> {
579
+ const errors: string[] = [];
580
+
581
+ // Create event lookup map
582
+ const eventMap = new Map<string, TraceEvent>();
583
+ for (const event of events) {
584
+ eventMap.set(event.id, event);
585
+ }
586
+
587
+ for (const span of spans) {
588
+ if (!span.hash) {
589
+ errors.push(
590
+ `Span ${span.id} (spanSeq ${span.spanSeq}) is missing hash`
591
+ );
592
+ continue;
593
+ }
594
+
595
+ try {
596
+ // Get event hashes for this span in seq order
597
+ const spanEvents = span.eventIds
598
+ .map((id) => eventMap.get(id))
599
+ .filter((e): e is TraceEvent => e !== undefined)
600
+ .sort((a, b) => a.seq - b.seq);
601
+
602
+ const eventHashes = spanEvents.map((e) => e.hash ?? "");
603
+
604
+ const computedHash = await computeSpanHash(span, eventHashes);
605
+ if (computedHash !== span.hash) {
606
+ errors.push(
607
+ `Span hash mismatch for ${span.id} (spanSeq ${span.spanSeq}): ` +
608
+ `expected ${span.hash}, computed ${computedHash}`
609
+ );
610
+ }
611
+ } catch (error) {
612
+ errors.push(
613
+ `Failed to compute hash for span ${span.id}: ` +
614
+ `${error instanceof Error ? error.message : String(error)}`
615
+ );
616
+ }
617
+ }
618
+
619
+ return errors;
620
+ }
621
+
622
+ // =============================================================================
623
+ // BUNDLE SIGNING
624
+ // =============================================================================
625
+
626
+ /**
627
+ * Sign a bundle using the provided signature provider.
628
+ *
629
+ * Signs the canonical JSON of { rootHash, merkleRoot, manifestHash? }.
630
+ * The signature and signer ID are added to the bundle.
631
+ *
632
+ * @param bundle - The bundle to sign
633
+ * @param provider - The signature provider implementation
634
+ * @returns Promise resolving to the signed bundle (new object, original unchanged)
635
+ *
636
+ * @example
637
+ * ```typescript
638
+ * const provider: SignatureProvider = {
639
+ * signerId: "agent-123",
640
+ * sign: async (data) => await myHSM.sign(data),
641
+ * verify: async (data, sig, signerId) => await myHSM.verify(data, sig),
642
+ * };
643
+ *
644
+ * const signedBundle = await signBundle(bundle, provider);
645
+ * console.log(signedBundle.signature); // Hex-encoded signature
646
+ * console.log(signedBundle.signerId); // "agent-123"
647
+ * ```
648
+ */
649
+ export async function signBundle(
650
+ bundle: TraceBundle,
651
+ provider: SignatureProvider
652
+ ): Promise<TraceBundle> {
653
+ // Create the signing payload
654
+ const signingPayload: {
655
+ rootHash: string;
656
+ merkleRoot: string;
657
+ manifestHash?: string;
658
+ } = {
659
+ rootHash: bundle.rootHash,
660
+ merkleRoot: bundle.merkleRoot,
661
+ };
662
+
663
+ // Include manifestHash if present
664
+ if (bundle.manifestHash) {
665
+ signingPayload.manifestHash = bundle.manifestHash;
666
+ }
667
+
668
+ // Canonicalize to get deterministic bytes
669
+ const canonicalPayload = canonicalize(signingPayload);
670
+ const payloadBytes = new TextEncoder().encode(canonicalPayload);
671
+
672
+ // Sign using the provider
673
+ const signatureBytes = await provider.sign(payloadBytes);
674
+ const signatureHex = bytesToHex(signatureBytes);
675
+
676
+ // Return new bundle with signature
677
+ return {
678
+ ...bundle,
679
+ signerId: provider.signerId,
680
+ signature: signatureHex,
681
+ };
682
+ }
683
+
684
+ /**
685
+ * Verify a bundle's signature.
686
+ *
687
+ * Recomputes the signing payload and verifies the signature using
688
+ * the provider. The bundle must have both signature and signerId set.
689
+ *
690
+ * @param bundle - The signed bundle to verify
691
+ * @param provider - The signature provider implementation
692
+ * @returns Promise resolving to true if signature is valid, false otherwise
693
+ *
694
+ * @example
695
+ * ```typescript
696
+ * const isValid = await verifyBundleSignature(signedBundle, provider);
697
+ * if (!isValid) {
698
+ * throw new Error("Bundle signature verification failed!");
699
+ * }
700
+ * ```
701
+ */
702
+ export async function verifyBundleSignature(
703
+ bundle: TraceBundle,
704
+ provider: SignatureProvider
705
+ ): Promise<boolean> {
706
+ // Check required fields
707
+ if (!bundle.signature) {
708
+ return false;
709
+ }
710
+
711
+ if (!bundle.signerId) {
712
+ return false;
713
+ }
714
+
715
+ try {
716
+ // Recreate the signing payload
717
+ const signingPayload: {
718
+ rootHash: string;
719
+ merkleRoot: string;
720
+ manifestHash?: string;
721
+ } = {
722
+ rootHash: bundle.rootHash,
723
+ merkleRoot: bundle.merkleRoot,
724
+ };
725
+
726
+ // Include manifestHash if it was present when signed
727
+ if (bundle.manifestHash) {
728
+ signingPayload.manifestHash = bundle.manifestHash;
729
+ }
730
+
731
+ // Canonicalize to get deterministic bytes
732
+ const canonicalPayload = canonicalize(signingPayload);
733
+ const payloadBytes = new TextEncoder().encode(canonicalPayload);
734
+
735
+ // Convert signature from hex
736
+ const signatureBytes = hexToBytes(bundle.signature);
737
+
738
+ // Verify using the provider
739
+ return await provider.verify(payloadBytes, signatureBytes, bundle.signerId);
740
+ } catch (error) {
741
+ // Verification failed due to error (invalid format, etc.)
742
+ return false;
743
+ }
744
+ }
745
+
746
+ // =============================================================================
747
+ // UTILITY FUNCTIONS
748
+ // =============================================================================
749
+
750
+ /**
751
+ * Get all events belonging to a specific span.
752
+ *
753
+ * @param span - The span to get events for
754
+ * @param events - Array of all events
755
+ * @returns Array of events belonging to the span, sorted by seq
756
+ */
757
+ export function getSpanEvents(
758
+ span: TraceSpan,
759
+ events: TraceEvent[]
760
+ ): TraceEvent[] {
761
+ const eventMap = new Map<string, TraceEvent>();
762
+ for (const event of events) {
763
+ eventMap.set(event.id, event);
764
+ }
765
+
766
+ return span.eventIds
767
+ .map((id) => eventMap.get(id))
768
+ .filter((e): e is TraceEvent => e !== undefined)
769
+ .sort((a, b) => a.seq - b.seq);
770
+ }
771
+
772
+ /**
773
+ * Count events by visibility level in a run.
774
+ *
775
+ * @param run - The trace run to analyze
776
+ * @returns Object with counts for each visibility level
777
+ */
778
+ export function countEventsByVisibility(run: TraceRun): Record<Visibility, number> {
779
+ const counts: Record<Visibility, number> = {
780
+ public: 0,
781
+ private: 0,
782
+ secret: 0,
783
+ };
784
+
785
+ for (const event of run.events) {
786
+ counts[event.visibility]++;
787
+ }
788
+
789
+ return counts;
790
+ }
791
+
792
+ /**
793
+ * Count spans by visibility level in a run.
794
+ *
795
+ * @param run - The trace run to analyze
796
+ * @returns Object with counts for each visibility level
797
+ */
798
+ export function countSpansByVisibility(run: TraceRun): Record<Visibility, number> {
799
+ const counts: Record<Visibility, number> = {
800
+ public: 0,
801
+ private: 0,
802
+ secret: 0,
803
+ };
804
+
805
+ for (const span of run.spans) {
806
+ counts[span.visibility]++;
807
+ }
808
+
809
+ return counts;
810
+ }