@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.
- package/DISCLOSURE +7 -3
- package/NOTICE +5 -4
- package/README.md +67 -10
- package/bin/whoisleuth.mjs +22 -9
- package/cli/arguments.mjs +256 -9
- package/cli/artifact-structure.mjs +1833 -0
- package/cli/artifact-verify.mjs +242 -49
- package/cli/bounded-json.mjs +1 -0
- package/cli/bulk-checkpoint.mjs +67 -31
- package/cli/bulk-command-runner.mjs +16 -6
- package/cli/bulk.mjs +18 -7
- package/cli/case-pack.mjs +819 -34
- package/cli/command-reference.mjs +48 -24
- package/cli/completion.mjs +50 -11
- package/cli/config-profile.mjs +15 -12
- package/cli/ct-event-intake.mjs +32 -2
- package/cli/discovery-observation-snapshot.mjs +8 -14
- package/cli/discovery-scan-command-runner.mjs +18 -6
- package/cli/discovery-snapshot.mjs +8 -15
- package/cli/discovery-workflow.mjs +2 -3
- package/cli/doctor.mjs +2 -2
- package/cli/domain-control-monitor.mjs +21 -15
- package/cli/domain-control-observations.mjs +25 -2
- package/cli/errors.mjs +7 -1
- package/cli/evidence-command-runner.mjs +17 -11
- package/cli/evidence-signing.mjs +73 -45
- package/cli/export-evidence.mjs +11 -4
- package/cli/formatters/html.mjs +3 -3
- package/cli/formatters/json.mjs +12 -4
- package/cli/formatters/terminal.mjs +4 -1
- package/cli/http.mjs +2 -1
- package/cli/input.mjs +57 -0
- package/cli/interchange-report.mjs +226 -0
- package/cli/investigation-manifest.mjs +16 -17
- package/cli/investigation-run.mjs +71 -23
- package/cli/local-mmdb-review.mjs +45 -14
- package/cli/lookup-command-runner.mjs +2 -2
- package/cli/lookup-diff.mjs +9 -0
- package/cli/lookup-reconcile.mjs +1 -1
- package/cli/mail-review.mjs +5 -5
- package/cli/manual.mjs +1 -1
- package/cli/network-command-runner.mjs +78 -0
- package/cli/offline-evidence-review.mjs +28 -8
- package/cli/page-compare.mjs +8 -5
- package/cli/registry-cohort.mjs +385 -61
- package/cli/registry-fixture-scaffold.mjs +4 -3
- package/cli/registry-support.mjs +2 -1
- package/cli/retained-artifact-diff.mjs +376 -0
- package/cli/risk-calibration.mjs +7 -5
- package/cli/runner.mjs +150 -177
- package/cli/saved-lookup.mjs +26 -3
- package/cli/sharing-review.mjs +12 -6
- package/docs/cli-reference.md +467 -74
- package/docs/cli.md +25 -5
- package/frontend/src/lib/analysis/acquisition-decision-packet.js +4 -4
- package/frontend/src/lib/analysis/acquisition-due-diligence.js +1 -1
- package/frontend/src/lib/analysis/artifact-integrity.js +42 -0
- package/frontend/src/lib/analysis/brand-profile-model.js +33 -7
- package/frontend/src/lib/analysis/bulk-domain-comparison.js +25 -5
- package/frontend/src/lib/analysis/bulk-mail-exposure.js +66 -15
- package/frontend/src/lib/analysis/bulk-retry-plan.js +1 -1
- package/frontend/src/lib/analysis/bulk-review-export.js +7 -6
- package/frontend/src/lib/analysis/bulk-review-model.js +3 -2
- package/frontend/src/lib/analysis/bulk-session-model.js +192 -26
- package/frontend/src/lib/analysis/bulk-sort.js +4 -0
- package/frontend/src/lib/analysis/case-brand-profile-references.js +85 -0
- package/frontend/src/lib/analysis/case-evidence-model.js +9 -1
- package/frontend/src/lib/analysis/case-investigation-branch-model.js +134 -0
- package/frontend/src/lib/analysis/case-migration-model.js +83 -31
- package/frontend/src/lib/analysis/case-record-contracts.js +4 -2
- package/frontend/src/lib/analysis/case-record-core.js +1 -1
- package/frontend/src/lib/analysis/case-record-model.js +2 -0
- package/frontend/src/lib/analysis/case-record-operations.js +43 -13
- package/frontend/src/lib/analysis/case-report.js +39 -6
- package/frontend/src/lib/analysis/case-response-model.js +66 -7
- package/frontend/src/lib/analysis/case-response-packet.js +15 -5
- package/frontend/src/lib/analysis/comparison-ledger-bulk.js +366 -0
- package/frontend/src/lib/analysis/comparison-ledger-contract.js +241 -0
- package/frontend/src/lib/analysis/comparison-ledger-json.js +79 -0
- package/frontend/src/lib/analysis/comparison-ledger-serialization.js +51 -0
- package/frontend/src/lib/analysis/domain-control-manifest-core.js +145 -0
- package/frontend/src/lib/analysis/domain-control-records.js +88 -0
- package/frontend/src/lib/analysis/external-findings-import.js +70 -6
- package/frontend/src/lib/analysis/investigation-capsule.js +47 -13
- package/frontend/src/lib/analysis/investigation-guide.js +52 -10
- package/frontend/src/lib/analysis/investigation-template-model.js +9 -6
- package/frontend/src/lib/analysis/lookup-asset-graph.js +8 -8
- package/frontend/src/lib/analysis/lookup-claim-passport.js +236 -0
- package/frontend/src/lib/analysis/lookup-claim-readiness.js +280 -0
- package/frontend/src/lib/analysis/lookup-decision-support.js +63 -8
- package/frontend/src/lib/analysis/lookup-presentation.js +70 -14
- package/frontend/src/lib/analysis/lookup-source-refresh.js +36 -63
- package/frontend/src/lib/analysis/opaque-reference-id.js +9 -0
- package/frontend/src/lib/analysis/utils.js +27 -4
- package/frontend/src/lib/analysis/visualization-matrix.js +71 -28
- package/frontend/src/lib/analysis/visualization-models.js +63 -94
- package/frontend/src/lib/analysis/visualization-time-series.js +53 -12
- package/frontend/src/lib/analysis/website-snapshot-model.js +6 -0
- package/frontend/src/lib/analysis/workspace-archive-crypto.js +1 -4
- package/frontend/src/lib/analysis/workspace-archive.js +105 -19
- package/lib/application-version.mjs +3 -0
- package/lib/availability.mjs +14 -12
- package/lib/bounded-file.mjs +82 -0
- package/lib/bounded-json-response.mjs +170 -0
- package/lib/bounded-json.mjs +249 -0
- package/lib/browser-library-profile.mjs +167 -38
- package/lib/certificate-extension-profile.mjs +2 -1
- package/lib/cryptographic-assurance.mjs +155 -0
- package/lib/ct-search.mjs +87 -47
- package/lib/dns-intelligence.mjs +3 -2
- package/lib/dns-resolver-selection.mjs +6 -4
- package/lib/dnssec-chain-validation.mjs +1053 -0
- package/lib/dnssec-evidence-validation.mjs +10 -3
- package/lib/domain-change-packet.mjs +7 -5
- package/lib/domain-control-manifest.mjs +17 -129
- package/lib/domain-posture.mjs +7 -4
- package/lib/evidence-export.mjs +751 -78
- package/lib/evidence-report-markdown.mjs +3 -0
- package/lib/evidence-report.mjs +5 -0
- package/lib/favicon.mjs +8 -5
- package/lib/http-evidence-bounds.mjs +3 -0
- package/lib/http-intelligence.mjs +2 -5
- package/lib/interchange-fidelity-registry.mjs +122 -0
- package/lib/local-geoip-evidence.mjs +2 -1
- package/lib/lookup-cache.mjs +5 -1
- package/lib/lookup-network-evidence-bounds.mjs +9 -0
- package/lib/lookup-response-contract.mjs +691 -3
- package/lib/lookup.mjs +2 -2
- package/lib/observation.mjs +7 -6
- package/lib/outbound-identity.mjs +2 -3
- package/lib/perceptual-hash.mjs +12 -4
- package/lib/portable-generator.mjs +21 -0
- package/lib/provider-policy-admission.mjs +9 -4
- package/lib/rdap-normalization.mjs +18 -5
- package/lib/rdap-registrar.mjs +5 -1
- package/lib/rdap-transport.mjs +6 -6
- package/lib/registry-capabilities.mjs +3 -0
- package/lib/registry-capability-catalogue.mjs +12 -2
- package/lib/registry-comparison.mjs +28 -11
- package/lib/risk-calibration-summary.mjs +338 -0
- package/lib/rpki-evidence.mjs +9 -8
- package/lib/safe-fetch.mjs +96 -42
- package/lib/security-txt.mjs +26 -3
- package/lib/service-binding-dns.mjs +6 -1
- package/lib/smtp-transport-review.mjs +784 -0
- package/lib/sslbl-certificates.generated.mjs +136 -134
- package/lib/threat-intelligence-runtime.mjs +4 -13
- package/lib/threat-intelligence-types.mjs +18 -1
- package/lib/tls-intelligence.mjs +11 -5
- package/lib/tlsa-evidence.mjs +37 -7
- package/lib/website-technology.mjs +2 -6
- package/package.json +1 -1
- 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
|
|
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 {
|
|
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([
|
|
183
|
-
const dayScale = scaleBand().domain([0, 1, 2, 3, 4, 5, 6]).range([
|
|
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:
|
|
204
|
-
height:
|
|
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,
|
|
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
|
|
208
|
+
export function projectProfileListingBars(rawGroups) {
|
|
241
209
|
const candidates = (Array.isArray(rawGroups) ? rawGroups : [])
|
|
242
210
|
.map((group, index) => {
|
|
243
|
-
const
|
|
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
|
-
...
|
|
253
|
-
total
|
|
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,
|
|
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 =
|
|
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 = ['
|
|
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
|
|
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:
|
|
130
|
-
|
|
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
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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,
|
|
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 =
|
|
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 (
|
|
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
|
|
410
|
-
const
|
|
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
|
-
|
|
414
|
-
|
|
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
|
-
|| (
|
|
417
|
-
return { added: 0, updated, skipped, settings: { activeProfileId
|
|
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
|
-
|
|
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
|
-
|
|
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,
|