@spottoai/types-package 1.0.2-beta.353 → 1.0.2-beta.356

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 (182) hide show
  1. package/README.md +17 -0
  2. package/dist/aws/commitments-planning.d.ts +3 -0
  3. package/dist/aws/commitments-planning.d.ts.map +1 -0
  4. package/dist/aws/commitments-planning.js +6 -0
  5. package/dist/aws/commitments-planning.js.map +1 -0
  6. package/dist/aws/commitmentsPlanningValidation.d.ts.map +1 -1
  7. package/dist/aws/commitmentsPlanningValidation.js +2 -0
  8. package/dist/aws/commitmentsPlanningValidation.js.map +1 -1
  9. package/dist/aws/index.d.ts +2 -0
  10. package/dist/aws/index.d.ts.map +1 -1
  11. package/dist/aws/index.js +2 -0
  12. package/dist/aws/index.js.map +1 -1
  13. package/dist/aws/portalCommitmentsPlanningPublicArtifactValidation.d.ts +4 -0
  14. package/dist/aws/portalCommitmentsPlanningPublicArtifactValidation.d.ts.map +1 -0
  15. package/dist/aws/portalCommitmentsPlanningPublicArtifactValidation.js +513 -0
  16. package/dist/aws/portalCommitmentsPlanningPublicArtifactValidation.js.map +1 -0
  17. package/dist/aws/portalCommitmentsPlanningPublicArtifacts.d.ts +13 -0
  18. package/dist/aws/portalCommitmentsPlanningPublicArtifacts.d.ts.map +1 -0
  19. package/dist/aws/portalCommitmentsPlanningPublicArtifacts.js +5 -0
  20. package/dist/aws/portalCommitmentsPlanningPublicArtifacts.js.map +1 -0
  21. package/dist/aws/portalPublicArtifactHistoryTypes.d.ts +5 -0
  22. package/dist/aws/portalPublicArtifactHistoryTypes.d.ts.map +1 -1
  23. package/dist/aws/portalPublicArtifactHistoryTypes.js +6 -0
  24. package/dist/aws/portalPublicArtifactHistoryTypes.js.map +1 -1
  25. package/dist/aws/portalPublicArtifacts.d.ts +2 -1
  26. package/dist/aws/portalPublicArtifacts.d.ts.map +1 -1
  27. package/dist/aws/portalPublicArtifacts.js.map +1 -1
  28. package/dist/aws/publicArtifacts.d.ts +1 -1
  29. package/dist/aws/publicArtifacts.d.ts.map +1 -1
  30. package/dist/aws/publicArtifacts.js +1 -0
  31. package/dist/aws/publicArtifacts.js.map +1 -1
  32. package/dist/aws/relationships.d.ts +4 -0
  33. package/dist/aws/relationships.d.ts.map +1 -0
  34. package/dist/aws/relationships.js +6 -0
  35. package/dist/aws/relationships.js.map +1 -0
  36. package/dist/azure/commitmentsPlanning.d.ts +3 -1
  37. package/dist/azure/commitmentsPlanning.d.ts.map +1 -1
  38. package/dist/esm/accounts/accounts.js +115 -0
  39. package/dist/esm/accounts/azureSpSetup.js +1 -0
  40. package/dist/esm/accounts/azureSpSetupDurable.js +1 -0
  41. package/dist/esm/accounts/index.js +5 -0
  42. package/dist/esm/accounts/readPermissions.js +198 -0
  43. package/dist/esm/accounts/validation.js +1 -0
  44. package/dist/esm/accounts/writePermissions.js +43 -0
  45. package/dist/esm/ai/index.js +2 -0
  46. package/dist/esm/assessments/artifact.js +1 -0
  47. package/dist/esm/assessments/catalogue.js +12 -0
  48. package/dist/esm/assessments/common.js +1 -0
  49. package/dist/esm/assessments/evidence.js +1 -0
  50. package/dist/esm/assessments/export.js +1 -0
  51. package/dist/esm/assessments/index.js +10 -0
  52. package/dist/esm/assessments/queue.js +2 -0
  53. package/dist/esm/assessments/review.js +1 -0
  54. package/dist/esm/assessments/run.js +1 -0
  55. package/dist/esm/assessments/sow.js +1 -0
  56. package/dist/esm/assessments/status.js +23 -0
  57. package/dist/esm/aws/commitments-planning.js +1 -0
  58. package/dist/esm/aws/commitmentsPlanningValidation.js +121 -0
  59. package/dist/esm/aws/estates.js +21 -0
  60. package/dist/esm/aws/index.js +18 -0
  61. package/dist/esm/aws/onboarding.js +1 -0
  62. package/dist/esm/aws/pluginPublicArtifactBodyValidation.js +217 -0
  63. package/dist/esm/aws/pluginPublicArtifactEvidence.js +1 -0
  64. package/dist/esm/aws/pluginPublicArtifactManifestValidation.js +255 -0
  65. package/dist/esm/aws/pluginPublicArtifactSectionValidation.js +464 -0
  66. package/dist/esm/aws/pluginPublicArtifactValidation.js +2 -0
  67. package/dist/esm/aws/pluginPublicArtifactValidationHelpers.js +216 -0
  68. package/dist/esm/aws/pluginPublicArtifacts.js +109 -0
  69. package/dist/esm/aws/portalCommitmentsPlanningPublicArtifactValidation.js +509 -0
  70. package/dist/esm/aws/portalCommitmentsPlanningPublicArtifacts.js +1 -0
  71. package/dist/esm/aws/portalPublicArtifactCurrentValidation.js +328 -0
  72. package/dist/esm/aws/portalPublicArtifactEvidenceValidation.js +294 -0
  73. package/dist/esm/aws/portalPublicArtifactHistoryAiValidation.js +368 -0
  74. package/dist/esm/aws/portalPublicArtifactHistoryTypes.js +40 -0
  75. package/dist/esm/aws/portalPublicArtifactNestedEvidence.js +1 -0
  76. package/dist/esm/aws/portalPublicArtifactNestedEvidenceValidation.js +397 -0
  77. package/dist/esm/aws/portalPublicArtifactValidation.js +2 -0
  78. package/dist/esm/aws/portalPublicArtifactValidationCommon.js +139 -0
  79. package/dist/esm/aws/portalPublicArtifacts.js +73 -0
  80. package/dist/esm/aws/portalRelationshipPublicArtifactValidation.js +334 -0
  81. package/dist/esm/aws/portalRelationshipPublicArtifacts.js +59 -0
  82. package/dist/esm/aws/portalRelationshipResourceNodeTypes.js +1 -0
  83. package/dist/esm/aws/portalRelationshipResourceNodeValidation.js +368 -0
  84. package/dist/esm/aws/publicArtifacts.js +13 -0
  85. package/dist/esm/aws/relationships.js +1 -0
  86. package/dist/esm/aws/requests.js +19 -0
  87. package/dist/esm/azure/activityLogs.js +2 -0
  88. package/dist/esm/azure/advisorScore.js +1 -0
  89. package/dist/esm/azure/benefits.js +1 -0
  90. package/dist/esm/azure/billingGeneration.js +1 -0
  91. package/dist/esm/azure/billingPlots.js +4 -0
  92. package/dist/esm/azure/budgets.js +1 -0
  93. package/dist/esm/azure/commitmentsPlanning.js +1 -0
  94. package/dist/esm/azure/common.js +1 -0
  95. package/dist/esm/azure/configs.js +1 -0
  96. package/dist/esm/azure/costComposition.js +1 -0
  97. package/dist/esm/azure/dataProtection.js +5 -0
  98. package/dist/esm/azure/governance.js +14 -0
  99. package/dist/esm/azure/licensing.js +1 -0
  100. package/dist/esm/azure/metrics.js +1 -0
  101. package/dist/esm/azure/payloads.js +1 -0
  102. package/dist/esm/azure/portalArtifacts.js +1 -0
  103. package/dist/esm/azure/prices.js +1 -0
  104. package/dist/esm/azure/publicIpAddresses.js +2 -0
  105. package/dist/esm/azure/recommendationAudit.js +16 -0
  106. package/dist/esm/azure/recommendationState.js +1 -0
  107. package/dist/esm/azure/recommendationTracks.js +40 -0
  108. package/dist/esm/azure/recommendationWorkflow.js +13 -0
  109. package/dist/esm/azure/recommendations.js +10 -0
  110. package/dist/esm/azure/regulatoryCompliance.js +6 -0
  111. package/dist/esm/azure/relationships.js +2 -0
  112. package/dist/esm/azure/reportingTemplates.js +1 -0
  113. package/dist/esm/azure/reports.js +12 -0
  114. package/dist/esm/azure/resourceHealth.js +12 -0
  115. package/dist/esm/azure/resourceOptimization.js +1 -0
  116. package/dist/esm/azure/resources.js +1 -0
  117. package/dist/esm/azure/reviewChecklist.js +1 -0
  118. package/dist/esm/azure/secureScore.js +1 -0
  119. package/dist/esm/azure/security.js +1 -0
  120. package/dist/esm/azure/serviceRetirement.js +1 -0
  121. package/dist/esm/azure/serviceRetirementState.js +1 -0
  122. package/dist/esm/azure/serviceRetirementWorkManagement.js +1 -0
  123. package/dist/esm/azure/storage-inventory.js +1 -0
  124. package/dist/esm/azure/subscriptions.js +1 -0
  125. package/dist/esm/azure/views.js +32 -0
  126. package/dist/esm/azure/workManagement.js +4 -0
  127. package/dist/esm/common/artifactGeneration.js +1 -0
  128. package/dist/esm/common/capabilityPassport.js +214 -0
  129. package/dist/esm/common/index.js +7 -0
  130. package/dist/esm/common/pagination.js +1 -0
  131. package/dist/esm/common/provider.js +11 -0
  132. package/dist/esm/common/providerSyncProgress.js +1 -0
  133. package/dist/esm/common/relationships.js +1 -0
  134. package/dist/esm/common/syncProgress.js +1 -0
  135. package/dist/esm/company/billingAccount.js +1 -0
  136. package/dist/esm/company/company.js +1 -0
  137. package/dist/esm/company/companyHierarchy.js +1 -0
  138. package/dist/esm/company/companyNotes.js +38 -0
  139. package/dist/esm/company/customProperties.js +1 -0
  140. package/dist/esm/company/index.js +6 -0
  141. package/dist/esm/company/notification.js +43 -0
  142. package/dist/esm/company/survey.js +1 -0
  143. package/dist/esm/customProperties/customProperties.js +1 -0
  144. package/dist/esm/customProperties/index.js +1 -0
  145. package/dist/esm/entries/aws.js +3 -0
  146. package/dist/esm/entries/commitments-planning.js +3 -0
  147. package/dist/esm/entries/provider.js +3 -0
  148. package/dist/esm/entries/recommendations.js +3 -0
  149. package/dist/esm/entries/relationships.js +3 -0
  150. package/dist/esm/entries/root.js +3 -0
  151. package/dist/esm/events/actionGroups.js +1 -0
  152. package/dist/esm/events/alertDefinitionRun.js +1 -0
  153. package/dist/esm/events/baseAlert.js +1 -0
  154. package/dist/esm/events/cost.js +1 -0
  155. package/dist/esm/events/costAnomalies.js +1 -0
  156. package/dist/esm/events/costViews.js +1 -0
  157. package/dist/esm/events/eventStatus.js +1 -0
  158. package/dist/esm/events/index.js +8 -0
  159. package/dist/esm/events/quickAlerts.js +2 -0
  160. package/dist/esm/features-and-permissions/access.js +1 -0
  161. package/dist/esm/features-and-permissions/index.js +1 -0
  162. package/dist/esm/feedbacks/feedback.js +1 -0
  163. package/dist/esm/identity/apikey.js +1 -0
  164. package/dist/esm/identity/index.js +1 -0
  165. package/dist/esm/index.js +54 -0
  166. package/dist/esm/index.vite.js +48 -0
  167. package/dist/esm/integrations/autotask.js +1 -0
  168. package/dist/esm/integrations/azureDevOps.js +1 -0
  169. package/dist/esm/integrations/github.js +1 -0
  170. package/dist/esm/integrations/halo.js +1 -0
  171. package/dist/esm/integrations/index.js +5 -0
  172. package/dist/esm/integrations/integrationTickets.js +1 -0
  173. package/dist/esm/package.json +3 -0
  174. package/dist/esm/scheduler/index.js +1 -0
  175. package/dist/esm/scheduler/scheduler.js +1 -0
  176. package/dist/esm/tags/index.js +1 -0
  177. package/dist/esm/tags/tags.js +6 -0
  178. package/dist/esm/unknown/services.js +1 -0
  179. package/dist/esm/users/companyUser.js +2 -0
  180. package/dist/esm/users/index.js +2 -0
  181. package/dist/esm/users/user.js +1 -0
  182. package/package.json +23 -6
@@ -0,0 +1,328 @@
1
+ import { AWS_PORTAL_ACCOUNT_SUMMARY_LOGICAL_NAME, AWS_PORTAL_RESOURCE_COLLECTION_LOGICAL_NAME, } from './portalPublicArtifacts.js';
2
+ import { asRecord, assertExactKeys, assertPublicJson, assertUnique, assertValue, isoTimestamp, nonNegativeInteger, requiredBoolean, requiredEnum, requiredString, stringArray, validateAccountScope, validateFreshness, validateJsonObject, validatePortalEnvelope, validateResourceScope, } from './portalPublicArtifactValidationCommon.js';
3
+ import { validateAwsPortalBillingBlock, validateAwsPortalRecommendationCoverage } from './portalPublicArtifactEvidenceValidation.js';
4
+ import { validateRecommendationActionability, validateResourceBillingEvidence, validateTemplateProvenanceSources, } from './portalPublicArtifactNestedEvidenceValidation.js';
5
+ const DISCOVERY_FAMILIES = [
6
+ 'ec2-instance',
7
+ 'ebs-volume',
8
+ 'efs-file-system',
9
+ 'lambda-function',
10
+ 'elasticache-cache-cluster',
11
+ 'elasticache-serverless-cache',
12
+ 'vpc',
13
+ 'subnet',
14
+ 'route-table',
15
+ 'internet-gateway',
16
+ 'nat-gateway',
17
+ 'network-interface',
18
+ 'virtual-private-gateway',
19
+ 'load-balancer-v2',
20
+ 'classic-load-balancer',
21
+ 'security-group',
22
+ 'rds-db-cluster',
23
+ 'rds-db-instance',
24
+ ];
25
+ const METRIC_FAMILIES = [
26
+ 'ec2-core-utilization',
27
+ 'ebs-volume-performance',
28
+ 'lambda-function-performance',
29
+ 'load-balancer-v2-network-traffic',
30
+ 'load-balancer-v2-application-traffic',
31
+ 'rds-db-instance-utilization',
32
+ ];
33
+ const RECOMMENDATION_SOURCES = [
34
+ 'custom',
35
+ 'compute-optimizer',
36
+ 'cost-optimization-hub',
37
+ 'resilience-hub',
38
+ 'security-hub',
39
+ 'trusted-advisor',
40
+ 'well-architected',
41
+ ];
42
+ const CURRENT_KEYS = [
43
+ 'schemaVersion',
44
+ 'portalSchemaVersion',
45
+ 'provider',
46
+ 'accountId',
47
+ 'artifactType',
48
+ 'artifactGeneration',
49
+ 'generatedAt',
50
+ 'logicalName',
51
+ 'scope',
52
+ ];
53
+ export function validateAwsPortalResourceCollectionDetailArtifact(value) {
54
+ const artifact = asRecord(value, 'artifact');
55
+ const { accountId } = validatePortalEnvelope(artifact, 'resource-collection', [...CURRENT_KEYS, 'summary', 'coverage', 'resources']);
56
+ assertValue(artifact.logicalName, AWS_PORTAL_RESOURCE_COLLECTION_LOGICAL_NAME, 'artifact.logicalName');
57
+ const scope = validateResourceScope(artifact.scope, accountId, 'artifact.scope');
58
+ validateResourceSummary(artifact.summary, 'artifact.summary');
59
+ validateResourceCoverage(artifact.coverage, accountId, scope);
60
+ if (!Array.isArray(artifact.resources))
61
+ throw new Error('artifact.resources must be an array.');
62
+ validateResources(artifact.resources, scope.resourceRegions);
63
+ const summary = asRecord(artifact.summary, 'artifact.summary');
64
+ if (summary.returnedResourceCount !== artifact.resources.length)
65
+ throw new Error('artifact.summary.returnedResourceCount must equal artifact.resources length.');
66
+ if (Number(summary.totalDiscoveredResourceCount) < Number(summary.returnedResourceCount))
67
+ throw new Error('artifact.summary.totalDiscoveredResourceCount must not be less than returnedResourceCount.');
68
+ assertPublicJson(artifact, 'artifact');
69
+ return value;
70
+ }
71
+ export function validateAwsPortalAccountSummaryDetailArtifact(value) {
72
+ const artifact = asRecord(value, 'artifact');
73
+ const { accountId } = validatePortalEnvelope(artifact, 'account-summary', [
74
+ ...CURRENT_KEYS,
75
+ 'account',
76
+ 'billing',
77
+ 'discovery',
78
+ 'metrics',
79
+ 'recommendations',
80
+ ]);
81
+ assertValue(artifact.logicalName, AWS_PORTAL_ACCOUNT_SUMMARY_LOGICAL_NAME, 'artifact.logicalName');
82
+ const scope = validateAccountScope(artifact.scope, accountId, 'artifact.scope');
83
+ validateAccount(artifact.account, accountId);
84
+ validateAwsPortalBillingBlock(artifact.billing, 'artifact.billing', scope.billing, accountId, true);
85
+ validateDiscovery(artifact.discovery, scope.resourceRegions, 'artifact.discovery');
86
+ validateMetrics(artifact.metrics, scope.resourceRegions, 'artifact.metrics', 'account');
87
+ validateAwsPortalRecommendationCoverage(artifact.recommendations, accountId, scope.resourceRegions, 'artifact.recommendations', RECOMMENDATION_SOURCES, true);
88
+ assertPublicJson(artifact, 'artifact');
89
+ return value;
90
+ }
91
+ function validateResourceSummary(value, field) {
92
+ const summary = asRecord(value, field);
93
+ const keys = [
94
+ 'totalDiscoveredResourceCount',
95
+ 'returnedResourceCount',
96
+ 'emptyCollection',
97
+ 'truncated',
98
+ 'unmatchedRecommendationResourceCount',
99
+ 'unmatchedBillingExpenseCount',
100
+ ];
101
+ assertExactKeys(summary, keys, field);
102
+ keys.filter(key => !['emptyCollection', 'truncated'].includes(key)).forEach(key => nonNegativeInteger(summary[key], `${field}.${key}`));
103
+ requiredBoolean(summary.emptyCollection, `${field}.emptyCollection`);
104
+ requiredBoolean(summary.truncated, `${field}.truncated`);
105
+ if (summary.emptyCollection === true && Number(summary.returnedResourceCount) !== 0)
106
+ throw new Error(`${field}.emptyCollection requires zero returned resources.`);
107
+ }
108
+ function validateResourceCoverage(value, accountId, scope) {
109
+ const coverage = asRecord(value, 'artifact.coverage');
110
+ assertExactKeys(coverage, ['billing', 'discovery', 'metrics', 'recommendations', 'virtualTags'], 'artifact.coverage');
111
+ validateAwsPortalBillingBlock(coverage.billing, 'artifact.coverage.billing', scope.billing, accountId, false);
112
+ validateDiscovery(coverage.discovery, scope.resourceRegions, 'artifact.coverage.discovery');
113
+ validateMetrics(coverage.metrics, scope.resourceRegions, 'artifact.coverage.metrics', 'resource');
114
+ validateAwsPortalRecommendationCoverage(coverage.recommendations, accountId, scope.resourceRegions, 'artifact.coverage.recommendations', RECOMMENDATION_SOURCES, false);
115
+ if (coverage.virtualTags !== undefined)
116
+ validateVirtualTags(coverage.virtualTags);
117
+ }
118
+ function validateDiscovery(value, regions, field) {
119
+ const discovery = asRecord(value, field);
120
+ assertExactKeys(discovery, ['families'], field);
121
+ if (!Array.isArray(discovery.families))
122
+ throw new Error(`${field}.families must be an array.`);
123
+ const identities = [];
124
+ discovery.families.forEach((entry, index) => {
125
+ const itemField = `${field}.families[${index}]`;
126
+ const family = asRecord(entry, itemField);
127
+ assertExactKeys(family, ['family', 'resourceRegion', 'freshness', 'summary', 'groupedTotals'], itemField);
128
+ const familyName = requiredEnum(family.family, DISCOVERY_FAMILIES, `${itemField}.family`);
129
+ const region = requiredString(family.resourceRegion, `${itemField}.resourceRegion`);
130
+ if (!regions.includes(region))
131
+ throw new Error(`${itemField}.resourceRegion is outside artifact scope.`);
132
+ identities.push(`${familyName}|${region}`);
133
+ validateFreshness(family.freshness, `${itemField}.freshness`, 'lastSuccessfulRefreshAt');
134
+ validateJsonObject(family.summary, `${itemField}.summary`);
135
+ validateJsonObject(family.groupedTotals, `${itemField}.groupedTotals`);
136
+ });
137
+ assertUnique(identities, `${field}.families`);
138
+ }
139
+ function validateMetrics(value, regions, field, kind) {
140
+ const metrics = asRecord(value, field);
141
+ assertExactKeys(metrics, ['families'], field);
142
+ if (!Array.isArray(metrics.families))
143
+ throw new Error(`${field}.families must be an array.`);
144
+ const identities = [];
145
+ metrics.families.forEach((entry, index) => {
146
+ const itemField = `${field}.families[${index}]`;
147
+ const family = asRecord(entry, itemField);
148
+ const keys = kind === 'resource'
149
+ ? ['metricFamily', 'resourceRegion', 'requestedMonths', 'availableMonths', 'missingMonths', 'coverage', 'summary']
150
+ : ['metricFamily', 'resourceRegion', 'status', 'requestedMonths', 'availableMonths', 'missingMonths', 'freshness', 'readiness', 'summary'];
151
+ assertExactKeys(family, keys, itemField);
152
+ const metricFamily = requiredEnum(family.metricFamily, METRIC_FAMILIES, `${itemField}.metricFamily`);
153
+ const region = requiredString(family.resourceRegion, `${itemField}.resourceRegion`);
154
+ if (!regions.includes(region))
155
+ throw new Error(`${itemField}.resourceRegion is outside artifact scope.`);
156
+ identities.push(`${metricFamily}|${region}`);
157
+ if (family.status !== undefined)
158
+ requiredEnum(family.status, ['available', 'not-found'], `${itemField}.status`);
159
+ const requestedMonths = stringArray(family.requestedMonths, `${itemField}.requestedMonths`);
160
+ const availableMonths = stringArray(family.availableMonths, `${itemField}.availableMonths`);
161
+ const missingMonths = stringArray(family.missingMonths, `${itemField}.missingMonths`);
162
+ [...requestedMonths, ...availableMonths, ...missingMonths].forEach(month => {
163
+ if (!/^\d{4}-(0[1-9]|1[0-2])$/.test(month))
164
+ throw new Error(`${itemField} contains a non-canonical billing month: ${month}.`);
165
+ });
166
+ const classifiedMonths = [...availableMonths, ...missingMonths].sort();
167
+ assertUnique(classifiedMonths, `${itemField} classified months`);
168
+ if (JSON.stringify(requestedMonths) !== JSON.stringify(classifiedMonths))
169
+ throw new Error(`${itemField} requestedMonths must equal availableMonths plus missingMonths.`);
170
+ if (family.freshness !== undefined)
171
+ validateFreshness(family.freshness, `${itemField}.freshness`, 'lastSuccessfulRefreshAt');
172
+ if (family.readiness !== undefined)
173
+ validateJsonObject(family.readiness, `${itemField}.readiness`);
174
+ if (family.coverage !== undefined)
175
+ validateJsonObject(family.coverage, `${itemField}.coverage`);
176
+ if (family.summary !== undefined)
177
+ validateJsonObject(family.summary, `${itemField}.summary`);
178
+ });
179
+ assertUnique(identities, `${field}.families`);
180
+ }
181
+ function validateVirtualTags(value) {
182
+ const tags = asRecord(value, 'artifact.coverage.virtualTags');
183
+ assertExactKeys(tags, ['status', 'source', 'generatedAt', 'rulesHash', 'enabledRuleCount', 'appliedRuleCount', 'taggedResourceCount', 'unsupportedRuleCount', 'reason'], 'artifact.coverage.virtualTags');
184
+ requiredEnum(tags.status, ['available', 'missing', 'malformed', 'unsupported'], 'artifact.coverage.virtualTags.status');
185
+ assertValue(tags.source, 'spotto-tags', 'artifact.coverage.virtualTags.source');
186
+ isoTimestamp(tags.generatedAt, 'artifact.coverage.virtualTags.generatedAt');
187
+ if (tags.rulesHash !== undefined && !/^[a-f0-9]{64}$/.test(String(tags.rulesHash)))
188
+ throw new Error('artifact.coverage.virtualTags.rulesHash must be a SHA-256.');
189
+ ['enabledRuleCount', 'appliedRuleCount', 'taggedResourceCount', 'unsupportedRuleCount'].forEach(key => nonNegativeInteger(tags[key], `artifact.coverage.virtualTags.${key}`));
190
+ if (tags.reason !== undefined)
191
+ requiredString(tags.reason, 'artifact.coverage.virtualTags.reason');
192
+ }
193
+ function validateResources(value, regions) {
194
+ if (!Array.isArray(value))
195
+ throw new Error('artifact.resources must be an array.');
196
+ const stableKeys = [];
197
+ value.forEach((entry, index) => {
198
+ const field = `artifact.resources[${index}]`;
199
+ const resource = asRecord(entry, field);
200
+ assertExactKeys(resource, [
201
+ 'stableKey',
202
+ 'family',
203
+ 'resourceRegion',
204
+ 'resourceType',
205
+ 'resourceArn',
206
+ 'resourceId',
207
+ 'resourceName',
208
+ 'tags',
209
+ 'spottoTags',
210
+ 'discovery',
211
+ 'billing',
212
+ 'metrics',
213
+ 'recommendations',
214
+ ], field);
215
+ stableKeys.push(requiredString(resource.stableKey, `${field}.stableKey`));
216
+ requiredEnum(resource.family, DISCOVERY_FAMILIES, `${field}.family`);
217
+ const region = requiredString(resource.resourceRegion, `${field}.resourceRegion`);
218
+ if (!regions.includes(region))
219
+ throw new Error(`${field}.resourceRegion is outside artifact scope.`);
220
+ requiredString(resource.resourceType, `${field}.resourceType`);
221
+ ['resourceArn', 'resourceId', 'resourceName'].forEach(key => {
222
+ if (resource[key] !== undefined)
223
+ requiredString(resource[key], `${field}.${key}`);
224
+ });
225
+ if (resource.tags !== undefined)
226
+ validateStringMap(resource.tags, `${field}.tags`);
227
+ if (resource.spottoTags !== undefined)
228
+ validateJsonObject(resource.spottoTags, `${field}.spottoTags`);
229
+ const discovery = asRecord(resource.discovery, `${field}.discovery`);
230
+ assertExactKeys(discovery, ['freshness', 'summary'], `${field}.discovery`);
231
+ validateFreshness(discovery.freshness, `${field}.discovery.freshness`, 'lastSuccessfulRefreshAt');
232
+ validateJsonObject(discovery.summary, `${field}.discovery.summary`);
233
+ if (resource.billing !== undefined)
234
+ validateResourceBillingEvidence(resource.billing, `${field}.billing`);
235
+ if (resource.metrics !== undefined)
236
+ validateResourceMetrics(resource.metrics, `${field}.metrics`);
237
+ if (resource.recommendations !== undefined)
238
+ validateResourceRecommendations(resource.recommendations, `${field}.recommendations`);
239
+ });
240
+ assertUnique(stableKeys, 'artifact.resources');
241
+ }
242
+ function validateResourceMetrics(value, field) {
243
+ const metrics = asRecord(value, field);
244
+ assertExactKeys(metrics, ['supported', 'families'], field);
245
+ requiredBoolean(metrics.supported, `${field}.supported`);
246
+ if (!Array.isArray(metrics.families))
247
+ throw new Error(`${field}.families must be an array.`);
248
+ metrics.families.forEach((entry, index) => {
249
+ const family = asRecord(entry, `${field}.families[${index}]`);
250
+ assertExactKeys(family, ['metricFamily', 'summary', 'coverage'], `${field}.families[${index}]`);
251
+ requiredEnum(family.metricFamily, METRIC_FAMILIES, `${field}.families[${index}].metricFamily`);
252
+ validateJsonObject(family.summary, `${field}.families[${index}].summary`);
253
+ validateJsonObject(family.coverage, `${field}.families[${index}].coverage`);
254
+ });
255
+ }
256
+ function validateResourceRecommendations(value, field) {
257
+ const recommendations = asRecord(value, field);
258
+ assertExactKeys(recommendations, ['matchedRecommendationCount', 'sources', 'actionability', 'templateProvenance', 'counts', 'savings'], field);
259
+ nonNegativeInteger(recommendations.matchedRecommendationCount, `${field}.matchedRecommendationCount`);
260
+ stringArray(recommendations.sources, `${field}.sources`).forEach(source => requiredEnum(source, RECOMMENDATION_SOURCES, `${field}.sources`));
261
+ if (recommendations.actionability !== undefined) {
262
+ validateRecommendationActionability(recommendations.actionability, `${field}.actionability`);
263
+ }
264
+ if (recommendations.templateProvenance !== undefined)
265
+ validateTemplateProvenance(recommendations.templateProvenance, `${field}.templateProvenance`);
266
+ validateJsonObject(recommendations.counts, `${field}.counts`);
267
+ validateJsonObject(recommendations.savings, `${field}.savings`);
268
+ }
269
+ function validateTemplateProvenance(value, field) {
270
+ const provenance = asRecord(value, field);
271
+ assertExactKeys(provenance, [
272
+ 'status',
273
+ 'templateBackedRecommendationCount',
274
+ 'missingTemplateRecommendationCount',
275
+ 'internalTemplateCount',
276
+ 'externalTemplateCount',
277
+ 'confidenceBackedRecommendationCount',
278
+ 'templateRecommendationWithoutConfidenceCount',
279
+ 'lowestConfidenceSources',
280
+ 'minimumConfidencePercentage',
281
+ 'maximumConfidencePercentage',
282
+ 'averageConfidencePercentage',
283
+ ], field);
284
+ requiredEnum(provenance.status, ['available-no-template-items', 'missing-template-evidence', 'missing-confidence-evidence', 'available-with-confidence'], `${field}.status`);
285
+ [
286
+ 'templateBackedRecommendationCount',
287
+ 'missingTemplateRecommendationCount',
288
+ 'internalTemplateCount',
289
+ 'externalTemplateCount',
290
+ 'confidenceBackedRecommendationCount',
291
+ 'templateRecommendationWithoutConfidenceCount',
292
+ ].forEach(key => nonNegativeInteger(provenance[key], `${field}.${key}`));
293
+ validateTemplateProvenanceSources(provenance.lowestConfidenceSources, `${field}.lowestConfidenceSources`);
294
+ ['minimumConfidencePercentage', 'maximumConfidencePercentage', 'averageConfidencePercentage'].forEach(key => {
295
+ if (provenance[key] !== undefined && (typeof provenance[key] !== 'number' || !Number.isFinite(provenance[key])))
296
+ throw new Error(`${field}.${key} must be finite.`);
297
+ });
298
+ }
299
+ function validateAccount(value, accountId) {
300
+ const account = asRecord(value, 'artifact.account');
301
+ assertExactKeys(account, [
302
+ 'metadataFound',
303
+ 'providerAccountId',
304
+ 'cloudAccountId',
305
+ 'accountName',
306
+ 'validatedAt',
307
+ 'providerPartition',
308
+ 'summary',
309
+ 'firstUsefulRecommendationReadiness',
310
+ ], 'artifact.account');
311
+ requiredBoolean(account.metadataFound, 'artifact.account.metadataFound');
312
+ assertValue(account.providerAccountId, accountId, 'artifact.account.providerAccountId');
313
+ ['cloudAccountId', 'accountName', 'providerPartition'].forEach(key => {
314
+ if (account[key] !== undefined)
315
+ requiredString(account[key], `artifact.account.${key}`);
316
+ });
317
+ isoTimestamp(account.validatedAt, 'artifact.account.validatedAt');
318
+ validateJsonObject(account.summary, 'artifact.account.summary');
319
+ if (account.firstUsefulRecommendationReadiness !== undefined)
320
+ validateJsonObject(account.firstUsefulRecommendationReadiness, 'artifact.account.firstUsefulRecommendationReadiness');
321
+ }
322
+ function validateStringMap(value, field) {
323
+ const map = asRecord(value, field);
324
+ Object.entries(map).forEach(([key, item]) => {
325
+ requiredString(key, `${field} key`);
326
+ requiredString(item, `${field}.${key}`);
327
+ });
328
+ }
@@ -0,0 +1,294 @@
1
+ import { asRecord, assertExactKeys, assertUnique, assertValue, finiteNumber, isoTimestamp, nonNegativeInteger, requiredBoolean, requiredString, stringArray, validateFreshness, validateJsonArray, validateJsonObject, } from './portalPublicArtifactValidationCommon.js';
2
+ export function validateAwsPortalBillingBlock(value, field, outerBilling, accountId, accountBody) {
3
+ const billing = asRecord(value, field);
4
+ const accountFields = [
5
+ 'costViewByCurrency',
6
+ 'groupedCosts',
7
+ 'commitmentSpend',
8
+ 'resourceAttribution',
9
+ 'calendarSummary',
10
+ 'periodSummary',
11
+ 'spendTrend',
12
+ 'comparison',
13
+ ];
14
+ const resourceFields = ['pricingReferenceEstimation', 'estimatedBillingAuthority'];
15
+ // AWS-only window evidence. Optional so artifacts published before the
16
+ // rolling resource window stay valid, but validated whenever present: a block
17
+ // that names one anchor period while its totals span three has to say so.
18
+ const windowFields = ['window', 'contributingBillingPeriods', 'windowCoverage'];
19
+ assertExactKeys(billing, ['scope', 'freshness', 'summary', 'totalsByCurrency', ...windowFields, ...(accountBody ? accountFields : resourceFields)], field);
20
+ validateBillingEvidenceScope(billing.scope, outerBilling, accountId, `${field}.scope`);
21
+ validateBillingEvidenceWindow(billing, outerBilling, field);
22
+ validateFreshness(billing.freshness, `${field}.freshness`, 'lastSuccessfulImportAt');
23
+ validateBillingSummary(billing.summary, `${field}.summary`);
24
+ validateBillingGroupedResult(billing.totalsByCurrency, `${field}.totalsByCurrency`);
25
+ if (!accountBody) {
26
+ if (billing.pricingReferenceEstimation !== undefined)
27
+ validateJsonObject(billing.pricingReferenceEstimation, `${field}.pricingReferenceEstimation`);
28
+ if (billing.estimatedBillingAuthority !== undefined)
29
+ validateJsonObject(billing.estimatedBillingAuthority, `${field}.estimatedBillingAuthority`);
30
+ return;
31
+ }
32
+ validateJsonArray(billing.costViewByCurrency, `${field}.costViewByCurrency`);
33
+ const grouped = asRecord(billing.groupedCosts, `${field}.groupedCosts`);
34
+ assertExactKeys(grouped, ['byChargeCategory', 'byServiceCode', 'byServiceCategory'], `${field}.groupedCosts`);
35
+ validateBillingGroupedResult(grouped.byChargeCategory, `${field}.groupedCosts.byChargeCategory`, 'chargeCategory');
36
+ validateBillingGroupedResult(grouped.byServiceCode, `${field}.groupedCosts.byServiceCode`, 'serviceCode');
37
+ if (grouped.byServiceCategory !== undefined)
38
+ validateBillingGroupedResult(grouped.byServiceCategory, `${field}.groupedCosts.byServiceCategory`, 'serviceCategory');
39
+ ['commitmentSpend', 'resourceAttribution', 'calendarSummary', 'periodSummary', 'spendTrend', 'comparison'].forEach(key => {
40
+ if (billing[key] !== undefined)
41
+ validateJsonObject(billing[key], `${field}.${key}`);
42
+ });
43
+ }
44
+ export function validateAwsPortalRecommendationCoverage(value, accountId, regions, field, sources, includeTrust) {
45
+ const recommendations = asRecord(value, field);
46
+ assertExactKeys(recommendations, ['sources'], field);
47
+ if (!Array.isArray(recommendations.sources))
48
+ throw new Error(`${field}.sources must be an array.`);
49
+ const seenSources = [];
50
+ recommendations.sources.forEach((entry, index) => {
51
+ const itemField = `${field}.sources[${index}]`;
52
+ const source = asRecord(entry, itemField);
53
+ assertExactKeys(source, includeTrust
54
+ ? ['source', 'scope', 'summary', 'counts', 'savings', 'sourceEvidence', 'actionHealth']
55
+ : ['source', 'scope', 'summary', 'counts', 'savings'], itemField);
56
+ const sourceName = requiredString(source.source, `${itemField}.source`);
57
+ if (!sources.includes(sourceName))
58
+ throw new Error(`${itemField}.source is not declared.`);
59
+ seenSources.push(sourceName);
60
+ const scope = asRecord(source.scope, `${itemField}.scope`);
61
+ assertExactKeys(scope, ['provider', 'source', 'scope', 'accountId', 'resourceRegions'], `${itemField}.scope`);
62
+ assertValue(scope.provider, 'aws', `${itemField}.scope.provider`);
63
+ assertValue(scope.source, sourceName, `${itemField}.scope.source`);
64
+ assertValue(scope.scope, 'account', `${itemField}.scope.scope`);
65
+ assertValue(scope.accountId, accountId, `${itemField}.scope.accountId`);
66
+ const nestedRegions = stringArray(scope.resourceRegions, `${itemField}.scope.resourceRegions`);
67
+ if (JSON.stringify(nestedRegions) !== JSON.stringify(regions))
68
+ throw new Error(`${itemField}.scope.resourceRegions must match artifact scope exactly.`);
69
+ validateRecommendationSummary(source.summary, `${itemField}.summary`);
70
+ validateRecommendationCounts(source.counts, `${itemField}.counts`);
71
+ validateRecommendationSavings(source.savings, `${itemField}.savings`);
72
+ if (source.sourceEvidence !== undefined)
73
+ validateRecommendationSourceEvidence(source.sourceEvidence, `${itemField}.sourceEvidence`);
74
+ if (source.actionHealth !== undefined)
75
+ validateRecommendationActionHealth(source.actionHealth, `${itemField}.actionHealth`);
76
+ });
77
+ assertUnique(seenSources, `${field}.sources`);
78
+ }
79
+ function validateBillingEvidenceScope(value, outerBilling, accountId, field) {
80
+ const scope = asRecord(value, field);
81
+ assertExactKeys(scope, ['provider', 'source', 'scope', 'accountId', 'reportName', 'billingPeriod'], field);
82
+ assertValue(scope.provider, 'aws', `${field}.provider`);
83
+ assertValue(scope.source, outerBilling.source, `${field}.source`);
84
+ assertValue(scope.scope, 'account', `${field}.scope`);
85
+ assertValue(scope.accountId, accountId, `${field}.accountId`);
86
+ assertValue(scope.reportName, outerBilling.source === 'cur' ? outerBilling.reportName : outerBilling.exportName, `${field}.reportName`);
87
+ if (JSON.stringify(scope.billingPeriod) !== JSON.stringify(outerBilling.billingPeriod))
88
+ throw new Error(`${field}.billingPeriod must match artifact scope exactly.`);
89
+ }
90
+ /**
91
+ * Validates the rolling window a billing block measures, and binds it to the
92
+ * anchor period the artifact scope names.
93
+ *
94
+ * The window may start inside the preceding billing period — that is the whole
95
+ * point of a run-anchored rolling 30 days — but it must end no later than the
96
+ * day after the anchor period ends, and every period it claims contributed must
97
+ * form an adjacent chain that covers it, or the block must admit
98
+ * `partial-billing-period-coverage`.
99
+ */
100
+ function validateBillingEvidenceWindow(billing, outerBilling, field) {
101
+ const hasWindow = billing.window !== undefined;
102
+ if (billing.contributingBillingPeriods !== undefined && !hasWindow)
103
+ throw new Error(`${field}.contributingBillingPeriods requires ${field}.window.`);
104
+ if (billing.windowCoverage !== undefined && !hasWindow)
105
+ throw new Error(`${field}.windowCoverage requires ${field}.window.`);
106
+ if (!hasWindow)
107
+ return;
108
+ const window = asRecord(billing.window, `${field}.window`);
109
+ assertExactKeys(window, ['kind', 'dayCount', 'startDateInclusive', 'endDateExclusive'], `${field}.window`);
110
+ assertValue(window.kind, 'last-30-days', `${field}.window.kind`);
111
+ assertValue(window.dayCount, 30, `${field}.window.dayCount`);
112
+ const startDateInclusive = utcDate(window.startDateInclusive, `${field}.window.startDateInclusive`);
113
+ const endDateExclusive = utcDate(window.endDateExclusive, `${field}.window.endDateExclusive`);
114
+ if (shiftUtcDate(endDateExclusive, -30) !== startDateInclusive)
115
+ throw new Error(`${field}.window must cover exactly 30 UTC days.`);
116
+ if (endDateExclusive > shiftUtcDate(outerBilling.billingPeriod.end, 1))
117
+ throw new Error(`${field}.window.endDateExclusive must not run past the day after the artifact billing period.`);
118
+ if (billing.contributingBillingPeriods === undefined)
119
+ return;
120
+ if (!Array.isArray(billing.contributingBillingPeriods))
121
+ throw new Error(`${field}.contributingBillingPeriods must be an array.`);
122
+ const periods = billing.contributingBillingPeriods.map((entry, index) => {
123
+ const itemField = `${field}.contributingBillingPeriods[${index}]`;
124
+ const period = asRecord(entry, itemField);
125
+ assertExactKeys(period, ['start', 'end'], itemField);
126
+ const start = utcDate(period.start, `${itemField}.start`);
127
+ const end = utcDate(period.end, `${itemField}.end`);
128
+ if (end < start)
129
+ throw new Error(`${itemField}.end must not precede ${itemField}.start.`);
130
+ return { start, end };
131
+ });
132
+ assertUnique(periods.map(period => `${period.start}|${period.end}`), `${field}.contributingBillingPeriods`);
133
+ if (periods.some((period, index) => index > 0 && period.start <= periods[index - 1].start))
134
+ throw new Error(`${field}.contributingBillingPeriods must be ordered by start date.`);
135
+ const anchor = `${outerBilling.billingPeriod.start}|${outerBilling.billingPeriod.end}`;
136
+ if (periods.length > 0 && !periods.some(period => `${period.start}|${period.end}` === anchor))
137
+ throw new Error(`${field}.contributingBillingPeriods must include the artifact billing period.`);
138
+ const complete = periods.length > 0 &&
139
+ periods[0].start <= startDateInclusive &&
140
+ periods[periods.length - 1].end >= shiftUtcDate(endDateExclusive, -1) &&
141
+ periods.every((period, index) => index === 0 || shiftUtcDate(periods[index - 1].end, 1) === period.start);
142
+ const coverage = billing.windowCoverage;
143
+ if (coverage !== undefined && coverage !== 'complete' && coverage !== 'partial-billing-period-coverage')
144
+ throw new Error(`${field}.windowCoverage must be complete or partial-billing-period-coverage.`);
145
+ if (coverage === 'complete' && !complete)
146
+ throw new Error(`${field}.windowCoverage claims complete coverage its contributingBillingPeriods do not provide.`);
147
+ }
148
+ function utcDate(value, field) {
149
+ const date = requiredString(value, field);
150
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(date) || new Date(`${date}T00:00:00.000Z`).toISOString().slice(0, 10) !== date)
151
+ throw new Error(`${field} must be a YYYY-MM-DD UTC date.`);
152
+ return date;
153
+ }
154
+ function shiftUtcDate(date, days) {
155
+ const parsed = new Date(`${date}T00:00:00.000Z`);
156
+ parsed.setUTCDate(parsed.getUTCDate() + days);
157
+ return parsed.toISOString().slice(0, 10);
158
+ }
159
+ function validateBillingSummary(value, field) {
160
+ const summary = asRecord(value, field);
161
+ assertExactKeys(summary, ['totalPersistedExpenseCount', 'emptyScope', 'metadataFound', 'metadataMatchesRequestedBillingPeriod'], field);
162
+ nonNegativeInteger(summary.totalPersistedExpenseCount, `${field}.totalPersistedExpenseCount`);
163
+ requiredBoolean(summary.emptyScope, `${field}.emptyScope`);
164
+ requiredBoolean(summary.metadataFound, `${field}.metadataFound`);
165
+ requiredBoolean(summary.metadataMatchesRequestedBillingPeriod, `${field}.metadataMatchesRequestedBillingPeriod`);
166
+ }
167
+ function validateBillingGroupedResult(value, field, label) {
168
+ const result = asRecord(value, field);
169
+ assertExactKeys(result, ['totalGroupCount', 'returnedGroupCount', 'truncated', 'items'], field);
170
+ const total = nonNegativeInteger(result.totalGroupCount, `${field}.totalGroupCount`);
171
+ const returned = nonNegativeInteger(result.returnedGroupCount, `${field}.returnedGroupCount`);
172
+ requiredBoolean(result.truncated, `${field}.truncated`);
173
+ if (returned > total)
174
+ throw new Error(`${field}.returnedGroupCount must not exceed totalGroupCount.`);
175
+ if (!Array.isArray(result.items) || result.items.length !== returned)
176
+ throw new Error(`${field}.items length must equal returnedGroupCount.`);
177
+ const identities = [];
178
+ result.items.forEach((entry, index) => {
179
+ const itemField = `${field}.items[${index}]`;
180
+ const item = asRecord(entry, itemField);
181
+ assertExactKeys(item, [...(label ? [label] : []), 'currency', 'expenseCount', 'baseCostAmount', 'amortizedCostAmount', 'amortizedExpenseCount'], itemField);
182
+ if (label)
183
+ requiredString(item[label], `${itemField}.${label}`);
184
+ const currency = requiredString(item.currency, `${itemField}.currency`);
185
+ nonNegativeInteger(item.expenseCount, `${itemField}.expenseCount`);
186
+ finiteNumber(item.baseCostAmount, `${itemField}.baseCostAmount`);
187
+ if (item.amortizedCostAmount !== undefined)
188
+ finiteNumber(item.amortizedCostAmount, `${itemField}.amortizedCostAmount`);
189
+ if (item.amortizedExpenseCount !== undefined)
190
+ nonNegativeInteger(item.amortizedExpenseCount, `${itemField}.amortizedExpenseCount`);
191
+ identities.push(`${label ? String(item[label]) : ''}|${currency}`);
192
+ });
193
+ assertUnique(identities, `${field}.items`);
194
+ }
195
+ function validateRecommendationSummary(value, field) {
196
+ const summary = asRecord(value, field);
197
+ const counts = [
198
+ 'totalStateCount',
199
+ 'currentStateCount',
200
+ 'missingStateCount',
201
+ 'snapshotBackedCount',
202
+ 'orphanSnapshotCount',
203
+ 'matchedStateCount',
204
+ 'filteredOutStateCount',
205
+ ];
206
+ assertExactKeys(summary, [...counts, 'emptyScope', 'emptyResult'], field);
207
+ counts.forEach(key => nonNegativeInteger(summary[key], `${field}.${key}`));
208
+ requiredBoolean(summary.emptyScope, `${field}.emptyScope`);
209
+ requiredBoolean(summary.emptyResult, `${field}.emptyResult`);
210
+ }
211
+ function validateRecommendationCounts(value, field) {
212
+ const counts = asRecord(value, field);
213
+ const groups = [
214
+ ['byLifecycleStatus', 'lifecycleStatus'],
215
+ ['bySourcePresence', 'sourcePresence'],
216
+ ['byCategory', 'category'],
217
+ ['byRegion', 'region'],
218
+ ['bySourceService', 'sourceService'],
219
+ ['byResourceType', 'resourceType'],
220
+ ['byActionType', 'actionType'],
221
+ ['byImplementationEffort', 'implementationEffort'],
222
+ ];
223
+ assertExactKeys(counts, groups.map(([group]) => group), field);
224
+ groups.forEach(([group, label]) => validateCountGroup(counts[group], `${field}.${group}`, label));
225
+ }
226
+ function validateCountGroup(value, field, label) {
227
+ if (!Array.isArray(value))
228
+ throw new Error(`${field} must be an array.`);
229
+ const identities = [];
230
+ value.forEach((entry, index) => {
231
+ const item = asRecord(entry, `${field}[${index}]`);
232
+ assertExactKeys(item, [label, 'count'], `${field}[${index}]`);
233
+ identities.push(requiredString(item[label], `${field}[${index}].${label}`));
234
+ nonNegativeInteger(item.count, `${field}[${index}].count`);
235
+ });
236
+ assertUnique(identities, field);
237
+ }
238
+ function validateRecommendationSavings(value, field) {
239
+ const savings = asRecord(value, field);
240
+ const counts = [
241
+ 'snapshotBackedMatchedCount',
242
+ 'positiveEstimatedSavingsRecommendationCount',
243
+ 'nonPositiveEstimatedSavingsCount',
244
+ 'missingEstimatedSavingsCount',
245
+ 'missingCurrencyCodeCount',
246
+ ];
247
+ assertExactKeys(savings, [...counts, 'emptySavings', 'mixedCurrencies', 'byCurrency'], field);
248
+ counts.forEach(key => nonNegativeInteger(savings[key], `${field}.${key}`));
249
+ requiredBoolean(savings.emptySavings, `${field}.emptySavings`);
250
+ requiredBoolean(savings.mixedCurrencies, `${field}.mixedCurrencies`);
251
+ if (!Array.isArray(savings.byCurrency))
252
+ throw new Error(`${field}.byCurrency must be an array.`);
253
+ const currencies = [];
254
+ savings.byCurrency.forEach((entry, index) => {
255
+ const itemField = `${field}.byCurrency[${index}]`;
256
+ const item = asRecord(entry, itemField);
257
+ assertExactKeys(item, ['currencyCode', 'recommendationCount', 'totalEstimatedMonthlySavings'], itemField);
258
+ currencies.push(requiredString(item.currencyCode, `${itemField}.currencyCode`));
259
+ nonNegativeInteger(item.recommendationCount, `${itemField}.recommendationCount`);
260
+ finiteNumber(item.totalEstimatedMonthlySavings, `${itemField}.totalEstimatedMonthlySavings`);
261
+ });
262
+ assertUnique(currencies, `${field}.byCurrency`);
263
+ }
264
+ export function validateRecommendationSourceEvidence(value, field) {
265
+ const evidence = asRecord(value, field);
266
+ const baseKeys = [
267
+ 'totalStateCount',
268
+ 'currentStateCount',
269
+ 'missingStateCount',
270
+ 'snapshotBackedCount',
271
+ 'orphanSnapshotCount',
272
+ 'matchedStateCount',
273
+ 'filteredOutStateCount',
274
+ 'emptyScope',
275
+ 'emptyResult',
276
+ ];
277
+ assertExactKeys(evidence, [...baseKeys, 'partialSourceEvidence', 'oldestRecommendationSynchronizedAt', 'latestRecommendationSynchronizedAt', 'exactScopeCoverage'], field);
278
+ baseKeys.filter(key => !['emptyScope', 'emptyResult'].includes(key)).forEach(key => nonNegativeInteger(evidence[key], `${field}.${key}`));
279
+ requiredBoolean(evidence.emptyScope, `${field}.emptyScope`);
280
+ requiredBoolean(evidence.emptyResult, `${field}.emptyResult`);
281
+ requiredBoolean(evidence.partialSourceEvidence, `${field}.partialSourceEvidence`);
282
+ if (evidence.oldestRecommendationSynchronizedAt !== undefined)
283
+ isoTimestamp(evidence.oldestRecommendationSynchronizedAt, `${field}.oldestRecommendationSynchronizedAt`);
284
+ if (evidence.latestRecommendationSynchronizedAt !== undefined)
285
+ isoTimestamp(evidence.latestRecommendationSynchronizedAt, `${field}.latestRecommendationSynchronizedAt`);
286
+ if (evidence.exactScopeCoverage !== undefined)
287
+ validateJsonObject(evidence.exactScopeCoverage, `${field}.exactScopeCoverage`);
288
+ }
289
+ export function validateRecommendationActionHealth(value, field) {
290
+ const health = asRecord(value, field);
291
+ assertExactKeys(health, ['latestActionCount', 'byLatestActionStatus'], field);
292
+ nonNegativeInteger(health.latestActionCount, `${field}.latestActionCount`);
293
+ validateCountGroup(health.byLatestActionStatus, `${field}.byLatestActionStatus`, 'status');
294
+ }