@slicedearth/whoisleuth-cli 1.45.0 → 2.0.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.
Files changed (268) hide show
  1. package/DISCLOSURE +7 -3
  2. package/NOTICE +5 -4
  3. package/README.md +74 -124
  4. package/SECURITY.md +6 -3
  5. package/bin/whoisleuth.mjs +7 -4
  6. package/cli/archive-inspect.mjs +28 -10
  7. package/cli/arguments.mjs +450 -173
  8. package/cli/artifact-structure.mjs +2507 -0
  9. package/cli/artifact-verify.mjs +286 -64
  10. package/cli/bounded-json.mjs +1 -0
  11. package/cli/bulk-checkpoint.mjs +35 -23
  12. package/cli/bulk-command-runner.mjs +2 -5
  13. package/cli/bulk.mjs +9 -2
  14. package/cli/case-pack.mjs +435 -46
  15. package/cli/ci-report.mjs +6 -1
  16. package/cli/collection-preflight.mjs +2 -2
  17. package/cli/command-catalogue.mjs +12 -3
  18. package/cli/command-reference.mjs +948 -106
  19. package/cli/completion.mjs +504 -218
  20. package/cli/config-profile.mjs +13 -3
  21. package/cli/ct-event-intake.mjs +32 -2
  22. package/cli/discover.mjs +7 -1
  23. package/cli/discovery-observation-snapshot.mjs +24 -28
  24. package/cli/discovery-scan-command-runner.mjs +13 -14
  25. package/cli/discovery-scan.mjs +17 -3
  26. package/cli/discovery-snapshot.mjs +15 -7
  27. package/cli/discovery-workflow.mjs +2 -3
  28. package/cli/doctor.mjs +2 -2
  29. package/cli/domain-control-monitor.mjs +215 -36
  30. package/cli/domain-control-observations.mjs +34 -17
  31. package/cli/errors.mjs +8 -3
  32. package/cli/evidence-command-runner.mjs +17 -11
  33. package/cli/evidence-signing.mjs +68 -50
  34. package/cli/export-evidence.mjs +11 -4
  35. package/cli/external-observation-mapping.mjs +3 -3
  36. package/cli/formatters/html.mjs +3 -3
  37. package/cli/formatters/json.mjs +20 -32
  38. package/cli/formatters/terminal.mjs +586 -22
  39. package/cli/input.mjs +62 -0
  40. package/cli/interactive-launcher.mjs +226 -0
  41. package/cli/interchange-report.mjs +226 -0
  42. package/cli/investigation-manifest.mjs +16 -17
  43. package/cli/investigation-plan.mjs +167 -16
  44. package/cli/investigation-run.mjs +80 -24
  45. package/cli/local-mmdb-review.mjs +45 -14
  46. package/cli/lookalike-calibration.mjs +3 -1
  47. package/cli/lookup-brief.mjs +13 -8
  48. package/cli/lookup-browser.mjs +844 -0
  49. package/cli/lookup-command-runner.mjs +96 -33
  50. package/cli/lookup-diff.mjs +11 -2
  51. package/cli/lookup-plan.mjs +1 -0
  52. package/cli/mail-review.mjs +206 -94
  53. package/cli/manual.mjs +5 -5
  54. package/cli/network-command-runner.mjs +78 -0
  55. package/cli/offline-evidence-review.mjs +45 -14
  56. package/cli/open-asset-model-bridge.mjs +1 -1
  57. package/cli/output-file.mjs +70 -14
  58. package/cli/page-compare.mjs +18 -10
  59. package/cli/progress-events.mjs +2 -1
  60. package/cli/progress.mjs +3 -3
  61. package/cli/registry-cohort.mjs +343 -61
  62. package/cli/registry-doctor.mjs +67 -28
  63. package/cli/registry-support.mjs +2 -1
  64. package/cli/retained-artifact-diff.mjs +375 -0
  65. package/cli/risk-calibration.mjs +89 -40
  66. package/cli/runner.mjs +322 -246
  67. package/cli/saved-lookup.mjs +439 -49
  68. package/cli/sharing-review.mjs +18 -7
  69. package/cli/source-reliability.mjs +75 -36
  70. package/cli/terminal-presentation.mjs +43 -18
  71. package/docs/cli-reference.md +125 -1334
  72. package/docs/cli.md +87 -236
  73. package/frontend/src/lib/analysis/domain-control-manifest-core.js +3 -0
  74. package/frontend/src/lib/analysis/domain-control-records.js +3 -0
  75. package/lib/application-version.mjs +3 -0
  76. package/lib/availability.mjs +72 -52
  77. package/lib/bounded-contract-normalizers.mjs +2 -4
  78. package/lib/bounded-file.mjs +91 -4
  79. package/lib/bounded-json.mjs +287 -0
  80. package/lib/browser-library-profile.mjs +1 -1
  81. package/lib/certificate-extension-profile.mjs +2 -1
  82. package/lib/classify.mjs +56 -5
  83. package/lib/client-behavior-profile.mjs +1 -1
  84. package/lib/credential-surface-profile.mjs +1 -1
  85. package/lib/cryptographic-assurance.mjs +173 -0
  86. package/lib/ct-query.mjs +3 -3
  87. package/lib/ct-search.mjs +120 -64
  88. package/lib/distributed-operation-budget.mjs +4 -2
  89. package/lib/dns-delegation-health.mjs +9 -6
  90. package/lib/dns-intelligence.mjs +4 -3
  91. package/lib/dns-resolver-selection.mjs +6 -4
  92. package/lib/dnssec-chain-validation.mjs +1052 -0
  93. package/lib/dnssec-evidence-validation.mjs +23 -10
  94. package/lib/domain-assurance.mjs +12 -12
  95. package/lib/domain-change-packet.mjs +7 -5
  96. package/lib/domain-control-flight-recorder.mjs +442 -78
  97. package/lib/domain-control-manifest.mjs +314 -171
  98. package/lib/domain-posture-analysis.mjs +5 -2
  99. package/lib/domain-posture.mjs +7 -4
  100. package/lib/encrypted-dns-contract.mjs +2 -3
  101. package/lib/evidence-export.mjs +1243 -88
  102. package/lib/evidence-report-markdown.mjs +3 -0
  103. package/lib/evidence-report.mjs +5 -0
  104. package/lib/external-intelligence-risk.mjs +26 -10
  105. package/lib/favicon.mjs +8 -5
  106. package/lib/feature-policy.mjs +1 -1
  107. package/lib/homepage-metadata-contract.mjs +290 -0
  108. package/lib/hostname.mjs +1 -1
  109. package/lib/html-signals.mjs +89 -5
  110. package/lib/http-evidence-bounds.mjs +3 -0
  111. package/lib/http-intelligence.mjs +332 -6
  112. package/lib/interchange-fidelity-registry.mjs +124 -0
  113. package/lib/local-geoip-evidence.mjs +2 -1
  114. package/lib/lookup-cache.mjs +6 -4
  115. package/lib/lookup-network-evidence-bounds.mjs +9 -0
  116. package/lib/lookup.mjs +27 -4
  117. package/lib/observed-network-context.mjs +1 -1
  118. package/lib/outbound-identity.mjs +2 -3
  119. package/lib/page-fingerprints.mjs +2 -2
  120. package/lib/page-role-profile.mjs +1 -1
  121. package/lib/perceptual-hash.mjs +12 -4
  122. package/lib/portable-generator.mjs +29 -0
  123. package/lib/provider-policy-admission.mjs +11 -4
  124. package/lib/rdap-bootstrap.mjs +76 -16
  125. package/lib/rdap-client.mjs +20 -12
  126. package/lib/rdap-normalization.mjs +324 -80
  127. package/lib/rdap-registrar.mjs +5 -1
  128. package/lib/rdap-transport.mjs +2 -2
  129. package/lib/rdap-validation.mjs +65 -14
  130. package/lib/rdap.mjs +2 -2
  131. package/lib/registrable-domain.mjs +36 -0
  132. package/lib/registry-capabilities.mjs +3 -0
  133. package/lib/registry-capability-catalogue.mjs +12 -2
  134. package/lib/registry-comparison.mjs +28 -11
  135. package/lib/registry-insights.mjs +7 -0
  136. package/lib/response-policy.mjs +27 -4
  137. package/lib/risk-calibration-summary.mjs +340 -0
  138. package/lib/risk-scoring.mjs +4 -3
  139. package/lib/rpki-evidence.mjs +9 -8
  140. package/lib/safe-fetch.mjs +121 -50
  141. package/lib/security-txt.mjs +27 -4
  142. package/lib/service-binding-dns.mjs +6 -1
  143. package/lib/smtp-transport-review.mjs +787 -0
  144. package/lib/sslbl-certificates.generated.mjs +136 -134
  145. package/lib/sslbl-intelligence.mjs +6 -8
  146. package/lib/static-html-analysis.mjs +301 -4
  147. package/lib/structured-data-identity.mjs +51 -19
  148. package/lib/threat-intelligence-fixtures.mjs +2 -4
  149. package/lib/threat-intelligence-manifests.mjs +2 -7
  150. package/lib/threat-intelligence-runtime.mjs +5 -15
  151. package/lib/threat-intelligence-types.mjs +21 -1
  152. package/lib/threatfox-intelligence.mjs +7 -3
  153. package/lib/tls-intelligence.mjs +15 -9
  154. package/lib/tlsa-evidence.mjs +37 -7
  155. package/lib/urlhaus-intelligence.mjs +4 -2
  156. package/lib/urlscan-intelligence.mjs +6 -5
  157. package/lib/website-security-posture.mjs +1 -1
  158. package/lib/website-technology.mjs +1 -1
  159. package/lib/whois-authority.mjs +43 -8
  160. package/lib/whois-chain.mjs +25 -3
  161. package/lib/whois-normalization.mjs +3 -1
  162. package/lib/whois-transport.mjs +12 -3
  163. package/lib/zone-intent-review.mjs +1 -1
  164. package/package.json +12 -4
  165. package/packages/cases/case-brand-profile-references.mjs +85 -0
  166. package/{frontend/src/lib/analysis/case-evidence-model.js → packages/cases/case-evidence-model.mjs} +43 -14
  167. package/packages/cases/case-investigation-branch-model.mjs +132 -0
  168. package/packages/cases/case-migration-model.mjs +476 -0
  169. package/{frontend/src/lib/analysis/case-model.js → packages/cases/case-model.mjs} +3 -3
  170. package/{frontend/src/lib/analysis/case-record-contracts.js → packages/cases/case-record-contracts.mjs} +3 -19
  171. package/{frontend/src/lib/analysis/case-record-core.js → packages/cases/case-record-core.mjs} +58 -31
  172. package/packages/cases/case-record-model.mjs +9 -0
  173. package/{frontend/src/lib/analysis/case-record-operations.js → packages/cases/case-record-operations.mjs} +106 -33
  174. package/{frontend/src/lib/analysis/case-report.js → packages/cases/case-report.mjs} +143 -16
  175. package/packages/cases/case-response-model.mjs +1604 -0
  176. package/packages/cases/case-response-packet.mjs +1312 -0
  177. package/{frontend/src/lib/analysis/case-storage-model.js → packages/cases/case-storage-model.mjs} +2 -2
  178. package/{frontend/src/lib/analysis/http-summary.js → packages/cases/http-summary.mjs} +1 -1
  179. package/packages/cases/opaque-reference-id.mjs +9 -0
  180. package/packages/comparison/comparison-ledger-bulk.mjs +366 -0
  181. package/packages/comparison/comparison-ledger-contract.mjs +241 -0
  182. package/packages/comparison/comparison-ledger-json.mjs +80 -0
  183. package/packages/comparison/comparison-ledger-serialization.mjs +52 -0
  184. package/packages/comparison/favicon-similarity.mjs +78 -0
  185. package/{frontend/src/lib/analysis/page-similarity.js → packages/comparison/page-similarity.mjs} +4 -3
  186. package/{frontend/src/lib/analysis/relationship-evidence.js → packages/comparison/relationship-evidence.mjs} +6 -7
  187. package/packages/contracts/analyst-review-state-contract.mjs +61 -0
  188. package/packages/contracts/analyst-review-state.mjs +50 -0
  189. package/packages/contracts/case-portability.mjs +1310 -0
  190. package/packages/contracts/cli-command-catalogue.mjs +2 -0
  191. package/packages/contracts/cli-lookup.mjs +394 -0
  192. package/packages/contracts/domain-control-flight-recorder.mjs +461 -0
  193. package/packages/contracts/domain-control-manifest.mjs +1027 -0
  194. package/packages/contracts/domain-control-monitor.mjs +310 -0
  195. package/packages/contracts/domain-control-review.mjs +828 -0
  196. package/packages/contracts/domain-name.mjs +4 -0
  197. package/packages/contracts/external-observation-interchange.mjs +224 -0
  198. package/packages/contracts/extracted-domain-lifecycle.mjs +218 -0
  199. package/packages/contracts/investigation-portability.mjs +331 -0
  200. package/packages/contracts/offline-comparison.mjs +160 -0
  201. package/packages/contracts/risk-calibration.mjs +1288 -0
  202. package/packages/contracts/schema-compatibility.mjs +71 -0
  203. package/packages/contracts/schema-lifecycle.mjs +1702 -0
  204. package/packages/contracts/sslbl-snapshot.mjs +17 -0
  205. package/packages/contracts/workspace-portability.mjs +1347 -0
  206. package/packages/evidence/artifact-integrity.mjs +65 -0
  207. package/packages/evidence/decision-fact.mjs +735 -0
  208. package/packages/evidence/domain-control-runtime.mjs +547 -0
  209. package/packages/evidence/domain-name.mjs +41 -0
  210. package/packages/evidence/observation.mjs +384 -0
  211. package/{frontend/src/lib/analysis/external-findings-import.js → packages/interchange/external-findings-import.mjs} +86 -20
  212. package/packages/interchange/retained-text.mjs +8 -0
  213. package/{frontend/src/lib/analysis/acquisition-decision-packet.js → packages/investigation/acquisition-decision-packet.mjs} +11 -8
  214. package/{frontend/src/lib/analysis/bulk-domain-comparison.js → packages/investigation/bulk-domain-comparison.mjs} +28 -9
  215. package/{frontend/src/lib/analysis/bulk-mail-exposure.js → packages/investigation/bulk-mail-exposure.mjs} +67 -18
  216. package/{frontend/src/lib/analysis/bulk-review-export.js → packages/investigation/bulk-review-export.mjs} +10 -9
  217. package/packages/investigation/bulk-review-policy.mjs +1 -0
  218. package/{frontend/src/lib/analysis/investigation-capsule.js → packages/investigation/investigation-capsule.mjs} +50 -18
  219. package/packages/investigation/lookup-artefact-inputs.mjs +1 -0
  220. package/{frontend/src/lib/analysis/lookup-asset-graph.js → packages/investigation/lookup-asset-graph.mjs} +4 -2
  221. package/packages/investigation/lookup-claim-passport.mjs +232 -0
  222. package/packages/investigation/lookup-claim-readiness.mjs +287 -0
  223. package/packages/investigation/lookup-investigation-brief.mjs +172 -0
  224. package/packages/monitoring/analyst-review-state.mjs +680 -0
  225. package/{frontend/src/lib/analysis/brand-profile-model.js → packages/workspace/brand-profile-model.mjs} +97 -50
  226. package/{frontend/src/lib/analysis/bulk-review-model.js → packages/workspace/bulk-review-model.mjs} +15 -15
  227. package/{frontend/src/lib/analysis/bulk-session-model.js → packages/workspace/bulk-session-model.mjs} +179 -39
  228. package/{frontend/src/lib/analysis/bulk-sort.js → packages/workspace/bulk-sort.mjs} +12 -5
  229. package/{frontend/src/lib/analysis/campaign-model.js → packages/workspace/campaign-model.mjs} +20 -22
  230. package/{frontend/src/lib/analysis/detection-rule-model.js → packages/workspace/detection-rule-model.mjs} +15 -17
  231. package/packages/workspace/hostile-input.mjs +172 -0
  232. package/{frontend/src/lib/analysis/investigation-guide.js → packages/workspace/investigation-guide.mjs} +51 -46
  233. package/{frontend/src/lib/analysis/investigation-template-model.js → packages/workspace/investigation-template-model.mjs} +15 -15
  234. package/{frontend/src/lib/analysis/page-baseline.js → packages/workspace/page-baseline.mjs} +6 -13
  235. package/{frontend/src/lib/analysis/relationship-observation-model.js → packages/workspace/relationship-observation-model.mjs} +13 -17
  236. package/{frontend/src/lib/analysis/shortlist-model.js → packages/workspace/shortlist-model.mjs} +20 -18
  237. package/{frontend/src/lib/analysis/watchlist-history.js → packages/workspace/watchlist-history.mjs} +9 -16
  238. package/{frontend/src/lib/analysis/watchlist-store.js → packages/workspace/watchlist-store.mjs} +24 -17
  239. package/{frontend/src/lib/analysis/website-snapshot-model.js → packages/workspace/website-snapshot-model.mjs} +18 -12
  240. package/{frontend/src/lib/analysis/workspace-archive-crypto.js → packages/workspace/workspace-archive-crypto.mjs} +49 -19
  241. package/{frontend/src/lib/analysis/workspace-archive.js → packages/workspace/workspace-archive.mjs} +195 -84
  242. package/third-party-notices.txt +112 -5834
  243. package/frontend/src/lib/analysis/artifact-integrity.js +0 -21
  244. package/frontend/src/lib/analysis/bulk-retry-plan.js +0 -79
  245. package/frontend/src/lib/analysis/bulk-source-coverage.js +0 -76
  246. package/frontend/src/lib/analysis/case-migration-model.js +0 -253
  247. package/frontend/src/lib/analysis/case-record-model.js +0 -7
  248. package/frontend/src/lib/analysis/case-response-model.js +0 -669
  249. package/frontend/src/lib/analysis/case-response-packet.js +0 -573
  250. package/frontend/src/lib/analysis/lookup-decision-support.js +0 -487
  251. package/frontend/src/lib/analysis/lookup-investigation-brief.js +0 -153
  252. package/frontend/src/lib/analysis/lookup-presentation.js +0 -58
  253. package/frontend/src/lib/analysis/lookup-response.js +0 -2
  254. package/frontend/src/lib/analysis/lookup-source-refresh.js +0 -277
  255. package/frontend/src/lib/analysis/lookup-summary-model.js +0 -398
  256. package/frontend/src/lib/analysis/scoring.js +0 -55
  257. package/frontend/src/lib/analysis/utils.js +0 -232
  258. package/frontend/src/lib/analysis/visualization-bounds.js +0 -26
  259. package/frontend/src/lib/analysis/visualization-force-graph.js +0 -352
  260. package/frontend/src/lib/analysis/visualization-matrix.js +0 -88
  261. package/frontend/src/lib/analysis/visualization-models.js +0 -369
  262. package/frontend/src/lib/analysis/visualization-time-series.js +0 -144
  263. package/lib/bounded-json-response.mjs +0 -157
  264. package/lib/lookup-response-contract.mjs +0 -346
  265. package/lib/observation.mjs +0 -114
  266. /package/{frontend/src/lib/analysis/acquisition-due-diligence.js → packages/investigation/acquisition-due-diligence.mjs} +0 -0
  267. /package/{frontend/src/lib/analysis/evidence-coverage-ledger.js → packages/investigation/evidence-coverage-ledger.mjs} +0 -0
  268. /package/{frontend/src/lib/analysis/dns-record-normalization.js → packages/workspace/dns-record-normalization.mjs} +0 -0
@@ -1,58 +1,44 @@
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, normalizeBrandProfileId, } from "./brand-profile-model.js";
6
- import { assertCampaignStoreBudget, buildCampaignExport, CAMPAIGN_SCHEMA, CAMPAIGN_SCHEMA_VERSION, mergeCampaigns, } from "./campaign-model.js";
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";
9
- import { assertDetectionRuleStoreBudget, buildDetectionRuleExport, DETECTION_RULE_SCHEMA, DETECTION_RULE_SCHEMA_VERSION, mergeDetectionRules, } from "./detection-rule-model.js";
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";
13
- import { INVESTIGATION_TEMPLATE_SCHEMA, INVESTIGATION_TEMPLATE_VERSION, buildInvestigationTemplateExport, mergeInvestigationTemplates, } from "./investigation-template-model.js";
14
- import { BULK_REVIEW_SCHEMA, BULK_REVIEW_SCHEMA_VERSION, buildBulkReviewExport, mergeBulkReviewStores, } from "./bulk-review-model.js";
15
- export const WORKSPACE_ARCHIVE_SCHEMA = 'whoisleuth.workspace-archive';
16
- export const WORKSPACE_ARCHIVE_VERSION = 5;
17
- export const SUPPORTED_WORKSPACE_ARCHIVE_VERSIONS = [1, 2, 3, 4, WORKSPACE_ARCHIVE_VERSION];
18
- export const WORKSPACE_SETTINGS_SCHEMA = 'whoisleuth.workspace-settings';
19
- export const WORKSPACE_SETTINGS_VERSION = 1;
20
- export const MAX_WORKSPACE_ARCHIVE_BYTES = 10 * 1024 * 1024;
21
- export const MAX_WORKSPACE_ARCHIVE_SECTION_BYTES = 5 * 1024 * 1024;
22
- export const MAX_WORKSPACE_ARCHIVE_SECTIONS = 12;
23
- export const WORKSPACE_ARCHIVE_SECTION_IDS = [
24
- 'cases',
25
- 'campaigns',
26
- 'brandProfiles',
27
- 'watchlists',
28
- 'shortlist',
29
- 'detectionRules',
30
- 'relationshipObservations',
31
- 'bulkSessions',
32
- 'websiteSnapshots',
33
- 'investigationTemplates',
34
- 'bulkReview',
35
- 'settings',
36
- ];
37
- export function isSupportedWorkspaceArchiveVersion(value) {
38
- return typeof value === 'number'
39
- && Number.isSafeInteger(value)
40
- && SUPPORTED_WORKSPACE_ARCHIVE_VERSIONS.some((version) => version === value);
41
- }
4
+ import { buildCaseExport, enforceStoreBudget, mergeCases, } from "../cases/case-model.mjs";
5
+ import { assertBrandProfileStoreBudget, buildBrandProfileExport, mergeBrandProfiles, normalizeBrandProfileId, } from "./brand-profile-model.mjs";
6
+ import { assertCampaignStoreBudget, buildCampaignExport, mergeCampaigns, } from "./campaign-model.mjs";
7
+ import { normalizeExplicitIsoTimestamp } from "../evidence/observation.mjs";
8
+ import { assertWorkspaceInputGraph } from "./hostile-input.mjs";
9
+ import { assertWatchlistStoreBudget, buildWatchlistExport, mergeWatchlistStores, } from "./watchlist-store.mjs";
10
+ import { assertShortlistStoreBudget, buildShortlistExport, mergeShortlistStores, } from "./shortlist-model.mjs";
11
+ import { assertDetectionRuleStoreBudget, buildDetectionRuleExport, mergeDetectionRules, } from "./detection-rule-model.mjs";
12
+ import { buildRelationshipObservationExport, mergeRelationshipObservations, } from "./relationship-observation-model.mjs";
13
+ import { buildBulkSessionExport, enforceBulkSessionStoreBudget, mergeBulkSessions, } from "./bulk-session-model.mjs";
14
+ import { buildWebsiteSnapshotExport, mergeWebsiteSnapshots, } from "./website-snapshot-model.mjs";
15
+ import { buildInvestigationTemplateExport, mergeInvestigationTemplates, } from "./investigation-template-model.mjs";
16
+ import { buildBulkReviewExport, mergeBulkReviewStores, } from "./bulk-review-model.mjs";
17
+ import { buildAnalystReviewStateExport, emptyAnalystReviewStateStore, mergeAnalystReviewStateStores, } from "../monitoring/analyst-review-state.mjs";
18
+ import { isSupportedWorkspaceArchiveVersion, MAX_WORKSPACE_ARCHIVE_BYTES, MAX_WORKSPACE_ARCHIVE_SECTION_BYTES, MAX_WORKSPACE_ARCHIVE_SECTIONS, serialiseWorkspaceArchiveSection, SUPPORTED_WORKSPACE_ARCHIVE_VERSIONS, WORKSPACE_ARCHIVE_CASE_SECTION, WORKSPACE_ARCHIVE_SCHEMA, WORKSPACE_ARCHIVE_SECTION_IDS, PUBLIC_WORKSPACE_ARCHIVE_SECTION_IDS, PUBLIC_WORKSPACE_ARCHIVE_VERSION, WORKSPACE_ARCHIVE_VERSION, WORKSPACE_SETTINGS_COMPATIBILITY, WORKSPACE_SETTINGS_SCHEMA, WORKSPACE_SETTINGS_VERSION, } from "../contracts/case-portability.mjs";
19
+ import { ANALYST_REVIEW_STATE_COMPATIBILITY } from "../contracts/analyst-review-state.mjs";
20
+ import { WORKSPACE_PORTABILITY_ARCHIVE_SECTION_REFERENCES } from "../contracts/workspace-portability.mjs";
21
+ export { isSupportedWorkspaceArchiveVersion, MAX_WORKSPACE_ARCHIVE_BYTES, MAX_WORKSPACE_ARCHIVE_SECTION_BYTES, MAX_WORKSPACE_ARCHIVE_SECTIONS, SUPPORTED_WORKSPACE_ARCHIVE_VERSIONS, WORKSPACE_ARCHIVE_CASE_SECTION, WORKSPACE_ARCHIVE_SCHEMA, WORKSPACE_ARCHIVE_SECTION_IDS, PUBLIC_WORKSPACE_ARCHIVE_SECTION_IDS, PUBLIC_WORKSPACE_ARCHIVE_VERSION, WORKSPACE_ARCHIVE_VERSION, WORKSPACE_SETTINGS_SCHEMA, WORKSPACE_SETTINGS_VERSION, };
42
22
  const CONTROL_RE = /[\x00-\x1f\x7f]/;
43
23
  const CHECKSUM_RE = /^sha256:[a-f0-9]{64}$/;
24
+ const WORKSPACE_ARCHIVE_ROOT_KEYS = Object.freeze(['schema', 'version', 'generatedAt', 'manifest', 'sections', 'limitations']);
25
+ const WORKSPACE_ARCHIVE_MANIFEST_KEYS = Object.freeze(['sectionCount', 'totalRecords', 'sections']);
26
+ const WORKSPACE_ARCHIVE_MANIFEST_ENTRY_KEYS = Object.freeze(['id', 'schema', 'version', 'recordCount', 'bytes', 'checksum']);
44
27
  function record(value) {
45
28
  return value && typeof value === 'object' && !Array.isArray(value) ? value : null;
46
29
  }
30
+ function assertExactKeys(value, expected, label) {
31
+ const keys = Object.keys(value);
32
+ if (keys.length !== expected.length || keys.some((key) => !expected.includes(key))) {
33
+ throw new Error(`The workspace archive ${label} contains missing or undeclared fields.`);
34
+ }
35
+ }
47
36
  function byteLength(value) {
48
37
  return new TextEncoder().encode(value).byteLength;
49
38
  }
50
39
  function serialize(value, message = 'The workspace archive contains data that cannot be serialised.') {
51
40
  try {
52
- const serialized = JSON.stringify(value);
53
- if (typeof serialized !== 'string')
54
- throw new Error(message);
55
- return serialized;
41
+ return serialiseWorkspaceArchiveSection(value);
56
42
  }
57
43
  catch {
58
44
  throw new Error(message);
@@ -62,10 +48,10 @@ function clone(value) {
62
48
  return JSON.parse(serialize(value));
63
49
  }
64
50
  function timestamp(value, fallback = null) {
65
- if (typeof value !== 'string' || value.length > 64 || CONTROL_RE.test(value))
66
- return fallback;
67
- const parsed = Date.parse(value);
68
- return Number.isFinite(parsed) ? new Date(parsed).toISOString() : fallback;
51
+ const explicit = normalizeExplicitIsoTimestamp(value);
52
+ if (explicit)
53
+ return explicit;
54
+ return fallback;
69
55
  }
70
56
  function boundedText(value, maximum = 300) {
71
57
  if (typeof value !== 'string' || value.length > maximum * 4 || CONTROL_RE.test(value))
@@ -124,6 +110,7 @@ function workspaceArchiveSections(input, now) {
124
110
  websiteSnapshots: buildWebsiteSnapshotExport(input.websiteSnapshots, now),
125
111
  investigationTemplates: buildInvestigationTemplateExport(input.investigationTemplates, now),
126
112
  bulkReview: buildBulkReviewExport(input.bulkReview),
113
+ analystReviewState: buildAnalystReviewStateExport(input.analystReviewState),
127
114
  settings: settingsDocument(input),
128
115
  };
129
116
  }
@@ -136,9 +123,29 @@ function objectCount(data, key) {
136
123
  const nested = value ? record(value[key]) : null;
137
124
  return nested ? Object.keys(nested).length : 0;
138
125
  }
126
+ function sectionContract(sectionId) {
127
+ const reference = WORKSPACE_PORTABILITY_ARCHIVE_SECTION_REFERENCES.find((entry) => entry.sectionId === sectionId);
128
+ if (!reference)
129
+ throw new TypeError(`Workspace archive section ${sectionId} has no canonical contract reference.`);
130
+ return Object.freeze({
131
+ id: reference.sectionId,
132
+ schema: reference.schema,
133
+ version: reference.version,
134
+ supportedVersions: reference.supportedVersions,
135
+ });
136
+ }
137
+ function compatibilitySchema(descriptor) {
138
+ if (!descriptor.schema)
139
+ throw new TypeError(`Workspace compatibility ${descriptor.id} must name a schema.`);
140
+ return descriptor.schema;
141
+ }
139
142
  const SECTION_DEFINITIONS = [
140
143
  {
141
- id: 'cases', label: 'Cases', schema: null, version: CASE_SCHEMA_VERSION,
144
+ id: WORKSPACE_ARCHIVE_CASE_SECTION.id,
145
+ label: 'Cases',
146
+ schema: WORKSPACE_ARCHIVE_CASE_SECTION.schema,
147
+ version: WORKSPACE_ARCHIVE_CASE_SECTION.currentVersion,
148
+ supportedVersions: WORKSPACE_ARCHIVE_CASE_SECTION.supportedVersions,
142
149
  count: (data) => arrayCount(data, 'cases'),
143
150
  merge: (local, data) => {
144
151
  const result = mergeCases(local.cases, data);
@@ -147,7 +154,7 @@ const SECTION_DEFINITIONS = [
147
154
  },
148
155
  },
149
156
  {
150
- id: 'campaigns', label: 'Campaigns', schema: CAMPAIGN_SCHEMA, version: CAMPAIGN_SCHEMA_VERSION,
157
+ ...sectionContract('campaigns'), label: 'Campaigns',
151
158
  count: (data) => arrayCount(data, 'campaigns'),
152
159
  merge: (local, data) => {
153
160
  const result = mergeCampaigns(local.campaigns, data);
@@ -155,7 +162,7 @@ const SECTION_DEFINITIONS = [
155
162
  },
156
163
  },
157
164
  {
158
- id: 'brandProfiles', label: 'Brand profiles', schema: BRAND_PROFILE_SCHEMA, version: BRAND_PROFILE_SCHEMA_VERSION,
165
+ ...sectionContract('brandProfiles'), label: 'Brand profiles',
159
166
  count: (data) => arrayCount(data, 'profiles'),
160
167
  merge: (local, data, now) => {
161
168
  const result = mergeBrandProfiles(local.brandProfiles, data, { nowIso: now });
@@ -163,7 +170,7 @@ const SECTION_DEFINITIONS = [
163
170
  },
164
171
  },
165
172
  {
166
- id: 'watchlists', label: 'Watchlists', schema: WATCHLIST_SCHEMA, version: WATCHLIST_SCHEMA_VERSION,
173
+ ...sectionContract('watchlists'), label: 'Watchlists',
167
174
  count: (data) => objectCount(data, 'watchlists'),
168
175
  merge: (local, data) => {
169
176
  const result = mergeWatchlistStores(local.watchlists, data);
@@ -171,7 +178,7 @@ const SECTION_DEFINITIONS = [
171
178
  },
172
179
  },
173
180
  {
174
- id: 'shortlist', label: 'Shortlist', schema: SHORTLIST_SCHEMA, version: SHORTLIST_SCHEMA_VERSION,
181
+ ...sectionContract('shortlist'), label: 'Shortlist',
175
182
  count: (data) => arrayCount(data, 'entries'),
176
183
  merge: (local, data) => {
177
184
  const result = mergeShortlistStores(local.shortlist, data);
@@ -179,7 +186,7 @@ const SECTION_DEFINITIONS = [
179
186
  },
180
187
  },
181
188
  {
182
- id: 'detectionRules', label: 'Detection rules', schema: DETECTION_RULE_SCHEMA, version: DETECTION_RULE_SCHEMA_VERSION,
189
+ ...sectionContract('detectionRules'), label: 'Detection rules',
183
190
  count: (data) => arrayCount(data, 'rules'),
184
191
  merge: (local, data) => {
185
192
  const result = mergeDetectionRules(local.detectionRules, data);
@@ -187,18 +194,14 @@ const SECTION_DEFINITIONS = [
187
194
  },
188
195
  },
189
196
  {
190
- id: 'relationshipObservations',
197
+ ...sectionContract('relationshipObservations'),
191
198
  label: 'Retained relationship observations',
192
- schema: RELATIONSHIP_OBSERVATION_SCHEMA,
193
- version: RELATIONSHIP_OBSERVATION_SCHEMA_VERSION,
194
199
  count: (data) => arrayCount(data, 'observations'),
195
200
  merge: (local, data) => mergeRelationshipObservations(local.relationshipObservations, data),
196
201
  },
197
202
  {
198
- id: 'bulkSessions',
203
+ ...sectionContract('bulkSessions'),
199
204
  label: 'Saved Bulk sessions',
200
- schema: BULK_SESSION_SCHEMA,
201
- version: BULK_SESSION_SCHEMA_VERSION,
202
205
  count: (data) => arrayCount(data, 'sessions'),
203
206
  merge: (local, data) => {
204
207
  const result = mergeBulkSessions(local.bulkSessions, data);
@@ -207,31 +210,36 @@ const SECTION_DEFINITIONS = [
207
210
  },
208
211
  },
209
212
  {
210
- id: 'websiteSnapshots',
213
+ ...sectionContract('websiteSnapshots'),
211
214
  label: 'Website profile snapshots',
212
- schema: WEBSITE_SNAPSHOT_SCHEMA,
213
- version: WEBSITE_SNAPSHOT_SCHEMA_VERSION,
214
215
  count: (data) => arrayCount(data, 'snapshots'),
215
216
  merge: (local, data) => mergeWebsiteSnapshots(local.websiteSnapshots, data),
216
217
  },
217
218
  {
218
- id: 'investigationTemplates',
219
+ ...sectionContract('investigationTemplates'),
219
220
  label: 'Investigation templates',
220
- schema: INVESTIGATION_TEMPLATE_SCHEMA,
221
- version: INVESTIGATION_TEMPLATE_VERSION,
222
221
  count: (data) => arrayCount(data, 'templates'),
223
222
  merge: (local, data) => mergeInvestigationTemplates(local.investigationTemplates, data),
224
223
  },
225
224
  {
226
- id: 'bulkReview',
225
+ ...sectionContract('bulkReview'),
227
226
  label: 'Bulk saved views and review queue',
228
- schema: BULK_REVIEW_SCHEMA,
229
- version: BULK_REVIEW_SCHEMA_VERSION,
230
227
  count: (data) => arrayCount(data, 'presets') + arrayCount(data, 'rows'),
231
228
  merge: (local, data) => mergeBulkReviewStores(local.bulkReview, data),
232
229
  },
233
230
  {
234
- id: 'settings', label: 'Workspace settings', schema: WORKSPACE_SETTINGS_SCHEMA, version: WORKSPACE_SETTINGS_VERSION,
231
+ id: 'analystReviewState',
232
+ label: 'Analyst Review Item lifecycle',
233
+ schema: compatibilitySchema(ANALYST_REVIEW_STATE_COMPATIBILITY),
234
+ version: ANALYST_REVIEW_STATE_COMPATIBILITY.currentVersion,
235
+ supportedVersions: ANALYST_REVIEW_STATE_COMPATIBILITY.supportedVersions,
236
+ count: (data) => arrayCount(data, 'records'),
237
+ merge: (local, data) => mergeAnalystReviewStateStores(local.analystReviewState, data),
238
+ },
239
+ {
240
+ id: 'settings', label: 'Workspace settings', schema: compatibilitySchema(WORKSPACE_SETTINGS_COMPATIBILITY),
241
+ version: WORKSPACE_SETTINGS_COMPATIBILITY.currentVersion,
242
+ supportedVersions: WORKSPACE_SETTINGS_COMPATIBILITY.supportedVersions,
235
243
  count: () => 1,
236
244
  merge: null,
237
245
  },
@@ -257,6 +265,7 @@ function normalizedInput(input) {
257
265
  websiteSnapshots: Array.isArray(value.websiteSnapshots) ? value.websiteSnapshots : [],
258
266
  investigationTemplates: Array.isArray(value.investigationTemplates) ? value.investigationTemplates : [],
259
267
  bulkReview: record(value.bulkReview) || {},
268
+ analystReviewState: record(value.analystReviewState) || emptyAnalystReviewStateStore(),
260
269
  settings: record(value.settings) || {},
261
270
  };
262
271
  }
@@ -270,7 +279,9 @@ function ensureArchiveBudget(value) {
270
279
  }
271
280
  /** Build one deterministic, unencrypted archive from normalized local stores. */
272
281
  export async function buildWorkspaceArchive(input, options = {}) {
273
- const now = timestamp(options.generatedAt) || new Date().toISOString();
282
+ const now = timestamp(options.generatedAt ?? new Date().toISOString());
283
+ if (!now)
284
+ throw new Error('Workspace archive generation time must use an explicit timezone.');
274
285
  const source = normalizedInput(input);
275
286
  const sections = workspaceArchiveSections(source, now);
276
287
  const manifestSections = [];
@@ -327,23 +338,35 @@ function manifestEntry(raw) {
327
338
  }
328
339
  /** Validate structure, section byte counts, and checksums without applying data. */
329
340
  export async function readWorkspaceArchive(raw, options = {}) {
341
+ assertWorkspaceInputGraph(raw, 'Workspace archive');
330
342
  const value = record(raw);
331
343
  if (!value || value.schema !== WORKSPACE_ARCHIVE_SCHEMA) {
332
344
  throw new Error('This file is not a WHOISleuth workspace archive.');
333
345
  }
334
346
  if (!isSupportedWorkspaceArchiveVersion(value.version)) {
347
+ if (typeof value.version === 'number' && Number.isSafeInteger(value.version) && value.version < WORKSPACE_ARCHIVE_VERSION) {
348
+ throw new Error(`Workspace archive schema ${value.version} is retired. Export it as schema ${WORKSPACE_ARCHIVE_VERSION} with the last broad-reader release before importing; no data was changed.`);
349
+ }
335
350
  if (typeof value.version === 'number' && Number.isSafeInteger(value.version) && value.version > WORKSPACE_ARCHIVE_VERSION) {
336
- throw new Error(`This workspace archive uses newer schema ${value.version}. Update the app before importing it.`);
351
+ throw new Error(`Workspace archive schema ${value.version} is newer than the supported schema ${WORKSPACE_ARCHIVE_VERSION}; no data was changed.`);
337
352
  }
338
- throw new Error(`Expected workspace archive schema 1, 2, 3, 4, or ${WORKSPACE_ARCHIVE_VERSION}.`);
353
+ throw new Error(`Expected a well-formed workspace archive using schema ${WORKSPACE_ARCHIVE_VERSION}; no data was changed.`);
339
354
  }
340
355
  const sourceVersion = value.version;
356
+ const expectedSectionIds = sourceVersion === PUBLIC_WORKSPACE_ARCHIVE_VERSION
357
+ ? PUBLIC_WORKSPACE_ARCHIVE_SECTION_IDS
358
+ : WORKSPACE_ARCHIVE_SECTION_IDS;
359
+ const generatedAt = timestamp(value.generatedAt);
360
+ if (!generatedAt)
361
+ throw new Error('The workspace archive generation time is invalid.');
341
362
  const { bytes } = ensureArchiveBudget(value);
363
+ assertExactKeys(value, WORKSPACE_ARCHIVE_ROOT_KEYS, `version ${sourceVersion} envelope`);
342
364
  const manifest = record(value.manifest);
343
365
  const sectionValues = record(value.sections);
344
366
  if (!manifest || !sectionValues || !Array.isArray(manifest.sections)) {
345
367
  throw new Error('The workspace archive manifest is missing or malformed.');
346
368
  }
369
+ assertExactKeys(manifest, WORKSPACE_ARCHIVE_MANIFEST_KEYS, 'manifest');
347
370
  if (manifest.sections.length > MAX_WORKSPACE_ARCHIVE_SECTIONS) {
348
371
  throw new Error(`Workspace archives are limited to ${MAX_WORKSPACE_ARCHIVE_SECTIONS} sections.`);
349
372
  }
@@ -351,6 +374,10 @@ export async function readWorkspaceArchive(raw, options = {}) {
351
374
  const sections = [];
352
375
  let totalRecords = 0;
353
376
  for (const rawEntry of manifest.sections) {
377
+ const rawEntryRecord = record(rawEntry);
378
+ if (!rawEntryRecord)
379
+ throw new Error('The workspace archive manifest contains a malformed section entry.');
380
+ assertExactKeys(rawEntryRecord, WORKSPACE_ARCHIVE_MANIFEST_ENTRY_KEYS, 'manifest section entry');
354
381
  const entry = manifestEntry(rawEntry);
355
382
  if (!entry || seen.has(entry.id) || !Object.prototype.hasOwnProperty.call(sectionValues, entry.id)) {
356
383
  throw new Error('The workspace archive manifest contains an invalid, duplicate, or missing section.');
@@ -363,6 +390,21 @@ export async function readWorkspaceArchive(raw, options = {}) {
363
390
  if (await checksum(data, options.cryptoProvider) !== entry.checksum) {
364
391
  throw new Error(`${entry.id} failed its archive checksum check.`);
365
392
  }
393
+ const embedded = record(data);
394
+ const embeddedVersion = embedded?.version;
395
+ const embeddedSchema = embedded && Object.prototype.hasOwnProperty.call(embedded, 'schema')
396
+ ? embedded.schema
397
+ : null;
398
+ if (!embedded
399
+ || typeof embeddedVersion !== 'number'
400
+ || !Number.isSafeInteger(embeddedVersion)
401
+ || embeddedVersion < 1
402
+ || embeddedVersion > 1000
403
+ || (embeddedSchema !== null && typeof embeddedSchema !== 'string')
404
+ || embeddedVersion !== entry.version
405
+ || embeddedSchema !== entry.schema) {
406
+ throw new Error(`${entry.id} section contract does not match its archive manifest.`);
407
+ }
366
408
  const definition = DEFINITION_BY_ID.get(entry.id);
367
409
  let status = 'ready';
368
410
  let reason = '';
@@ -370,11 +412,13 @@ export async function readWorkspaceArchive(raw, options = {}) {
370
412
  status = 'unsupported';
371
413
  reason = 'This app does not recognise the archive section.';
372
414
  }
373
- else if (entry.version !== definition.version || entry.schema !== definition.schema) {
415
+ else if (!(definition.supportedVersions ?? [definition.version]).includes(entry.version) || entry.schema !== definition.schema) {
374
416
  status = 'unsupported';
375
- reason = entry.version > definition.version
376
- ? `This section uses newer schema ${entry.version}.`
377
- : 'This section uses an unsupported schema contract.';
417
+ reason = entry.schema !== definition.schema
418
+ ? 'This section uses an unsupported schema contract.'
419
+ : entry.version > definition.version
420
+ ? `This section uses newer schema ${entry.version}.`
421
+ : `This section uses retired schema ${entry.version}. Export it with the last broad-reader release before importing; no data was changed.`;
378
422
  }
379
423
  else if (definition.count(data) !== entry.recordCount) {
380
424
  throw new Error(`${entry.id} does not match its manifest record count.`);
@@ -388,12 +432,30 @@ export async function readWorkspaceArchive(raw, options = {}) {
388
432
  if (manifest.sectionCount !== sections.length || manifest.totalRecords !== totalRecords) {
389
433
  throw new Error('The workspace archive manifest totals do not match its sections.');
390
434
  }
435
+ if (seen.size !== expectedSectionIds.length || expectedSectionIds.some((id) => !seen.has(id))) {
436
+ throw new Error(`Workspace archive schema ${sourceVersion} does not contain its exact required section set.`);
437
+ }
438
+ if (sourceVersion === PUBLIC_WORKSPACE_ARCHIVE_VERSION) {
439
+ const data = emptyAnalystReviewStateStore();
440
+ sections.push({
441
+ id: 'analystReviewState',
442
+ label: 'Analyst Review Item lifecycle',
443
+ schema: compatibilitySchema(ANALYST_REVIEW_STATE_COMPATIBILITY),
444
+ version: ANALYST_REVIEW_STATE_COMPATIBILITY.currentVersion,
445
+ recordCount: 0,
446
+ bytes: byteLength(serialize(data)),
447
+ checksum: await checksum(data, options.cryptoProvider),
448
+ status: 'ready',
449
+ reason: `Workspace schema ${PUBLIC_WORKSPACE_ARCHIVE_VERSION} did not contain Review Item lifecycle state. Import supplies an empty section and invents no analyst decisions.`,
450
+ data,
451
+ });
452
+ }
391
453
  sections.sort(canonicalSectionOrder);
392
454
  return {
393
455
  schema: WORKSPACE_ARCHIVE_SCHEMA,
394
456
  version: WORKSPACE_ARCHIVE_VERSION,
395
457
  sourceVersion,
396
- generatedAt: timestamp(value.generatedAt),
458
+ generatedAt,
397
459
  bytes,
398
460
  sections,
399
461
  limitations: Array.isArray(value.limitations)
@@ -403,14 +465,31 @@ export async function readWorkspaceArchive(raw, options = {}) {
403
465
  }
404
466
  function settingsPreview(data, local, mergedProfiles) {
405
467
  const value = record(data) || {};
406
- const activeProfileId = normalizeBrandProfileId(value.activeProfileId) || '';
468
+ const rawActiveProfileId = value.activeProfileId;
469
+ const intentionalClear = rawActiveProfileId === '';
470
+ const normalizedActiveProfileId = normalizeBrandProfileId(rawActiveProfileId);
471
+ const validActiveProfileId = typeof rawActiveProfileId === 'string'
472
+ && rawActiveProfileId.length > 0
473
+ && normalizedActiveProfileId === rawActiveProfileId;
407
474
  const theme = normalizeTheme(value.theme);
475
+ const retainedActiveProfileId = normalizeBrandProfileId(local.settings?.activeProfileId) || '';
476
+ let activeProfileId = retainedActiveProfileId;
408
477
  let skipped = 0;
409
- if (activeProfileId && !mergedProfiles.some((profile) => profile?.id === activeProfileId))
410
- skipped++;
478
+ let reason = '';
479
+ if (!intentionalClear && !validActiveProfileId) {
480
+ skipped = 1;
481
+ reason = 'The imported active Brand Profile preference is missing or malformed. Existing active-profile context is preserved.';
482
+ }
483
+ else if (validActiveProfileId && !mergedProfiles.some((profile) => profile?.id === normalizedActiveProfileId)) {
484
+ skipped = 1;
485
+ 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.';
486
+ }
487
+ else {
488
+ activeProfileId = intentionalClear ? '' : normalizedActiveProfileId ?? retainedActiveProfileId;
489
+ }
411
490
  const updated = Number(theme !== normalizeTheme(local.settings?.theme)
412
- || (Boolean(activeProfileId) && activeProfileId !== local.settings?.activeProfileId && skipped === 0));
413
- return { added: 0, updated, skipped, settings: { activeProfileId: skipped ? '' : activeProfileId, theme } };
491
+ || (skipped === 0 && activeProfileId !== retainedActiveProfileId));
492
+ return { added: 0, updated, skipped, reason, settings: { activeProfileId, theme } };
414
493
  }
415
494
  /** Preview section-specific non-destructive merge outcomes without writing. */
416
495
  export async function previewWorkspaceArchive(raw, localInput, options = {}) {
@@ -418,6 +497,9 @@ export async function previewWorkspaceArchive(raw, localInput, options = {}) {
418
497
  const local = normalizedInput(localInput);
419
498
  const results = [];
420
499
  let mergedProfiles = local.brandProfiles;
500
+ const selectedIds = options.selectedSectionIds
501
+ ? new Set(options.selectedSectionIds.slice(0, MAX_WORKSPACE_ARCHIVE_SECTIONS))
502
+ : null;
421
503
  for (const section of archive.sections) {
422
504
  if (section.status !== 'ready') {
423
505
  results.push({ ...section, added: 0, updated: 0, skipped: section.recordCount, selected: false });
@@ -432,17 +514,21 @@ export async function previewWorkspaceArchive(raw, localInput, options = {}) {
432
514
  : definition.merge
433
515
  ? definition.merge(local, section.data, archive.generatedAt)
434
516
  : { added: 0, updated: 0, skipped: section.recordCount };
435
- if (definition.id === 'brandProfiles' && result.profiles)
517
+ const selected = selectedIds ? selectedIds.has(section.id) : true;
518
+ if (definition.id === 'brandProfiles' && selected && result.profiles)
436
519
  mergedProfiles = result.profiles;
437
520
  results.push({
438
521
  ...section,
439
522
  status: 'ready',
440
- reason: '',
523
+ reason: definition.id === 'settings' && selected && (result.skipped ?? 0) > 0
524
+ ? result.reason ?? 'The imported workspace preference was skipped. Existing browser-local context is preserved.'
525
+ : section.reason,
441
526
  added: result.added ?? 0,
442
527
  updated: result.updated ?? 0,
443
528
  skipped: result.skipped ?? 0,
444
529
  pruned: result.pruned ?? 0,
445
- selected: true,
530
+ brandProfileReferencesOmitted: result.brandProfileReferencesOmitted ?? 0,
531
+ selected,
446
532
  normalizedSettings: result.settings || null,
447
533
  });
448
534
  }
@@ -461,6 +547,31 @@ export async function previewWorkspaceArchive(raw, localInput, options = {}) {
461
547
  });
462
548
  }
463
549
  }
550
+ const profileCollision = results.find((section) => section.id === 'brandProfiles'
551
+ && section.status === 'blocked'
552
+ && /reuses one exact identifier for different normalised profile names/iu.test(section.reason));
553
+ if (profileCollision) {
554
+ for (const dependentId of ['cases', 'settings']) {
555
+ const dependentIndex = results.findIndex((section) => section.id === dependentId && section.status === 'ready');
556
+ const dependentSection = results[dependentIndex];
557
+ if (dependentIndex < 0 || !dependentSection)
558
+ continue;
559
+ results[dependentIndex] = {
560
+ ...dependentSection,
561
+ status: 'blocked',
562
+ reason: dependentId === 'cases'
563
+ ? 'Cases were not selected because a Brand Profile identifier collision must be resolved before their opaque references can be imported safely.'
564
+ : 'Workspace settings were not selected because an active Brand Profile preference could otherwise bind to a different local profile that reuses the same identifier.',
565
+ added: 0,
566
+ updated: 0,
567
+ skipped: dependentSection.recordCount,
568
+ pruned: 0,
569
+ brandProfileReferencesOmitted: 0,
570
+ selected: false,
571
+ normalizedSettings: dependentId === 'settings' ? null : dependentSection.normalizedSettings ?? null,
572
+ };
573
+ }
574
+ }
464
575
  return {
465
576
  ...archive,
466
577
  sections: results,