@slicedearth/whoisleuth-cli 1.43.0 → 1.47.4

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.
Files changed (153) hide show
  1. package/DISCLOSURE +7 -3
  2. package/NOTICE +5 -4
  3. package/README.md +67 -10
  4. package/bin/whoisleuth.mjs +22 -9
  5. package/cli/arguments.mjs +256 -9
  6. package/cli/artifact-structure.mjs +1833 -0
  7. package/cli/artifact-verify.mjs +242 -49
  8. package/cli/bounded-json.mjs +1 -0
  9. package/cli/bulk-checkpoint.mjs +67 -31
  10. package/cli/bulk-command-runner.mjs +16 -6
  11. package/cli/bulk.mjs +18 -7
  12. package/cli/case-pack.mjs +819 -34
  13. package/cli/command-reference.mjs +48 -24
  14. package/cli/completion.mjs +50 -11
  15. package/cli/config-profile.mjs +15 -12
  16. package/cli/ct-event-intake.mjs +32 -2
  17. package/cli/discovery-observation-snapshot.mjs +8 -14
  18. package/cli/discovery-scan-command-runner.mjs +18 -6
  19. package/cli/discovery-snapshot.mjs +8 -15
  20. package/cli/discovery-workflow.mjs +2 -3
  21. package/cli/doctor.mjs +2 -2
  22. package/cli/domain-control-monitor.mjs +21 -15
  23. package/cli/domain-control-observations.mjs +25 -2
  24. package/cli/errors.mjs +7 -1
  25. package/cli/evidence-command-runner.mjs +17 -11
  26. package/cli/evidence-signing.mjs +73 -45
  27. package/cli/export-evidence.mjs +11 -4
  28. package/cli/formatters/html.mjs +3 -3
  29. package/cli/formatters/json.mjs +12 -4
  30. package/cli/formatters/terminal.mjs +4 -1
  31. package/cli/http.mjs +2 -1
  32. package/cli/input.mjs +57 -0
  33. package/cli/interchange-report.mjs +226 -0
  34. package/cli/investigation-manifest.mjs +16 -17
  35. package/cli/investigation-run.mjs +71 -23
  36. package/cli/local-mmdb-review.mjs +45 -14
  37. package/cli/lookup-command-runner.mjs +2 -2
  38. package/cli/lookup-diff.mjs +9 -0
  39. package/cli/lookup-reconcile.mjs +1 -1
  40. package/cli/mail-review.mjs +5 -5
  41. package/cli/manual.mjs +1 -1
  42. package/cli/network-command-runner.mjs +78 -0
  43. package/cli/offline-evidence-review.mjs +28 -8
  44. package/cli/page-compare.mjs +8 -5
  45. package/cli/registry-cohort.mjs +385 -61
  46. package/cli/registry-fixture-scaffold.mjs +4 -3
  47. package/cli/registry-support.mjs +2 -1
  48. package/cli/retained-artifact-diff.mjs +376 -0
  49. package/cli/risk-calibration.mjs +7 -5
  50. package/cli/runner.mjs +150 -177
  51. package/cli/saved-lookup.mjs +26 -3
  52. package/cli/sharing-review.mjs +12 -6
  53. package/docs/cli-reference.md +467 -74
  54. package/docs/cli.md +25 -5
  55. package/frontend/src/lib/analysis/acquisition-decision-packet.js +4 -4
  56. package/frontend/src/lib/analysis/acquisition-due-diligence.js +1 -1
  57. package/frontend/src/lib/analysis/artifact-integrity.js +42 -0
  58. package/frontend/src/lib/analysis/brand-profile-model.js +33 -7
  59. package/frontend/src/lib/analysis/bulk-domain-comparison.js +25 -5
  60. package/frontend/src/lib/analysis/bulk-mail-exposure.js +66 -15
  61. package/frontend/src/lib/analysis/bulk-retry-plan.js +1 -1
  62. package/frontend/src/lib/analysis/bulk-review-export.js +7 -6
  63. package/frontend/src/lib/analysis/bulk-review-model.js +3 -2
  64. package/frontend/src/lib/analysis/bulk-session-model.js +192 -26
  65. package/frontend/src/lib/analysis/bulk-sort.js +4 -0
  66. package/frontend/src/lib/analysis/case-brand-profile-references.js +85 -0
  67. package/frontend/src/lib/analysis/case-evidence-model.js +9 -1
  68. package/frontend/src/lib/analysis/case-investigation-branch-model.js +134 -0
  69. package/frontend/src/lib/analysis/case-migration-model.js +83 -31
  70. package/frontend/src/lib/analysis/case-record-contracts.js +4 -2
  71. package/frontend/src/lib/analysis/case-record-core.js +1 -1
  72. package/frontend/src/lib/analysis/case-record-model.js +2 -0
  73. package/frontend/src/lib/analysis/case-record-operations.js +43 -13
  74. package/frontend/src/lib/analysis/case-report.js +39 -6
  75. package/frontend/src/lib/analysis/case-response-model.js +66 -7
  76. package/frontend/src/lib/analysis/case-response-packet.js +15 -5
  77. package/frontend/src/lib/analysis/comparison-ledger-bulk.js +366 -0
  78. package/frontend/src/lib/analysis/comparison-ledger-contract.js +241 -0
  79. package/frontend/src/lib/analysis/comparison-ledger-json.js +79 -0
  80. package/frontend/src/lib/analysis/comparison-ledger-serialization.js +51 -0
  81. package/frontend/src/lib/analysis/domain-control-manifest-core.js +145 -0
  82. package/frontend/src/lib/analysis/domain-control-records.js +88 -0
  83. package/frontend/src/lib/analysis/external-findings-import.js +70 -6
  84. package/frontend/src/lib/analysis/investigation-capsule.js +47 -13
  85. package/frontend/src/lib/analysis/investigation-guide.js +52 -10
  86. package/frontend/src/lib/analysis/investigation-template-model.js +9 -6
  87. package/frontend/src/lib/analysis/lookup-asset-graph.js +8 -8
  88. package/frontend/src/lib/analysis/lookup-claim-passport.js +236 -0
  89. package/frontend/src/lib/analysis/lookup-claim-readiness.js +280 -0
  90. package/frontend/src/lib/analysis/lookup-decision-support.js +63 -8
  91. package/frontend/src/lib/analysis/lookup-presentation.js +70 -14
  92. package/frontend/src/lib/analysis/lookup-source-refresh.js +36 -63
  93. package/frontend/src/lib/analysis/opaque-reference-id.js +9 -0
  94. package/frontend/src/lib/analysis/utils.js +27 -4
  95. package/frontend/src/lib/analysis/visualization-matrix.js +71 -28
  96. package/frontend/src/lib/analysis/visualization-models.js +63 -94
  97. package/frontend/src/lib/analysis/visualization-time-series.js +53 -12
  98. package/frontend/src/lib/analysis/website-snapshot-model.js +6 -0
  99. package/frontend/src/lib/analysis/workspace-archive-crypto.js +1 -4
  100. package/frontend/src/lib/analysis/workspace-archive.js +105 -19
  101. package/lib/application-version.mjs +3 -0
  102. package/lib/availability.mjs +14 -12
  103. package/lib/bounded-file.mjs +82 -0
  104. package/lib/bounded-json-response.mjs +170 -0
  105. package/lib/bounded-json.mjs +249 -0
  106. package/lib/browser-library-profile.mjs +167 -38
  107. package/lib/certificate-extension-profile.mjs +2 -1
  108. package/lib/cryptographic-assurance.mjs +155 -0
  109. package/lib/ct-search.mjs +87 -47
  110. package/lib/dns-intelligence.mjs +3 -2
  111. package/lib/dns-resolver-selection.mjs +6 -4
  112. package/lib/dnssec-chain-validation.mjs +1053 -0
  113. package/lib/dnssec-evidence-validation.mjs +10 -3
  114. package/lib/domain-change-packet.mjs +7 -5
  115. package/lib/domain-control-manifest.mjs +17 -129
  116. package/lib/domain-posture.mjs +7 -4
  117. package/lib/evidence-export.mjs +751 -78
  118. package/lib/evidence-report-markdown.mjs +3 -0
  119. package/lib/evidence-report.mjs +5 -0
  120. package/lib/favicon.mjs +8 -5
  121. package/lib/http-evidence-bounds.mjs +3 -0
  122. package/lib/http-intelligence.mjs +2 -5
  123. package/lib/interchange-fidelity-registry.mjs +122 -0
  124. package/lib/local-geoip-evidence.mjs +2 -1
  125. package/lib/lookup-cache.mjs +5 -1
  126. package/lib/lookup-network-evidence-bounds.mjs +9 -0
  127. package/lib/lookup-response-contract.mjs +691 -3
  128. package/lib/lookup.mjs +2 -2
  129. package/lib/observation.mjs +7 -6
  130. package/lib/outbound-identity.mjs +2 -3
  131. package/lib/perceptual-hash.mjs +12 -4
  132. package/lib/portable-generator.mjs +21 -0
  133. package/lib/provider-policy-admission.mjs +9 -4
  134. package/lib/rdap-normalization.mjs +18 -5
  135. package/lib/rdap-registrar.mjs +5 -1
  136. package/lib/rdap-transport.mjs +6 -6
  137. package/lib/registry-capabilities.mjs +3 -0
  138. package/lib/registry-capability-catalogue.mjs +12 -2
  139. package/lib/registry-comparison.mjs +28 -11
  140. package/lib/risk-calibration-summary.mjs +338 -0
  141. package/lib/rpki-evidence.mjs +9 -8
  142. package/lib/safe-fetch.mjs +96 -42
  143. package/lib/security-txt.mjs +26 -3
  144. package/lib/service-binding-dns.mjs +6 -1
  145. package/lib/smtp-transport-review.mjs +784 -0
  146. package/lib/sslbl-certificates.generated.mjs +136 -134
  147. package/lib/threat-intelligence-runtime.mjs +4 -13
  148. package/lib/threat-intelligence-types.mjs +18 -1
  149. package/lib/tls-intelligence.mjs +11 -5
  150. package/lib/tlsa-evidence.mjs +37 -7
  151. package/lib/website-technology.mjs +2 -6
  152. package/package.json +1 -1
  153. package/third-party-notices.txt +95 -5817
@@ -1,21 +1,15 @@
1
1
  import { scaleBand, scaleLinear, scalePoint } from 'd3-scale';
2
2
  import { boundedVisualizationId as boundedId, boundedVisualizationNumber as boundedNumber, boundedVisualizationText as boundedText, validVisualizationDate as validDate, } from "./visualization-bounds.js";
3
3
  export { FORCE_GRAPH_LINK_KINDS, MAX_FORCE_GRAPH_LINKS, MAX_FORCE_GRAPH_NODES, projectBoundedForceGraph, } from "./visualization-force-graph.js";
4
- export { MAX_VISUAL_MATRIX_COLUMNS, MAX_VISUAL_MATRIX_ROWS, projectEvidenceMatrix, } from "./visualization-matrix.js";
4
+ export { MAX_VISUAL_MATRIX_CELLS, MAX_VISUAL_MATRIX_COLUMNS, MAX_VISUAL_MATRIX_ROWS, projectEvidenceMatrix, } from "./visualization-matrix.js";
5
5
  export { MAX_COLLECTION_TIMING_SOURCES, MAX_LIFECYCLE_EVENTS, MAX_TREND_POINTS, projectCollectionTiming, projectLifecycleEvents, projectTrendPoints, } from "./visualization-time-series.js";
6
6
  export const MAX_REDIRECT_NODES = 9;
7
- export const MAX_TRIAGE_PLOT_POINTS = 300;
8
7
  export const WATCHLIST_ACTIVITY_DAYS = 28;
9
8
  export const MAX_SCORE_FACTORS = 16;
10
- export const MAX_COVERAGE_BAR_GROUPS = 18;
9
+ export const MAX_SCORE_FACTOR_LABEL_LENGTH = 160;
10
+ export const MAX_PROFILE_LISTING_BAR_GROUPS = 18;
11
11
  export const MAX_MONITOR_TIMELINE_EVENTS = 12;
12
12
  export const MAX_MONITOR_TIMELINE_LANES = 6;
13
- function boundedScore(value) {
14
- if (value === null || value === undefined || value === '')
15
- return null;
16
- const score = Number(value);
17
- return Number.isFinite(score) ? Math.max(0, Math.min(100, score)) : null;
18
- }
19
13
  function redirectLabel(value) {
20
14
  const text = boundedText(value, 320);
21
15
  try {
@@ -75,80 +69,21 @@ export function projectRedirectPath(rawRedirects) {
75
69
  truncated: rawCount > redirects.length,
76
70
  };
77
71
  }
78
- function deterministicSample(items, limit) {
79
- const boundedLimit = Number.isFinite(limit) ? Math.max(0, Math.floor(limit)) : 0;
80
- if (boundedLimit === 0)
81
- return [];
82
- if (items.length <= boundedLimit)
83
- return items;
84
- if (boundedLimit === 1)
85
- return items.slice(0, 1);
86
- return Array.from({ length: boundedLimit }, (_, index) => {
87
- const sourceIndex = Math.round(index * (items.length - 1) / (boundedLimit - 1));
88
- return items[sourceIndex];
89
- }).filter((item) => item !== undefined);
90
- }
91
- export function projectTriagePoints(rawPoints) {
92
- const eligible = (Array.isArray(rawPoints) ? rawPoints : [])
93
- .map((point) => ({
94
- domain: boundedText(point?.domain, 253).toLowerCase(),
95
- risk: boundedScore(point?.risk),
96
- opportunity: boundedScore(point?.opportunity),
97
- availability: boundedText(point?.availability, 30).toLowerCase() || 'unknown',
98
- trusted: Boolean(point?.trusted),
99
- }))
100
- .filter((point) => point.domain && point.risk !== null && point.opportunity !== null)
101
- .sort((a, b) => a.domain.localeCompare(b.domain));
102
- const sampled = deterministicSample(eligible, MAX_TRIAGE_PLOT_POINTS);
103
- const quadrants = eligible.reduce((counts, point) => {
104
- if (point.risk >= 50) {
105
- if (point.opportunity >= 50)
106
- counts.priorityReview += 1;
107
- else
108
- counts.riskLedReview += 1;
109
- }
110
- else if (point.opportunity >= 50) {
111
- counts.availableReview += 1;
112
- }
113
- else {
114
- counts.lowerScores += 1;
115
- }
116
- return counts;
117
- }, {
118
- availableReview: 0,
119
- priorityReview: 0,
120
- lowerScores: 0,
121
- riskLedReview: 0,
122
- });
123
- const x = scaleLinear().domain([0, 100]).range([58, 842]).clamp(true);
124
- const y = scaleLinear().domain([0, 100]).range([308, 28]).clamp(true);
125
- const points = sampled.map((point) => ({
126
- ...point,
127
- risk: point.risk,
128
- opportunity: point.opportunity,
129
- x: x(point.risk),
130
- y: y(point.opportunity),
131
- tone: point.trusted
132
- ? 'trusted'
133
- : point.availability === 'available'
134
- ? 'available'
135
- : point.availability === 'error'
136
- ? 'error'
137
- : 'registered',
138
- }));
139
- return {
140
- width: 900,
141
- height: 360,
142
- points,
143
- eligibleCount: eligible.length,
144
- omittedCount: Math.max(0, (Array.isArray(rawPoints) ? rawPoints.length : 0) - eligible.length),
145
- sampled: eligible.length > sampled.length,
146
- quadrants,
147
- };
148
- }
149
72
  function utcDayKey(milliseconds) {
150
73
  return new Date(milliseconds).toISOString().slice(0, 10);
151
74
  }
75
+ function utcWeekLabel(startMilliseconds, endMilliseconds) {
76
+ const start = new Date(startMilliseconds);
77
+ const end = new Date(endMilliseconds);
78
+ const startDay = start.getUTCDate();
79
+ const endDay = end.getUTCDate();
80
+ const startMonth = start.toLocaleDateString('en-AU', { month: 'short', timeZone: 'UTC' });
81
+ const endMonth = end.toLocaleDateString('en-AU', { month: 'short', timeZone: 'UTC' });
82
+ const endYear = end.getUTCFullYear();
83
+ return startMonth === endMonth
84
+ ? `${startDay}–${endDay} ${endMonth} ${endYear}`
85
+ : `${startDay} ${startMonth}–${endDay} ${endMonth} ${endYear}`;
86
+ }
152
87
  export function projectWatchlistActivity(rawEvents) {
153
88
  const valid = (Array.isArray(rawEvents) ? rawEvents : [])
154
89
  .map((event) => {
@@ -162,7 +97,19 @@ export function projectWatchlistActivity(rawEvents) {
162
97
  })
163
98
  .filter((event) => Boolean(event));
164
99
  if (!valid.length) {
165
- return { width: 620, height: 190, days: [], maxChanges: 0, totalChecks: 0, totalChanges: 0 };
100
+ return {
101
+ width: 660,
102
+ height: 235,
103
+ days: [],
104
+ weekdayLabels: [],
105
+ weekLabels: [],
106
+ timeBasis: 'UTC calendar days',
107
+ windowStart: null,
108
+ windowEnd: null,
109
+ maxChanges: 0,
110
+ totalChecks: 0,
111
+ totalChanges: 0,
112
+ };
166
113
  }
167
114
  const latest = Math.max(...valid.map((event) => event.milliseconds));
168
115
  const latestDay = Date.UTC(new Date(latest).getUTCFullYear(), new Date(latest).getUTCMonth(), new Date(latest).getUTCDate());
@@ -179,8 +126,8 @@ export function projectWatchlistActivity(rawEvents) {
179
126
  current.conclusive += event.conclusiveCount;
180
127
  byDay.set(key, current);
181
128
  }
182
- const weekScale = scaleBand().domain([0, 1, 2, 3]).range([72, 580]).padding(0.12);
183
- const dayScale = scaleBand().domain([0, 1, 2, 3, 4, 5, 6]).range([26, 165]).padding(0.12);
129
+ const weekScale = scaleBand().domain([0, 1, 2, 3]).range([92, 638]).padding(0.12);
130
+ const dayScale = scaleBand().domain([0, 1, 2, 3, 4, 5, 6]).range([42, 190]).padding(0.12);
184
131
  const dayWidth = weekScale.bandwidth();
185
132
  const dayHeight = dayScale.bandwidth();
186
133
  const days = Array.from({ length: WATCHLIST_ACTIVITY_DAYS }, (_, index) => {
@@ -199,10 +146,31 @@ export function projectWatchlistActivity(rawEvents) {
199
146
  ...activity,
200
147
  };
201
148
  });
149
+ const weekdayLabels = Array.from({ length: 7 }, (_, index) => ({
150
+ index,
151
+ label: new Date(start + index * 86_400_000).toLocaleDateString('en-AU', { weekday: 'short', timeZone: 'UTC' }),
152
+ y: (dayScale(index) ?? 0) + dayHeight / 2 + 3,
153
+ }));
154
+ const weekLabels = Array.from({ length: 4 }, (_, index) => {
155
+ const weekStart = start + index * 7 * 86_400_000;
156
+ const weekEnd = weekStart + 6 * 86_400_000;
157
+ return {
158
+ index,
159
+ label: utcWeekLabel(weekStart, weekEnd),
160
+ startDate: utcDayKey(weekStart),
161
+ endDate: utcDayKey(weekEnd),
162
+ x: (weekScale(index) ?? 0) + dayWidth / 2,
163
+ };
164
+ });
202
165
  return {
203
- width: 620,
204
- height: 190,
166
+ width: 660,
167
+ height: 235,
205
168
  days,
169
+ weekdayLabels,
170
+ weekLabels,
171
+ timeBasis: 'UTC calendar days',
172
+ windowStart: utcDayKey(start),
173
+ windowEnd: utcDayKey(latestDay),
206
174
  maxChanges: Math.max(0, ...days.map((day) => day.changes)),
207
175
  totalChecks: windowEvents.length,
208
176
  totalChanges: windowEvents.reduce((sum, event) => sum + event.changeCount, 0),
@@ -212,7 +180,7 @@ export function projectScoreFactors(rawFactors) {
212
180
  const candidates = (Array.isArray(rawFactors) ? rawFactors : [])
213
181
  .map((factor, index) => ({
214
182
  id: `${boundedId(factor?.label) || 'factor'}-${index}`,
215
- label: boundedText(factor?.label, 56) || `Factor ${index + 1}`,
183
+ label: boundedText(factor?.label, MAX_SCORE_FACTOR_LABEL_LENGTH) || `Factor ${index + 1}`,
216
184
  delta: boundedNumber(factor?.delta, -100, 100),
217
185
  }));
218
186
  const factors = candidates.slice(0, MAX_SCORE_FACTORS);
@@ -237,34 +205,35 @@ export function projectScoreFactors(rawFactors) {
237
205
  truncated: candidates.length > factors.length,
238
206
  };
239
207
  }
240
- export function projectCoverageBars(rawGroups) {
208
+ export function projectProfileListingBars(rawGroups) {
241
209
  const candidates = (Array.isArray(rawGroups) ? rawGroups : [])
242
210
  .map((group, index) => {
243
- const values = {
244
- protected: Math.trunc(boundedNumber(group?.protected, 0, 100_000)),
211
+ const outcomes = {
245
212
  registered: Math.trunc(boundedNumber(group?.registered, 0, 100_000)),
246
213
  available: Math.trunc(boundedNumber(group?.available, 0, 100_000)),
247
214
  unknown: Math.trunc(boundedNumber(group?.unknown, 0, 100_000)),
248
215
  };
216
+ const total = Object.values(outcomes).reduce((sum, value) => sum + value, 0);
249
217
  return {
250
218
  id: boundedId(group?.id) || `group-${index}`,
251
219
  label: boundedText(group?.label, 56) || `Group ${index + 1}`,
252
- ...values,
253
- total: Object.values(values).reduce((sum, value) => sum + value, 0),
220
+ ...outcomes,
221
+ total,
222
+ profileListed: Math.min(total, Math.trunc(boundedNumber(group?.profileListed, 0, 100_000))),
254
223
  };
255
224
  })
256
225
  .filter((group) => group.total > 0)
257
226
  .sort((a, b) => b.total - a.total || a.label.localeCompare(b.label));
258
- const groups = candidates.slice(0, MAX_COVERAGE_BAR_GROUPS);
227
+ const groups = candidates.slice(0, MAX_PROFILE_LISTING_BAR_GROUPS);
259
228
  const maximum = Math.max(1, ...groups.map((group) => group.total));
260
229
  const x = scaleLinear().domain([0, maximum]).range([210, 860]).clamp(true);
261
- const rowHeight = 31;
230
+ const rowHeight = 40;
262
231
  return {
263
232
  width: 900,
264
233
  height: Math.max(76, 40 + groups.length * rowHeight),
265
234
  groups: groups.map((group, index) => {
266
235
  let offset = 0;
267
- const segments = ['protected', 'registered', 'available', 'unknown'].map((state) => {
236
+ const segments = ['registered', 'available', 'unknown'].map((state) => {
268
237
  const value = group[state];
269
238
  const start = offset;
270
239
  offset += value;
@@ -116,28 +116,69 @@ export function projectTrendPoints(rawPoints) {
116
116
  .filter((point) => Boolean(point))
117
117
  .sort((a, b) => a.milliseconds - b.milliseconds || a.id.localeCompare(b.id));
118
118
  const points = candidates.slice(-MAX_TREND_POINTS);
119
- const x = scalePoint().domain(points.map((point) => point.id)).range([64, 850]).padding(0.45);
119
+ const firstMilliseconds = points[0]?.milliseconds ?? 0;
120
+ const latestMilliseconds = points.at(-1)?.milliseconds ?? firstMilliseconds;
121
+ const x = scaleLinear()
122
+ .domain(firstMilliseconds === latestMilliseconds
123
+ ? [firstMilliseconds - 1, latestMilliseconds + 1]
124
+ : [firstMilliseconds, latestMilliseconds])
125
+ .range([64, 850])
126
+ .clamp(true);
120
127
  const maximum = Math.max(1, ...points.map((point) => point.total));
121
128
  const y = scaleLinear().domain([0, maximum]).range([190, 24]).nice().clamp(true);
122
129
  const first = points[0] ?? null;
123
130
  const latest = points.at(-1) ?? null;
131
+ const projectedPoints = points.map((point, sequenceIndex) => ({
132
+ ...point,
133
+ sequenceIndex,
134
+ x: points.length > 1 ? x(point.milliseconds) : 450,
135
+ y: y(point.total),
136
+ addedY: y(point.added),
137
+ }));
138
+ // A capped point is a lower bound, so neither adjacent slope is measured.
139
+ // Retain only directly adjacent complete-to-complete segments and leave a
140
+ // visible gap around every capped point.
141
+ const segments = projectedPoints.slice(1).flatMap((to, index) => {
142
+ const from = projectedPoints[index];
143
+ return from && !from.partial && !to.partial ? [{
144
+ fromId: from.id,
145
+ toId: to.id,
146
+ x1: from.x,
147
+ y1: from.y,
148
+ x2: to.x,
149
+ y2: to.y,
150
+ }] : [];
151
+ });
152
+ const partialChecks = points.filter((point) => point.partial).length;
153
+ const hasPartialChecks = partialChecks > 0;
154
+ const summaryMetric = (value, lowerBound) => ({
155
+ value,
156
+ lowerBound,
157
+ label: lowerBound ? `At least ${value}` : String(value),
158
+ });
159
+ const peakTotal = points.reduce((peak, point) => Math.max(peak, point.total), 0);
160
+ const newlyObserved = points.reduce((total, point) => total + point.added, 0);
124
161
  return {
125
162
  width: 900,
126
163
  height: 225,
127
164
  maximum,
165
+ spacing: 'elapsed_time',
166
+ elapsed: {
167
+ firstAt: points[0]?.date ?? null,
168
+ latestAt: points.at(-1)?.date ?? null,
169
+ milliseconds: Math.max(0, latestMilliseconds - firstMilliseconds),
170
+ },
128
171
  ticks: y.ticks(4).map((value) => ({ value, y: y(value) })),
129
- points: points.map((point) => ({
130
- ...point,
131
- x: x(point.id) ?? 450,
132
- y: y(point.total),
133
- addedY: y(point.added),
134
- })),
172
+ points: projectedPoints,
173
+ segments,
135
174
  summary: {
136
- firstTotal: first?.total ?? 0,
137
- latestTotal: latest?.total ?? 0,
138
- peakTotal: points.reduce((peak, point) => Math.max(peak, point.total), 0),
139
- newlyObserved: points.reduce((total, point) => total + point.added, 0),
140
- partialChecks: points.filter((point) => point.partial).length,
175
+ first: summaryMetric(first?.total ?? 0, Boolean(first?.partial)),
176
+ latest: summaryMetric(latest?.total ?? 0, Boolean(latest?.partial)),
177
+ // Any capped total can exceed the displayed observed maximum.
178
+ peak: summaryMetric(peakTotal, hasPartialChecks),
179
+ // Every capped new-count is itself a lower bound, including observed 0.
180
+ newlyObserved: summaryMetric(newlyObserved, hasPartialChecks),
181
+ partialChecks,
141
182
  },
142
183
  truncated: candidates.length > points.length,
143
184
  };
@@ -1,6 +1,7 @@
1
1
  import { normalizeDomain } from "./case-model.js";
2
2
  export const WEBSITE_SNAPSHOT_SCHEMA = 'whoisleuth.website-profile-snapshots';
3
3
  export const WEBSITE_SNAPSHOT_SCHEMA_VERSION = 4;
4
+ export const SUPPORTED_WEBSITE_SNAPSHOT_SCHEMA_VERSIONS = Object.freeze([1, 2, 3, WEBSITE_SNAPSHOT_SCHEMA_VERSION]);
4
5
  export const MAX_WEBSITE_SNAPSHOTS = 60;
5
6
  export const MAX_WEBSITE_SNAPSHOTS_PER_DOMAIN = 12;
6
7
  export const MAX_WEBSITE_SNAPSHOT_STORE_BYTES = 512 * 1024;
@@ -188,6 +189,11 @@ export function normalizeWebsiteSnapshotStore(raw) {
188
189
  && Number(value?.version) > WEBSITE_SNAPSHOT_SCHEMA_VERSION) {
189
190
  throw new Error('This website-snapshot collection uses a newer schema and cannot be read safely.');
190
191
  }
192
+ if (value?.schema === WEBSITE_SNAPSHOT_SCHEMA
193
+ && Number.isSafeInteger(value.version)
194
+ && !SUPPORTED_WEBSITE_SNAPSHOT_SCHEMA_VERSIONS.includes(Number(value.version))) {
195
+ throw new Error('This website-snapshot collection uses an unsupported schema and cannot be read safely.');
196
+ }
191
197
  const sourceValues = Array.isArray(raw) ? raw : Array.isArray(value?.snapshots) ? value.snapshots : [];
192
198
  const snapshots = values(sourceValues, MAX_WEBSITE_SNAPSHOTS * 2, normalizeWebsiteProfileSnapshot);
193
199
  const perDomain = new Map();
@@ -1,7 +1,7 @@
1
1
  // Browser-local authenticated encryption for portable workspace archives.
2
2
  // This protects a downloaded file while it is locked. It does not encrypt the
3
3
  // active IndexedDB workspace or defend an unlocked page from same-origin code.
4
- import { MAX_WORKSPACE_ARCHIVE_BYTES, WORKSPACE_ARCHIVE_SCHEMA, WORKSPACE_ARCHIVE_VERSION, isSupportedWorkspaceArchiveVersion, readWorkspaceArchive, } from "./workspace-archive.js";
4
+ import { MAX_WORKSPACE_ARCHIVE_BYTES, WORKSPACE_ARCHIVE_SCHEMA, isSupportedWorkspaceArchiveVersion, readWorkspaceArchive, } from "./workspace-archive.js";
5
5
  export const ENCRYPTED_WORKSPACE_ARCHIVE_SCHEMA = 'whoisleuth.encrypted-workspace-archive';
6
6
  export const ENCRYPTED_WORKSPACE_ARCHIVE_VERSION = 1;
7
7
  export const WORKSPACE_ARCHIVE_PBKDF2_ITERATIONS = 600_000;
@@ -26,9 +26,6 @@ function hasExactKeys(value, expected) {
26
26
  const keys = Object.keys(value);
27
27
  return keys.length === expected.length && keys.every((key) => expected.includes(key));
28
28
  }
29
- function byteLength(value) {
30
- return encoder.encode(value).byteLength;
31
- }
32
29
  function arrayBuffer(bytes) {
33
30
  return bytes.slice().buffer;
34
31
  }
@@ -1,15 +1,15 @@
1
1
  // Pure, bounded workspace archive composition. Existing typed store models remain the
2
2
  // authority for normalization and merge semantics; this module only packages
3
3
  // their portable contracts, verifies integrity, and previews conflicts.
4
- import { buildCaseExport, CASE_SCHEMA_VERSION, enforceStoreBudget, mergeCases, } from "./case-model.js";
5
- import { assertBrandProfileStoreBudget, BRAND_PROFILE_SCHEMA, BRAND_PROFILE_SCHEMA_VERSION, buildBrandProfileExport, mergeBrandProfiles, } from "./brand-profile-model.js";
4
+ import { buildCaseExport, CASE_SCHEMA_VERSION, CASE_IMPORT_VERSIONS, enforceStoreBudget, mergeCases, } from "./case-model.js";
5
+ import { assertBrandProfileStoreBudget, BRAND_PROFILE_SCHEMA, BRAND_PROFILE_SCHEMA_VERSION, SUPPORTED_BRAND_PROFILE_SCHEMA_VERSIONS, buildBrandProfileExport, mergeBrandProfiles, normalizeBrandProfileId, } from "./brand-profile-model.js";
6
6
  import { assertCampaignStoreBudget, buildCampaignExport, CAMPAIGN_SCHEMA, CAMPAIGN_SCHEMA_VERSION, mergeCampaigns, } from "./campaign-model.js";
7
7
  import { assertWatchlistStoreBudget, buildWatchlistExport, mergeWatchlistStores, WATCHLIST_SCHEMA, WATCHLIST_SCHEMA_VERSION, } from "./watchlist-store.js";
8
- import { assertShortlistStoreBudget, buildShortlistExport, mergeShortlistStores, SHORTLIST_SCHEMA, SHORTLIST_SCHEMA_VERSION, } from "./shortlist-model.js";
8
+ import { assertShortlistStoreBudget, buildShortlistExport, mergeShortlistStores, SHORTLIST_SCHEMA, SHORTLIST_SCHEMA_VERSION, SUPPORTED_SHORTLIST_SCHEMA_VERSIONS, } from "./shortlist-model.js";
9
9
  import { assertDetectionRuleStoreBudget, buildDetectionRuleExport, DETECTION_RULE_SCHEMA, DETECTION_RULE_SCHEMA_VERSION, mergeDetectionRules, } from "./detection-rule-model.js";
10
10
  import { buildRelationshipObservationExport, mergeRelationshipObservations, RELATIONSHIP_OBSERVATION_SCHEMA, RELATIONSHIP_OBSERVATION_SCHEMA_VERSION, } from "./relationship-observation-model.js";
11
- import { BULK_SESSION_SCHEMA, BULK_SESSION_SCHEMA_VERSION, buildBulkSessionExport, enforceBulkSessionStoreBudget, mergeBulkSessions, } from "./bulk-session-model.js";
12
- import { WEBSITE_SNAPSHOT_SCHEMA, WEBSITE_SNAPSHOT_SCHEMA_VERSION, buildWebsiteSnapshotExport, mergeWebsiteSnapshots, } from "./website-snapshot-model.js";
11
+ import { BULK_SESSION_SCHEMA, BULK_SESSION_SCHEMA_VERSION, SUPPORTED_BULK_SESSION_SCHEMA_VERSIONS, buildBulkSessionExport, enforceBulkSessionStoreBudget, mergeBulkSessions, } from "./bulk-session-model.js";
12
+ import { WEBSITE_SNAPSHOT_SCHEMA, WEBSITE_SNAPSHOT_SCHEMA_VERSION, SUPPORTED_WEBSITE_SNAPSHOT_SCHEMA_VERSIONS, buildWebsiteSnapshotExport, mergeWebsiteSnapshots, } from "./website-snapshot-model.js";
13
13
  import { INVESTIGATION_TEMPLATE_SCHEMA, INVESTIGATION_TEMPLATE_VERSION, buildInvestigationTemplateExport, mergeInvestigationTemplates, } from "./investigation-template-model.js";
14
14
  import { BULK_REVIEW_SCHEMA, BULK_REVIEW_SCHEMA_VERSION, buildBulkReviewExport, mergeBulkReviewStores, } from "./bulk-review-model.js";
15
15
  export const WORKSPACE_ARCHIVE_SCHEMA = 'whoisleuth.workspace-archive';
@@ -40,11 +40,25 @@ export function isSupportedWorkspaceArchiveVersion(value) {
40
40
  && SUPPORTED_WORKSPACE_ARCHIVE_VERSIONS.some((version) => version === value);
41
41
  }
42
42
  const CONTROL_RE = /[\x00-\x1f\x7f]/;
43
- const SAFE_ID_RE = /^[A-Za-z0-9_-]{1,128}$/;
44
43
  const CHECKSUM_RE = /^sha256:[a-f0-9]{64}$/;
44
+ const WORKSPACE_ARCHIVE_ROOT_KEYS = Object.freeze({
45
+ 1: Object.freeze(['schema', 'version', 'generatedAt', 'manifest', 'sections', 'limitations']),
46
+ 2: Object.freeze(['schema', 'version', 'generatedAt', 'manifest', 'sections', 'limitations']),
47
+ 3: Object.freeze(['schema', 'version', 'generatedAt', 'manifest', 'sections', 'limitations']),
48
+ 4: Object.freeze(['schema', 'version', 'generatedAt', 'manifest', 'sections', 'limitations']),
49
+ 5: Object.freeze(['schema', 'version', 'generatedAt', 'manifest', 'sections', 'limitations']),
50
+ });
51
+ const WORKSPACE_ARCHIVE_MANIFEST_KEYS = Object.freeze(['sectionCount', 'totalRecords', 'sections']);
52
+ const WORKSPACE_ARCHIVE_MANIFEST_ENTRY_KEYS = Object.freeze(['id', 'schema', 'version', 'recordCount', 'bytes', 'checksum']);
45
53
  function record(value) {
46
54
  return value && typeof value === 'object' && !Array.isArray(value) ? value : null;
47
55
  }
56
+ function assertExactKeys(value, expected, label) {
57
+ const keys = Object.keys(value);
58
+ if (keys.length !== expected.length || keys.some((key) => !expected.includes(key))) {
59
+ throw new Error(`The workspace archive ${label} contains missing or undeclared fields.`);
60
+ }
61
+ }
48
62
  function byteLength(value) {
49
63
  return new TextEncoder().encode(value).byteLength;
50
64
  }
@@ -101,9 +115,7 @@ async function checksum(value, cryptoProvider = globalThis.crypto) {
101
115
  }
102
116
  function settingsDocument(input) {
103
117
  const profiles = Array.isArray(input.brandProfiles) ? input.brandProfiles : [];
104
- const requestedProfileId = typeof input.settings?.activeProfileId === 'string' && SAFE_ID_RE.test(input.settings.activeProfileId)
105
- ? input.settings.activeProfileId
106
- : '';
118
+ const requestedProfileId = normalizeBrandProfileId(input.settings?.activeProfileId) || '';
107
119
  const activeProfileId = profiles.some((profile) => profile?.id === requestedProfileId)
108
120
  ? requestedProfileId
109
121
  : '';
@@ -142,6 +154,7 @@ function objectCount(data, key) {
142
154
  const SECTION_DEFINITIONS = [
143
155
  {
144
156
  id: 'cases', label: 'Cases', schema: null, version: CASE_SCHEMA_VERSION,
157
+ supportedVersions: CASE_IMPORT_VERSIONS,
145
158
  count: (data) => arrayCount(data, 'cases'),
146
159
  merge: (local, data) => {
147
160
  const result = mergeCases(local.cases, data);
@@ -159,6 +172,7 @@ const SECTION_DEFINITIONS = [
159
172
  },
160
173
  {
161
174
  id: 'brandProfiles', label: 'Brand profiles', schema: BRAND_PROFILE_SCHEMA, version: BRAND_PROFILE_SCHEMA_VERSION,
175
+ supportedVersions: SUPPORTED_BRAND_PROFILE_SCHEMA_VERSIONS,
162
176
  count: (data) => arrayCount(data, 'profiles'),
163
177
  merge: (local, data, now) => {
164
178
  const result = mergeBrandProfiles(local.brandProfiles, data, { nowIso: now });
@@ -175,6 +189,7 @@ const SECTION_DEFINITIONS = [
175
189
  },
176
190
  {
177
191
  id: 'shortlist', label: 'Shortlist', schema: SHORTLIST_SCHEMA, version: SHORTLIST_SCHEMA_VERSION,
192
+ supportedVersions: SUPPORTED_SHORTLIST_SCHEMA_VERSIONS,
178
193
  count: (data) => arrayCount(data, 'entries'),
179
194
  merge: (local, data) => {
180
195
  const result = mergeShortlistStores(local.shortlist, data);
@@ -202,6 +217,7 @@ const SECTION_DEFINITIONS = [
202
217
  label: 'Saved Bulk sessions',
203
218
  schema: BULK_SESSION_SCHEMA,
204
219
  version: BULK_SESSION_SCHEMA_VERSION,
220
+ supportedVersions: SUPPORTED_BULK_SESSION_SCHEMA_VERSIONS,
205
221
  count: (data) => arrayCount(data, 'sessions'),
206
222
  merge: (local, data) => {
207
223
  const result = mergeBulkSessions(local.bulkSessions, data);
@@ -214,6 +230,7 @@ const SECTION_DEFINITIONS = [
214
230
  label: 'Website profile snapshots',
215
231
  schema: WEBSITE_SNAPSHOT_SCHEMA,
216
232
  version: WEBSITE_SNAPSHOT_SCHEMA_VERSION,
233
+ supportedVersions: SUPPORTED_WEBSITE_SNAPSHOT_SCHEMA_VERSIONS,
217
234
  count: (data) => arrayCount(data, 'snapshots'),
218
235
  merge: (local, data) => mergeWebsiteSnapshots(local.websiteSnapshots, data),
219
236
  },
@@ -342,11 +359,13 @@ export async function readWorkspaceArchive(raw, options = {}) {
342
359
  }
343
360
  const sourceVersion = value.version;
344
361
  const { bytes } = ensureArchiveBudget(value);
362
+ assertExactKeys(value, WORKSPACE_ARCHIVE_ROOT_KEYS[sourceVersion] ?? [], `version ${sourceVersion} envelope`);
345
363
  const manifest = record(value.manifest);
346
364
  const sectionValues = record(value.sections);
347
365
  if (!manifest || !sectionValues || !Array.isArray(manifest.sections)) {
348
366
  throw new Error('The workspace archive manifest is missing or malformed.');
349
367
  }
368
+ assertExactKeys(manifest, WORKSPACE_ARCHIVE_MANIFEST_KEYS, 'manifest');
350
369
  if (manifest.sections.length > MAX_WORKSPACE_ARCHIVE_SECTIONS) {
351
370
  throw new Error(`Workspace archives are limited to ${MAX_WORKSPACE_ARCHIVE_SECTIONS} sections.`);
352
371
  }
@@ -354,6 +373,10 @@ export async function readWorkspaceArchive(raw, options = {}) {
354
373
  const sections = [];
355
374
  let totalRecords = 0;
356
375
  for (const rawEntry of manifest.sections) {
376
+ const rawEntryRecord = record(rawEntry);
377
+ if (!rawEntryRecord)
378
+ throw new Error('The workspace archive manifest contains a malformed section entry.');
379
+ assertExactKeys(rawEntryRecord, WORKSPACE_ARCHIVE_MANIFEST_ENTRY_KEYS, 'manifest section entry');
357
380
  const entry = manifestEntry(rawEntry);
358
381
  if (!entry || seen.has(entry.id) || !Object.prototype.hasOwnProperty.call(sectionValues, entry.id)) {
359
382
  throw new Error('The workspace archive manifest contains an invalid, duplicate, or missing section.');
@@ -366,6 +389,21 @@ export async function readWorkspaceArchive(raw, options = {}) {
366
389
  if (await checksum(data, options.cryptoProvider) !== entry.checksum) {
367
390
  throw new Error(`${entry.id} failed its archive checksum check.`);
368
391
  }
392
+ const embedded = record(data);
393
+ const embeddedVersion = embedded?.version;
394
+ const embeddedSchema = embedded && Object.prototype.hasOwnProperty.call(embedded, 'schema')
395
+ ? embedded.schema
396
+ : null;
397
+ if (!embedded
398
+ || typeof embeddedVersion !== 'number'
399
+ || !Number.isSafeInteger(embeddedVersion)
400
+ || embeddedVersion < 1
401
+ || embeddedVersion > 1000
402
+ || (embeddedSchema !== null && typeof embeddedSchema !== 'string')
403
+ || embeddedVersion !== entry.version
404
+ || embeddedSchema !== entry.schema) {
405
+ throw new Error(`${entry.id} section contract does not match its archive manifest.`);
406
+ }
369
407
  const definition = DEFINITION_BY_ID.get(entry.id);
370
408
  let status = 'ready';
371
409
  let reason = '';
@@ -373,7 +411,7 @@ export async function readWorkspaceArchive(raw, options = {}) {
373
411
  status = 'unsupported';
374
412
  reason = 'This app does not recognise the archive section.';
375
413
  }
376
- else if (entry.version !== definition.version || entry.schema !== definition.schema) {
414
+ else if (!(definition.supportedVersions ?? [definition.version]).includes(entry.version) || entry.schema !== definition.schema) {
377
415
  status = 'unsupported';
378
416
  reason = entry.version > definition.version
379
417
  ? `This section uses newer schema ${entry.version}.`
@@ -406,15 +444,31 @@ export async function readWorkspaceArchive(raw, options = {}) {
406
444
  }
407
445
  function settingsPreview(data, local, mergedProfiles) {
408
446
  const value = record(data) || {};
409
- const requestedProfileId = typeof value.activeProfileId === 'string' ? value.activeProfileId : '';
410
- const activeProfileId = SAFE_ID_RE.test(requestedProfileId) ? requestedProfileId : '';
447
+ const rawActiveProfileId = value.activeProfileId;
448
+ const intentionalClear = rawActiveProfileId === '';
449
+ const normalizedActiveProfileId = normalizeBrandProfileId(rawActiveProfileId);
450
+ const validActiveProfileId = typeof rawActiveProfileId === 'string'
451
+ && rawActiveProfileId.length > 0
452
+ && normalizedActiveProfileId === rawActiveProfileId;
411
453
  const theme = normalizeTheme(value.theme);
454
+ const retainedActiveProfileId = normalizeBrandProfileId(local.settings?.activeProfileId) || '';
455
+ let activeProfileId = retainedActiveProfileId;
412
456
  let skipped = 0;
413
- if (activeProfileId && !mergedProfiles.some((profile) => profile?.id === activeProfileId))
414
- skipped++;
457
+ let reason = '';
458
+ if (!intentionalClear && !validActiveProfileId) {
459
+ skipped = 1;
460
+ reason = 'The imported active Brand Profile preference is missing or malformed. Existing active-profile context is preserved.';
461
+ }
462
+ else if (validActiveProfileId && !mergedProfiles.some((profile) => profile?.id === normalizedActiveProfileId)) {
463
+ skipped = 1;
464
+ reason = 'The imported active Brand Profile preference will be skipped because its identifier is not available in the selected Profile data or the current browser. Existing active-profile context is preserved.';
465
+ }
466
+ else {
467
+ activeProfileId = intentionalClear ? '' : normalizedActiveProfileId ?? retainedActiveProfileId;
468
+ }
415
469
  const updated = Number(theme !== normalizeTheme(local.settings?.theme)
416
- || (Boolean(activeProfileId) && activeProfileId !== local.settings?.activeProfileId && skipped === 0));
417
- return { added: 0, updated, skipped, settings: { activeProfileId: skipped ? '' : activeProfileId, theme } };
470
+ || (skipped === 0 && activeProfileId !== retainedActiveProfileId));
471
+ return { added: 0, updated, skipped, reason, settings: { activeProfileId, theme } };
418
472
  }
419
473
  /** Preview section-specific non-destructive merge outcomes without writing. */
420
474
  export async function previewWorkspaceArchive(raw, localInput, options = {}) {
@@ -422,6 +476,9 @@ export async function previewWorkspaceArchive(raw, localInput, options = {}) {
422
476
  const local = normalizedInput(localInput);
423
477
  const results = [];
424
478
  let mergedProfiles = local.brandProfiles;
479
+ const selectedIds = options.selectedSectionIds
480
+ ? new Set(options.selectedSectionIds.slice(0, MAX_WORKSPACE_ARCHIVE_SECTIONS))
481
+ : null;
425
482
  for (const section of archive.sections) {
426
483
  if (section.status !== 'ready') {
427
484
  results.push({ ...section, added: 0, updated: 0, skipped: section.recordCount, selected: false });
@@ -436,17 +493,21 @@ export async function previewWorkspaceArchive(raw, localInput, options = {}) {
436
493
  : definition.merge
437
494
  ? definition.merge(local, section.data, archive.generatedAt)
438
495
  : { added: 0, updated: 0, skipped: section.recordCount };
439
- if (definition.id === 'brandProfiles' && result.profiles)
496
+ const selected = selectedIds ? selectedIds.has(section.id) : true;
497
+ if (definition.id === 'brandProfiles' && selected && result.profiles)
440
498
  mergedProfiles = result.profiles;
441
499
  results.push({
442
500
  ...section,
443
501
  status: 'ready',
444
- reason: '',
502
+ reason: definition.id === 'settings' && selected && (result.skipped ?? 0) > 0
503
+ ? result.reason ?? 'The imported workspace preference was skipped. Existing browser-local context is preserved.'
504
+ : '',
445
505
  added: result.added ?? 0,
446
506
  updated: result.updated ?? 0,
447
507
  skipped: result.skipped ?? 0,
448
508
  pruned: result.pruned ?? 0,
449
- selected: true,
509
+ brandProfileReferencesOmitted: result.brandProfileReferencesOmitted ?? 0,
510
+ selected,
450
511
  normalizedSettings: result.settings || null,
451
512
  });
452
513
  }
@@ -465,6 +526,31 @@ export async function previewWorkspaceArchive(raw, localInput, options = {}) {
465
526
  });
466
527
  }
467
528
  }
529
+ const profileCollision = results.find((section) => section.id === 'brandProfiles'
530
+ && section.status === 'blocked'
531
+ && /reuses one exact identifier for different normalised profile names/iu.test(section.reason));
532
+ if (profileCollision) {
533
+ for (const dependentId of ['cases', 'settings']) {
534
+ const dependentIndex = results.findIndex((section) => section.id === dependentId && section.status === 'ready');
535
+ const dependentSection = results[dependentIndex];
536
+ if (dependentIndex < 0 || !dependentSection)
537
+ continue;
538
+ results[dependentIndex] = {
539
+ ...dependentSection,
540
+ status: 'blocked',
541
+ reason: dependentId === 'cases'
542
+ ? 'Cases were not selected because a Brand Profile identifier collision must be resolved before their opaque references can be imported safely.'
543
+ : 'Workspace settings were not selected because an active Brand Profile preference could otherwise bind to a different local profile that reuses the same identifier.',
544
+ added: 0,
545
+ updated: 0,
546
+ skipped: dependentSection.recordCount,
547
+ pruned: 0,
548
+ brandProfileReferencesOmitted: 0,
549
+ selected: false,
550
+ normalizedSettings: dependentId === 'settings' ? null : dependentSection.normalizedSettings ?? null,
551
+ };
552
+ }
553
+ }
468
554
  return {
469
555
  ...archive,
470
556
  sections: results,
@@ -0,0 +1,3 @@
1
+ import packageDocument from '../package.json' with { type: 'json' };
2
+ import { normalizeBoundedSemanticVersion } from "./semantic-version.mjs";
3
+ export const WHOISLEUTH_APPLICATION_VERSION = normalizeBoundedSemanticVersion(packageDocument.version, 'Application manifest');