@slicedearth/whoisleuth-cli 2.3.0 → 2.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/NOTICE +6 -0
- package/README.md +19 -15
- package/bin/whoisleuth.mjs +8 -1
- package/cli/archive-content-digest.mjs +14 -0
- package/cli/archive-inspect.mjs +20 -53
- package/cli/arguments.mjs +68 -6
- package/cli/artifact-validation/case-response.mjs +47 -41
- package/cli/artifact-validation/investigation-capsule.mjs +52 -31
- package/cli/artifact-validation/lookup-evidence.mjs +12 -6
- package/cli/artifact-validation/signed-review.mjs +83 -61
- package/cli/artifact-validation/structure-primitives.mjs +1 -105
- package/cli/artifact-verify.mjs +90 -24
- package/cli/assurance-command-runner.mjs +39 -4
- package/cli/bagit-folder.mjs +57 -0
- package/cli/bagit-review.mjs +23 -0
- package/cli/bounded-json.mjs +1 -1
- package/cli/bulk-command-runner.mjs +2 -0
- package/cli/bulk-output.mjs +29 -3
- package/cli/case-command.mjs +195 -0
- package/cli/case-pack.mjs +39 -31
- package/cli/command-argument-grammar.mjs +4 -0
- package/cli/command-reference.mjs +145 -75
- package/cli/completion.mjs +298 -79
- package/cli/config-profile.mjs +7 -5
- package/cli/ct-event-intake.mjs +12 -12
- package/cli/discover.mjs +4 -3
- package/cli/discovery-observation-snapshot.mjs +44 -16
- package/cli/discovery-scan-command-runner.mjs +2 -0
- package/cli/discovery-scan.mjs +7 -4
- package/cli/discovery-workflow.mjs +1 -1
- package/cli/domain-control-monitor.mjs +50 -20
- package/cli/domain-control-observations.mjs +149 -102
- package/cli/errors.mjs +33 -1
- package/cli/evidence-command-runner.mjs +16 -5
- package/cli/evidence-signing.mjs +2 -2
- package/cli/external-observation-mapping.mjs +7 -8
- package/cli/formatters/terminal-lookup.mjs +18 -46
- package/cli/formatters/terminal-shared.mjs +5 -3
- package/cli/formatters/terminal-technology.mjs +114 -0
- package/cli/inline-command-families.mjs +1 -0
- package/cli/interactive-launcher.mjs +2 -160
- package/cli/interchange-report.mjs +4 -8
- package/cli/investigation-artifacts.mjs +176 -0
- package/cli/investigation-checkpoint.mjs +10 -0
- package/cli/investigation-folder.mjs +114 -0
- package/cli/investigation-manifest.mjs +1 -112
- package/cli/investigation-package-review.mjs +103 -0
- package/cli/investigation-plan.mjs +32 -19
- package/cli/investigation-run.mjs +188 -33
- package/cli/local-document-checkpoint.mjs +170 -0
- package/cli/lookup-browser.mjs +5 -12
- package/cli/lookup-command-runner.mjs +17 -6
- package/cli/lookup-diff.mjs +37 -3
- package/cli/lookup-plan.mjs +11 -5
- package/cli/lookup-reconcile.mjs +10 -8
- package/cli/mail-review.mjs +66 -14
- package/cli/manual.mjs +1 -0
- package/cli/network-command-runner.mjs +2 -0
- package/cli/output-file.mjs +31 -5
- package/cli/registry-doctor.mjs +3 -1
- package/cli/registry-support.mjs +1 -1
- package/cli/retained-artifact-diff.mjs +2 -2
- package/cli/review-command-runner.mjs +45 -0
- package/cli/runner.mjs +55 -6
- package/cli/sharing-review.mjs +21 -34
- package/cli/signer-trust.mjs +104 -0
- package/cli/source-reliability.mjs +74 -83
- package/cli/terminal-input.mjs +168 -0
- package/cli/workflow-command-runner.mjs +68 -13
- package/docs/cli-reference.md +76 -30
- package/docs/cli.md +289 -25
- package/lib/abort.mjs +4 -1
- package/lib/availability.mjs +161 -112
- package/lib/bounded-file.mjs +3 -1
- package/lib/bounded-html-document.mjs +125 -0
- package/lib/bounded-json.mjs +43 -3
- package/lib/browser-library-profile.mjs +42 -51
- package/lib/browser-library-worker.mjs +112 -0
- package/lib/client-behavior-profile.mjs +3 -3
- package/lib/ct-search.mjs +11 -4
- package/lib/dns-delegation-health.mjs +24 -14
- package/lib/dns-inheritance-review.mjs +217 -0
- package/lib/dns-intelligence.mjs +36 -23
- package/lib/dns-mx.mjs +1 -1
- package/lib/dnssec-evidence-validation.mjs +11 -3
- package/lib/domain-change-packet.mjs +7 -2
- package/lib/domain-change-review.mjs +14 -8
- package/lib/domain-control-flight-recorder.mjs +219 -118
- package/lib/domain-control-manifest.mjs +117 -57
- package/lib/domain-posture-analysis.mjs +20 -7
- package/lib/domain-posture-parsers.mjs +88 -6
- package/lib/domain-posture.mjs +113 -26
- package/lib/evidence-export-privacy.mjs +561 -0
- package/lib/evidence-export.mjs +11 -592
- package/lib/evidence-report.mjs +3 -1
- package/lib/favicon.mjs +117 -41
- package/lib/generated/cisa-kev-catalog.mjs +12 -4
- package/lib/generated/registrar-standing-catalogue.mjs +7 -7
- package/lib/generated/retire-browser-catalog.mjs +2335 -940
- package/lib/hostname.mjs +2 -38
- package/lib/html-signals.mjs +82 -85
- package/lib/http-evidence-bounds.mjs +2 -1
- package/lib/http-intelligence.mjs +39 -26
- package/lib/lookup-cache.mjs +34 -11
- package/lib/lookup-child-profile-contract.mjs +24 -15
- package/lib/lookup-diagnostics.mjs +24 -1
- package/lib/lookup-progress.mjs +30 -13
- package/lib/lookup-response-contract.mjs +1048 -0
- package/lib/lookup-response.mjs +7 -10
- package/lib/lookup-source-progress.mjs +17 -14
- package/lib/lookup.mjs +48 -27
- package/lib/observed-network-context.mjs +43 -27
- package/lib/outbound-request-bounds.mjs +4 -1
- package/lib/page-fingerprints.mjs +69 -133
- package/lib/page-role-profile.mjs +12 -8
- package/lib/perceptual-hash.mjs +43 -14
- package/lib/rdap-bootstrap.mjs +72 -22
- package/lib/rdap-client.mjs +15 -7
- package/lib/rdap-normalization.mjs +33 -18
- package/lib/rdap-search-workbench.mjs +1 -1
- package/lib/rdap-transport.mjs +47 -20
- package/lib/rdap-types.mjs +2 -1
- package/lib/rdap.mjs +18 -3
- package/lib/registrable-domain.mjs +21 -1
- package/lib/registrar-standing-contract.mjs +1 -0
- package/lib/registry-capabilities.mjs +5 -1
- package/lib/registry-capability-catalogue.mjs +2 -1
- package/lib/registry-dates.mjs +2 -190
- package/lib/registry-insights.mjs +56 -18
- package/lib/response-policy.mjs +86 -48
- package/lib/risk-calibration-summary.mjs +17 -3
- package/lib/rpki-evidence.mjs +8 -5
- package/lib/safe-fetch.mjs +54 -47
- package/lib/security-txt.mjs +10 -7
- package/lib/service-binding-dns.mjs +8 -0
- package/lib/smtp-transport-review.mjs +27 -15
- package/lib/sslbl-certificates.generated.mjs +136 -135
- package/lib/static-html-analysis.mjs +117 -146
- package/lib/structured-data-identity.mjs +1 -0
- package/lib/technology-markup.mjs +100 -0
- package/lib/threat-intelligence-manifests.mjs +11 -139
- package/lib/threat-intelligence-runtime.mjs +24 -52
- package/lib/threat-intelligence-targets.mjs +6 -34
- package/lib/threat-intelligence-types.mjs +39 -0
- package/lib/typosquat-generator.mjs +19 -15
- package/lib/urlhaus-intelligence.mjs +8 -3
- package/lib/urlscan-intelligence.mjs +1 -1
- package/lib/website-security-posture.mjs +30 -6
- package/lib/website-technology.mjs +111 -71
- package/lib/whois-authority.mjs +14 -2
- package/lib/whois-chain.mjs +9 -2
- package/lib/whois-common-formats.mjs +5 -5
- package/lib/whois-contracts.mjs +2 -1
- package/lib/whois-dialects.mjs +2 -2
- package/lib/whois-parser-support.mjs +8 -1
- package/lib/whois-transport.mjs +23 -7
- package/package.json +2 -1
- package/packages/cases/case-attachment-model.mjs +83 -0
- package/packages/cases/case-evidence-model.mjs +156 -62
- package/packages/cases/case-export-input.mjs +53 -0
- package/packages/cases/case-migration-model.mjs +59 -25
- package/packages/cases/case-model.mjs +1 -0
- package/packages/cases/case-recheck-model.mjs +91 -0
- package/packages/cases/case-record-core.mjs +3 -2
- package/packages/cases/case-record-operations.mjs +89 -27
- package/packages/cases/case-record-projection.mjs +23 -20
- package/packages/cases/case-report.mjs +63 -52
- package/packages/cases/case-response-actions.mjs +654 -0
- package/packages/cases/case-response-model.mjs +41 -1129
- package/packages/cases/case-response-outcomes.mjs +317 -0
- package/packages/cases/case-response-packet-render.mjs +153 -0
- package/packages/cases/case-response-packet-types.mjs +1 -0
- package/packages/cases/case-response-packet-vocabulary.mjs +48 -0
- package/packages/cases/case-response-packet.mjs +38 -587
- package/packages/cases/case-response-records.mjs +94 -0
- package/packages/cases/case-response-review-inputs.mjs +371 -0
- package/packages/cases/case-response-values.mjs +85 -0
- package/packages/cases/case-review-package.mjs +45 -0
- package/packages/cases/case-selection.mjs +46 -0
- package/packages/cases/case-storage-model.mjs +25 -0
- package/packages/cases/response-route-freshness.mjs +16 -0
- package/packages/comparison/capture-context.mjs +61 -0
- package/packages/comparison/comparison-ledger-bulk.mjs +4 -1
- package/packages/comparison/page-similarity.mjs +2 -1
- package/packages/comparison/relationship-evidence.mjs +85 -8
- package/packages/comparison/relationship-provenance.mjs +93 -0
- package/packages/contracts/analyst-interchange.mjs +216 -0
- package/packages/contracts/analyst-review-state-contract.mjs +11 -0
- package/packages/contracts/analyst-review-state.mjs +5 -4
- package/packages/contracts/case-portability.mjs +279 -116
- package/packages/contracts/case-views-contract.mjs +12 -0
- package/packages/contracts/case-views.mjs +26 -0
- package/packages/contracts/cli-command-semantics.mjs +1 -0
- package/packages/contracts/cli-lookup.mjs +0 -1
- package/packages/contracts/domain-control-flight-recorder.mjs +80 -42
- package/packages/contracts/domain-control-manifest.mjs +127 -73
- package/packages/contracts/domain-control-monitor.mjs +48 -45
- package/packages/contracts/domain-control-review.mjs +179 -121
- package/packages/contracts/domain-name.mjs +21 -0
- package/packages/contracts/external-observation-interchange.mjs +2 -1
- package/packages/contracts/extracted-domain-lifecycle.mjs +11 -32
- package/packages/contracts/http-url.mjs +2 -0
- package/packages/contracts/investigation-package-limits.mjs +24 -0
- package/packages/contracts/investigation-portability.mjs +56 -27
- package/packages/contracts/investigation-run.mjs +16 -0
- package/packages/contracts/ip-address.mjs +34 -0
- package/packages/contracts/lookup-evidence.mjs +35 -0
- package/packages/contracts/offline-comparison.mjs +1 -2
- package/packages/contracts/page-fingerprints.mjs +15 -0
- package/packages/contracts/risk-calibration.mjs +0 -1
- package/packages/contracts/schema-compatibility.mjs +1 -1
- package/packages/contracts/schema-lifecycle.mjs +83 -153
- package/packages/contracts/selected-file-limits.mjs +6 -0
- package/packages/contracts/vulnerability-identifiers.mjs +5 -0
- package/packages/contracts/web-capture.mjs +10 -0
- package/packages/contracts/workspace-portability.mjs +160 -225
- package/packages/evidence/artifact-integrity.mjs +8 -2
- package/packages/evidence/artifact-structure.mjs +105 -0
- package/packages/evidence/decision-fact.mjs +4 -1
- package/packages/evidence/domain-control-runtime.mjs +155 -27
- package/packages/evidence/domain-posture-context.mjs +79 -0
- package/packages/evidence/image-regions.mjs +56 -0
- package/packages/evidence/latest-observations.mjs +28 -0
- package/packages/evidence/lookup-target.mjs +104 -0
- package/packages/evidence/observation.mjs +16 -1
- package/packages/evidence/passphrase-encryption.mjs +44 -0
- package/packages/evidence/record-identity.mjs +72 -0
- package/packages/evidence/registry-dates.mjs +190 -0
- package/packages/evidence/retained-file.mjs +69 -0
- package/packages/evidence/review-age.mjs +14 -0
- package/packages/interchange/bagit.mjs +342 -0
- package/packages/interchange/bounded-zip-extraction.mjs +278 -0
- package/packages/interchange/crc32.mjs +17 -0
- package/packages/interchange/external-findings-import.mjs +89 -49
- package/packages/interchange/web-capture-import.mjs +382 -0
- package/packages/investigation/bulk-mail-exposure.mjs +2 -1
- package/packages/investigation/investigation-bagit.mjs +14 -0
- package/packages/investigation/investigation-capsule.mjs +33 -5
- package/packages/investigation/investigation-manifest.mjs +200 -0
- package/packages/investigation/investigation-package-crypto.mjs +98 -0
- package/packages/investigation/investigation-package.mjs +200 -0
- package/packages/investigation/lookup-asset-graph-inputs.mjs +38 -0
- package/packages/investigation/lookup-asset-graph.mjs +161 -141
- package/packages/investigation/lookup-claim-readiness.mjs +18 -0
- package/packages/investigation/lookup-investigation-brief.mjs +3 -3
- package/packages/investigation/lookup-task-guidance.mjs +58 -0
- package/packages/monitoring/analyst-review-state.mjs +38 -90
- package/packages/workspace/brand-profile-model.mjs +158 -20
- package/packages/workspace/bulk-columns.mjs +17 -0
- package/packages/workspace/bulk-review-model.mjs +11 -5
- package/packages/workspace/bulk-session-model.mjs +95 -20
- package/packages/workspace/bulk-sort.mjs +1 -0
- package/packages/workspace/case-views.mjs +102 -0
- package/packages/workspace/hostile-input.mjs +29 -8
- package/packages/workspace/investigation-template-model.mjs +19 -4
- package/packages/workspace/page-baseline.mjs +18 -15
- package/packages/workspace/relationship-observation-model.mjs +41 -26
- package/packages/workspace/watchlist-history.mjs +2 -2
- package/packages/workspace/watchlist-store.mjs +8 -2
- package/packages/workspace/website-snapshot-model.mjs +70 -30
- package/packages/workspace/workspace-archive-crypto.mjs +41 -61
- package/packages/workspace/workspace-archive.mjs +85 -21
- package/third-party-notices.txt +29 -2
- package/packages/investigation/bulk-review-export.mjs +0 -63
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
import { Buffer } from 'node:buffer';
|
|
2
|
+
import { canonicalDomainControlRecords } from "../packages/evidence/domain-control-runtime.mjs";
|
|
2
3
|
import { requireBoundedString, requireDomainName, requireIsoTimestamp, } from "./bounded-contract-normalizers.mjs";
|
|
3
|
-
import { DOMAIN_CONTROL_FLIGHT_RECORDER_FIELDS, DOMAIN_CONTROL_FLIGHT_RECORDER_APPROVED_WINDOW_KEYS, DOMAIN_CONTROL_FLIGHT_RECORDER_EVENT_KEYS, DOMAIN_CONTROL_FLIGHT_RECORDER_FIELD_KEYS, DOMAIN_CONTROL_FLIGHT_RECORDER_INPUT_KEYS, DOMAIN_CONTROL_FLIGHT_RECORDER_INPUT_SCHEMA, DOMAIN_CONTROL_FLIGHT_RECORDER_LIMITATIONS, DOMAIN_CONTROL_FLIGHT_RECORDER_OBSERVATION_KEYS, DOMAIN_CONTROL_FLIGHT_RECORDER_ROOT_KEYS, DOMAIN_CONTROL_FLIGHT_RECORDER_SCHEMA, DOMAIN_CONTROL_FLIGHT_RECORDER_SUMMARY_KEYS, DOMAIN_CONTROL_FLIGHT_RECORDER_VERSION, DOMAIN_CONTROL_FLIGHT_RECORDER_WINDOW_KEYS, MAX_DOMAIN_CONTROL_FLIGHT_RECORDER_INPUT_BYTES, MAX_DOMAIN_CONTROL_FLIGHT_RECORDER_OUTPUT_BYTES, MAX_FLIGHT_RECORDER_FIELDS, MAX_FLIGHT_RECORDER_EVENTS, MAX_FLIGHT_RECORDER_INPUT_VALUES, MAX_FLIGHT_RECORDER_JSON_DEPTH, MAX_FLIGHT_RECORDER_JSON_VALUES, MAX_FLIGHT_RECORDER_OBSERVATIONS, MAX_FLIGHT_RECORDER_SOURCE_LENGTH, MAX_FLIGHT_RECORDER_VALUE_LENGTH, MAX_FLIGHT_RECORDER_VALUES, MAX_FLIGHT_RECORDER_WINDOWS, MAX_FLIGHT_RECORDER_WINDOW_ID_LENGTH, MAX_FLIGHT_RECORDER_WINDOW_REASON_LENGTH, MIN_FLIGHT_RECORDER_OBSERVATIONS, } from "../packages/contracts/domain-control-flight-recorder.mjs";
|
|
4
|
+
import { DOMAIN_CONTROL_FLIGHT_RECORDER_FIELDS, DOMAIN_CONTROL_FLIGHT_RECORDER_APPROVED_WINDOW_KEYS, DOMAIN_CONTROL_FLIGHT_RECORDER_EVENT_KEYS, DOMAIN_CONTROL_FLIGHT_RECORDER_FIELD_KEYS, DOMAIN_CONTROL_FLIGHT_RECORDER_INPUT_KEYS, DOMAIN_CONTROL_FLIGHT_RECORDER_INPUT_SCHEMA, DOMAIN_CONTROL_FLIGHT_RECORDER_LIMITATIONS, DOMAIN_CONTROL_FLIGHT_RECORDER_OBSERVATION_KEYS, DOMAIN_CONTROL_FLIGHT_RECORDER_ROOT_KEYS, DOMAIN_CONTROL_FLIGHT_RECORDER_SCHEMA, DOMAIN_CONTROL_FLIGHT_RECORDER_SUMMARY_KEYS, DOMAIN_CONTROL_FLIGHT_RECORDER_VERSION, PUBLIC_DOMAIN_CONTROL_FLIGHT_RECORDER_VERSION, PUBLIC_DOMAIN_CONTROL_FLIGHT_RECORDER_OBSERVATION_KEYS, PUBLIC_DOMAIN_CONTROL_FLIGHT_RECORDER_FIELD_KEYS, PUBLIC_DOMAIN_CONTROL_FLIGHT_RECORDER_EVENT_KEYS, PUBLIC_DOMAIN_CONTROL_FLIGHT_RECORDER_SUMMARY_KEYS, PUBLIC_DOMAIN_CONTROL_FLIGHT_RECORDER_LIMITATIONS, PUBLIC_MAX_FLIGHT_RECORDER_INPUT_VALUES, PUBLIC_MAX_FLIGHT_RECORDER_VALUES, PUBLIC_MAX_FLIGHT_RECORDER_VALUE_LENGTH, SUPPORTED_DOMAIN_CONTROL_FLIGHT_RECORDER_VERSIONS, DOMAIN_CONTROL_FLIGHT_RECORDER_WINDOW_KEYS, MAX_DOMAIN_CONTROL_FLIGHT_RECORDER_INPUT_BYTES, MAX_DOMAIN_CONTROL_FLIGHT_RECORDER_OUTPUT_BYTES, MAX_FLIGHT_RECORDER_FIELDS, MAX_FLIGHT_RECORDER_EVENTS, MAX_FLIGHT_RECORDER_INPUT_VALUES, MAX_FLIGHT_RECORDER_JSON_DEPTH, MAX_FLIGHT_RECORDER_JSON_VALUES, MAX_FLIGHT_RECORDER_OBSERVATIONS, MAX_FLIGHT_RECORDER_SOURCE_LENGTH, MAX_FLIGHT_RECORDER_VALUE_LENGTH, MAX_FLIGHT_RECORDER_VALUES, MAX_FLIGHT_RECORDER_WINDOWS, MAX_FLIGHT_RECORDER_WINDOW_ID_LENGTH, MAX_FLIGHT_RECORDER_WINDOW_REASON_LENGTH, MIN_FLIGHT_RECORDER_OBSERVATIONS, } from "../packages/contracts/domain-control-flight-recorder.mjs";
|
|
4
5
|
export { DOMAIN_CONTROL_FLIGHT_RECORDER_FIELDS, DOMAIN_CONTROL_FLIGHT_RECORDER_INPUT_SCHEMA, DOMAIN_CONTROL_FLIGHT_RECORDER_SCHEMA, DOMAIN_CONTROL_FLIGHT_RECORDER_VERSION, MAX_FLIGHT_RECORDER_FIELDS, MAX_FLIGHT_RECORDER_OBSERVATIONS, MAX_FLIGHT_RECORDER_VALUES, MAX_FLIGHT_RECORDER_WINDOWS, };
|
|
5
6
|
const ROOT_KEYS = new Set(DOMAIN_CONTROL_FLIGHT_RECORDER_INPUT_KEYS);
|
|
6
7
|
const OBSERVATION_KEYS = new Set(DOMAIN_CONTROL_FLIGHT_RECORDER_OBSERVATION_KEYS);
|
|
7
8
|
const FIELD_KEYS = new Set(DOMAIN_CONTROL_FLIGHT_RECORDER_FIELD_KEYS);
|
|
9
|
+
const PUBLIC_OBSERVATION_KEYS = new Set(PUBLIC_DOMAIN_CONTROL_FLIGHT_RECORDER_OBSERVATION_KEYS);
|
|
10
|
+
const PUBLIC_FIELD_KEYS = new Set(PUBLIC_DOMAIN_CONTROL_FLIGHT_RECORDER_FIELD_KEYS);
|
|
8
11
|
const WINDOW_KEYS = new Set(DOMAIN_CONTROL_FLIGHT_RECORDER_WINDOW_KEYS);
|
|
9
12
|
const DOCUMENT_ROOT_KEYS = new Set(DOMAIN_CONTROL_FLIGHT_RECORDER_ROOT_KEYS);
|
|
10
13
|
const EVENT_KEYS = new Set(DOMAIN_CONTROL_FLIGHT_RECORDER_EVENT_KEYS);
|
|
@@ -217,22 +220,23 @@ function assertPrettyJsonByteBudget(value, maximum, label) {
|
|
|
217
220
|
throw new TypeError(`${label} must be serialisable as bounded JSON.`);
|
|
218
221
|
}
|
|
219
222
|
}
|
|
220
|
-
function snapshotFlightRecorderInput(input) {
|
|
223
|
+
function snapshotFlightRecorderInput(input, legacy) {
|
|
221
224
|
const observations = boundedDataArray(input.observations, 'Domain control flight-recorder input observations', MIN_FLIGHT_RECORDER_OBSERVATIONS, MAX_FLIGHT_RECORDER_OBSERVATIONS).map((raw, index) => {
|
|
222
|
-
const item = exactRecord(raw, OBSERVATION_KEYS, `observations[${index}]`);
|
|
225
|
+
const item = exactRecord(raw, legacy ? PUBLIC_OBSERVATION_KEYS : OBSERVATION_KEYS, `observations[${index}]`);
|
|
223
226
|
const fields = boundedDataArray(item.fields, `observations[${index}].fields`, 0, MAX_FLIGHT_RECORDER_FIELDS)
|
|
224
227
|
.map((fieldRaw, fieldIndex) => {
|
|
225
|
-
const field = exactRecord(fieldRaw, FIELD_KEYS, `observations[${index}].fields[${fieldIndex}]`);
|
|
228
|
+
const field = exactRecord(fieldRaw, legacy ? PUBLIC_FIELD_KEYS : FIELD_KEYS, `observations[${index}].fields[${fieldIndex}]`);
|
|
226
229
|
return Object.freeze({
|
|
227
230
|
id: field.id,
|
|
228
231
|
source: field.source,
|
|
229
232
|
state: field.state,
|
|
230
|
-
|
|
233
|
+
...(!legacy ? { observedAt: field.observedAt } : {}),
|
|
234
|
+
values: boundedDataArray(field.values, `observations[${index}].fields[${fieldIndex}].values`, 0, legacy ? PUBLIC_MAX_FLIGHT_RECORDER_INPUT_VALUES : MAX_FLIGHT_RECORDER_INPUT_VALUES),
|
|
231
235
|
});
|
|
232
236
|
});
|
|
233
237
|
return Object.freeze({
|
|
234
238
|
domain: item.domain,
|
|
235
|
-
observedAt: item.observedAt,
|
|
239
|
+
...(legacy ? { observedAt: item.observedAt } : { capturedAt: item.capturedAt }),
|
|
236
240
|
collectionDepth: item.collectionDepth,
|
|
237
241
|
fields: Object.freeze(fields),
|
|
238
242
|
});
|
|
@@ -251,7 +255,7 @@ function snapshotFlightRecorderInput(input) {
|
|
|
251
255
|
});
|
|
252
256
|
return Object.freeze({
|
|
253
257
|
schema: DOMAIN_CONTROL_FLIGHT_RECORDER_INPUT_SCHEMA,
|
|
254
|
-
version:
|
|
258
|
+
version: input.version,
|
|
255
259
|
observations: Object.freeze(observations),
|
|
256
260
|
approvedWindows: Object.freeze(approvedWindows),
|
|
257
261
|
});
|
|
@@ -262,25 +266,41 @@ function text(value, label, maximum = 240) {
|
|
|
262
266
|
.replace(/\s+/gu, ' ')
|
|
263
267
|
.trim();
|
|
264
268
|
}
|
|
265
|
-
|
|
266
|
-
|
|
269
|
+
const RECORD_FIELDS = Object.freeze({
|
|
270
|
+
registry_nameservers: 'nameservers', whois_nameservers: 'nameservers', delegated_nameservers: 'nameservers',
|
|
271
|
+
delegation_ds: 'ds', mail_exchangers: 'mx', caa_policy: 'caa',
|
|
272
|
+
});
|
|
273
|
+
function recorderValue(value, label, legacy, field, complete = true) {
|
|
274
|
+
if (legacy)
|
|
275
|
+
return text(value, label, PUBLIC_MAX_FLIGHT_RECORDER_VALUE_LENGTH).toLowerCase();
|
|
276
|
+
if (typeof value !== 'string' || !value.trim() || value.length > MAX_FLIGHT_RECORDER_VALUE_LENGTH
|
|
277
|
+
|| /[\u0000-\u001f\u007f]/u.test(value)) {
|
|
278
|
+
throw new TypeError(`${label} must be bounded text without control characters.`);
|
|
279
|
+
}
|
|
280
|
+
const recordField = RECORD_FIELDS[field];
|
|
281
|
+
return recordField && complete ? canonicalDomainControlRecords([value], recordField)[0] : value.trim();
|
|
282
|
+
}
|
|
283
|
+
function values(value, label, legacy, field, complete) {
|
|
284
|
+
const input = boundedDataArray(value, label, 0, legacy ? PUBLIC_MAX_FLIGHT_RECORDER_INPUT_VALUES : MAX_FLIGHT_RECORDER_INPUT_VALUES);
|
|
285
|
+
const maximum = legacy ? PUBLIC_MAX_FLIGHT_RECORDER_VALUES : MAX_FLIGHT_RECORDER_VALUES;
|
|
267
286
|
const output = new Set();
|
|
268
287
|
for (let index = 0; index < input.length; index += 1) {
|
|
269
|
-
const normalized =
|
|
288
|
+
const normalized = recorderValue(input[index], `${label}[${index}]`, legacy, field, complete);
|
|
270
289
|
if (normalized)
|
|
271
290
|
output.add(normalized);
|
|
272
|
-
if (output.size >
|
|
273
|
-
throw new TypeError(`${label} must contain at most ${
|
|
291
|
+
if (output.size > maximum) {
|
|
292
|
+
throw new TypeError(`${label} must contain at most ${maximum} unique normalized values.`);
|
|
274
293
|
}
|
|
275
294
|
}
|
|
276
295
|
return [...output].sort(ordinalCompare);
|
|
277
296
|
}
|
|
278
|
-
function observation(value, index) {
|
|
279
|
-
const item = exactRecord(value, OBSERVATION_KEYS, `observations[${index}]`);
|
|
297
|
+
function observation(value, index, legacy) {
|
|
298
|
+
const item = exactRecord(value, legacy ? PUBLIC_OBSERVATION_KEYS : OBSERVATION_KEYS, `observations[${index}]`);
|
|
299
|
+
const capturedAt = requireIsoTimestamp(legacy ? item.observedAt : item.capturedAt, `observations[${index}].${legacy ? 'observedAt' : 'capturedAt'}`);
|
|
280
300
|
const inputFields = boundedDataArray(item.fields, `observations[${index}].fields`, 0, MAX_FLIGHT_RECORDER_FIELDS);
|
|
281
301
|
const seen = new Set();
|
|
282
302
|
const fields = inputFields.map((raw, fieldIndex) => {
|
|
283
|
-
const field = exactRecord(raw, FIELD_KEYS, `observations[${index}].fields[${fieldIndex}]`);
|
|
303
|
+
const field = exactRecord(raw, legacy ? PUBLIC_FIELD_KEYS : FIELD_KEYS, `observations[${index}].fields[${fieldIndex}]`);
|
|
284
304
|
if (typeof field.id !== 'string' || !FIELD_IDS.has(field.id) || seen.has(field.id)) {
|
|
285
305
|
throw new TypeError(`observations[${index}] contains an unsupported or duplicate field.`);
|
|
286
306
|
}
|
|
@@ -288,11 +308,27 @@ function observation(value, index) {
|
|
|
288
308
|
throw new TypeError(`observations[${index}].fields[${fieldIndex}].state is unsupported.`);
|
|
289
309
|
}
|
|
290
310
|
seen.add(field.id);
|
|
311
|
+
let fieldState = field.state;
|
|
312
|
+
let fieldValues = values(field.values, `observations[${index}].fields[${fieldIndex}].values`, legacy, field.id, fieldState === 'observed');
|
|
313
|
+
const recordField = RECORD_FIELDS[field.id];
|
|
314
|
+
if (legacy && recordField) {
|
|
315
|
+
try {
|
|
316
|
+
fieldValues = canonicalDomainControlRecords(fieldValues, recordField);
|
|
317
|
+
}
|
|
318
|
+
catch (cause) {
|
|
319
|
+
if (!(cause instanceof TypeError))
|
|
320
|
+
throw cause;
|
|
321
|
+
if (fieldState === 'observed')
|
|
322
|
+
fieldState = 'partial';
|
|
323
|
+
}
|
|
324
|
+
}
|
|
291
325
|
return Object.freeze({
|
|
292
326
|
id: field.id,
|
|
293
327
|
source: text(field.source, `observations[${index}].fields[${fieldIndex}].source`, MAX_FLIGHT_RECORDER_SOURCE_LENGTH),
|
|
294
|
-
state:
|
|
295
|
-
values: Object.freeze(
|
|
328
|
+
state: fieldState,
|
|
329
|
+
values: Object.freeze(fieldValues),
|
|
330
|
+
observedAt: legacy ? capturedAt : field.observedAt === null ? null
|
|
331
|
+
: requireIsoTimestamp(field.observedAt, `observations[${index}].fields[${fieldIndex}].observedAt`),
|
|
296
332
|
});
|
|
297
333
|
}).sort((left, right) => ordinalCompare(left.id, right.id));
|
|
298
334
|
const collectionDepth = item.collectionDepth === 'deep' || item.collectionDepth === 'fast'
|
|
@@ -304,11 +340,18 @@ function observation(value, index) {
|
|
|
304
340
|
throw new TypeError(`observations[${index}].collectionDepth is unsupported.`);
|
|
305
341
|
return Object.freeze({
|
|
306
342
|
domain: requireDomainName(item.domain, `observations[${index}].domain`),
|
|
307
|
-
|
|
343
|
+
capturedAt,
|
|
308
344
|
collectionDepth,
|
|
309
345
|
fields: Object.freeze(fields),
|
|
310
346
|
});
|
|
311
347
|
}
|
|
348
|
+
export function normalizeDomainControlFlightRecorderObservations(value, version) {
|
|
349
|
+
if (!SUPPORTED_DOMAIN_CONTROL_FLIGHT_RECORDER_VERSIONS.some((supported) => supported === version)) {
|
|
350
|
+
throw new TypeError('Domain control observations use an unsupported version.');
|
|
351
|
+
}
|
|
352
|
+
return Object.freeze(boundedDataArray(value, 'observations', MIN_FLIGHT_RECORDER_OBSERVATIONS, MAX_FLIGHT_RECORDER_OBSERVATIONS)
|
|
353
|
+
.map((item, index) => observation(item, index, version === PUBLIC_DOMAIN_CONTROL_FLIGHT_RECORDER_VERSION)));
|
|
354
|
+
}
|
|
312
355
|
function approvedWindow(value, index) {
|
|
313
356
|
const item = exactRecord(value, WINDOW_KEYS, `approvedWindows[${index}]`);
|
|
314
357
|
const inputFields = boundedDataArray(item.fields, `approvedWindows[${index}].fields`, 1, MAX_FLIGHT_RECORDER_FIELDS);
|
|
@@ -342,102 +385,121 @@ function matchingWindow(windows, domain, field, observedAt) {
|
|
|
342
385
|
&& Date.parse(window.startsAt) <= time
|
|
343
386
|
&& time <= Date.parse(window.endsAt))) ?? null;
|
|
344
387
|
}
|
|
388
|
+
/** Equal-capture disagreement stays incomplete, independently of input ordering. */
|
|
389
|
+
export function mergeConcurrentDomainControlFields(observations) {
|
|
390
|
+
return Object.freeze(DOMAIN_CONTROL_FLIGHT_RECORDER_FIELDS.flatMap((id) => {
|
|
391
|
+
const candidates = observations.flatMap((fields) => fields.filter((field) => field.id === id));
|
|
392
|
+
const first = candidates[0];
|
|
393
|
+
if (!first)
|
|
394
|
+
return [];
|
|
395
|
+
const identity = (field) => JSON.stringify([field.source, field.state, field.observedAt, field.values]);
|
|
396
|
+
if (candidates.every((field) => identity(field) === identity(first)))
|
|
397
|
+
return [first];
|
|
398
|
+
const retained = [...new Set(candidates.flatMap((field) => field.values))].sort(ordinalCompare);
|
|
399
|
+
const omitted = Math.max(0, retained.length - MAX_FLIGHT_RECORDER_VALUES);
|
|
400
|
+
const sources = new Set(candidates.map((field) => field.source));
|
|
401
|
+
const times = new Set(candidates.map((field) => field.observedAt));
|
|
402
|
+
return [Object.freeze({
|
|
403
|
+
id, source: `${sources.size === 1 ? first.source.slice(0, 60) : 'Multiple sources'} (conflicting observations; ${omitted} omitted)`,
|
|
404
|
+
state: 'partial', observedAt: times.size === 1 ? first.observedAt : null,
|
|
405
|
+
values: Object.freeze(retained.slice(0, MAX_FLIGHT_RECORDER_VALUES)),
|
|
406
|
+
})];
|
|
407
|
+
}));
|
|
408
|
+
}
|
|
409
|
+
function observationTimeline(observations) {
|
|
410
|
+
const cohorts = new Map();
|
|
411
|
+
for (const item of observations) {
|
|
412
|
+
const key = `${item.domain}\u0000${item.capturedAt}`;
|
|
413
|
+
const cohort = cohorts.get(key) ?? [];
|
|
414
|
+
cohort.push(item);
|
|
415
|
+
cohorts.set(key, cohort);
|
|
416
|
+
}
|
|
417
|
+
return [...cohorts.values()].map((cohort) => {
|
|
418
|
+
const first = cohort[0];
|
|
419
|
+
const collectionDepth = cohort.every((item) => item.collectionDepth === first.collectionDepth)
|
|
420
|
+
? first.collectionDepth : 'unknown';
|
|
421
|
+
return Object.freeze({ ...first, collectionDepth, fields: mergeConcurrentDomainControlFields(cohort.map((item) => item.fields)) });
|
|
422
|
+
}).sort((left, right) => Date.parse(left.capturedAt) - Date.parse(right.capturedAt)
|
|
423
|
+
|| ordinalCompare(left.domain, right.domain));
|
|
424
|
+
}
|
|
345
425
|
export function buildDomainControlFlightRecorder(inputRaw, generatedAtValue = new Date().toISOString()) {
|
|
346
426
|
const inputRoot = exactRecord(inputRaw, ROOT_KEYS, 'Domain control flight-recorder input');
|
|
347
427
|
if (inputRoot.schema !== DOMAIN_CONTROL_FLIGHT_RECORDER_INPUT_SCHEMA
|
|
348
|
-
||
|
|
349
|
-
throw new TypeError(`Domain control flight-recorder input must use ${DOMAIN_CONTROL_FLIGHT_RECORDER_INPUT_SCHEMA} version ${
|
|
428
|
+
|| !SUPPORTED_DOMAIN_CONTROL_FLIGHT_RECORDER_VERSIONS.some((version) => version === inputRoot.version)) {
|
|
429
|
+
throw new TypeError(`Domain control flight-recorder input must use ${DOMAIN_CONTROL_FLIGHT_RECORDER_INPUT_SCHEMA} supported version ${SUPPORTED_DOMAIN_CONTROL_FLIGHT_RECORDER_VERSIONS.join(' or ')}.`);
|
|
350
430
|
}
|
|
351
|
-
const input = snapshotFlightRecorderInput(inputRoot);
|
|
431
|
+
const input = snapshotFlightRecorderInput(inputRoot, inputRoot.version === PUBLIC_DOMAIN_CONTROL_FLIGHT_RECORDER_VERSION);
|
|
352
432
|
assertPrettyJsonByteBudget(input, MAX_DOMAIN_CONTROL_FLIGHT_RECORDER_INPUT_BYTES, 'Domain control flight-recorder input');
|
|
353
|
-
const inputObservations = input.observations;
|
|
354
433
|
const inputWindows = input.approvedWindows;
|
|
355
|
-
const observations =
|
|
356
|
-
.sort((left, right) => Date.parse(left.observedAt) - Date.parse(right.observedAt)
|
|
357
|
-
|| ordinalCompare(left.domain, right.domain));
|
|
434
|
+
const observations = normalizeDomainControlFlightRecorderObservations(input.observations, Number(input.version));
|
|
358
435
|
const windows = inputWindows.map(approvedWindow);
|
|
359
436
|
if (new Set(windows.map((window) => window.id)).size !== windows.length) {
|
|
360
437
|
throw new TypeError('Approved change-window ids must be unique.');
|
|
361
438
|
}
|
|
362
439
|
const generatedAt = requireIsoTimestamp(generatedAtValue, 'generatedAt');
|
|
363
|
-
if (observations.some((item) => Date.parse(item.
|
|
364
|
-
throw new TypeError('Domain control flight-recorder
|
|
440
|
+
if (observations.some((item) => Date.parse(item.capturedAt) > Date.parse(generatedAt))) {
|
|
441
|
+
throw new TypeError('Domain control flight-recorder captures cannot be later than generatedAt.');
|
|
365
442
|
}
|
|
366
443
|
const latest = new Map();
|
|
367
444
|
const events = [];
|
|
368
|
-
|
|
445
|
+
let incompleteFields = 0;
|
|
446
|
+
for (const item of observationTimeline(observations)) {
|
|
369
447
|
for (const field of item.fields) {
|
|
370
448
|
const key = `${item.domain}\u0000${field.id}`;
|
|
371
449
|
const previous = latest.get(key);
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
domain: item.domain,
|
|
379
|
-
field: field.id,
|
|
380
|
-
observedAt: item.observedAt,
|
|
381
|
-
kind: 'first_observation',
|
|
382
|
-
state: 'observed',
|
|
383
|
-
before: Object.freeze([]),
|
|
384
|
-
after: field.values,
|
|
385
|
-
source: field.source,
|
|
386
|
-
approvedWindow: null,
|
|
387
|
-
explanation: 'The first complete value for this source-attributed field was retained.',
|
|
388
|
-
}));
|
|
450
|
+
let complete = field.state === 'observed' && field.observedAt !== null
|
|
451
|
+
&& Date.parse(field.observedAt) <= Date.parse(item.capturedAt) && item.collectionDepth !== 'unknown';
|
|
452
|
+
const sameContext = previous?.field.source === field.source && previous.collectionDepth === item.collectionDepth;
|
|
453
|
+
const repeated = previous?.complete && complete && sameContext
|
|
454
|
+
&& previous.field.observedAt === field.observedAt && sameValues(previous.field.values, field.values);
|
|
455
|
+
if (repeated)
|
|
389
456
|
continue;
|
|
457
|
+
let limitation = field.observedAt === null ? 'The source observation time is unavailable.'
|
|
458
|
+
: Date.parse(field.observedAt) > Date.parse(item.capturedAt) ? 'The source observation time is later than capture.'
|
|
459
|
+
: item.collectionDepth === 'unknown' ? 'Collection conditions are unknown or conflicting.'
|
|
460
|
+
: `The source is ${field.state}.`;
|
|
461
|
+
if (complete && previous?.complete && (!sameContext
|
|
462
|
+
|| Date.parse(field.observedAt) <= Date.parse(previous.field.observedAt))) {
|
|
463
|
+
complete = false;
|
|
464
|
+
limitation = !sameContext ? 'The source identity or collection conditions changed.'
|
|
465
|
+
: 'The source observation time did not increase.';
|
|
390
466
|
}
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
state: 'observed',
|
|
402
|
-
before: previous.values,
|
|
403
|
-
after: field.values,
|
|
404
|
-
source: field.source,
|
|
405
|
-
approvedWindow: window ? Object.freeze({ id: window.id, reason: window.reason }) : null,
|
|
406
|
-
explanation: window
|
|
407
|
-
? 'A complete observed value changed during an analyst-approved change window. The evidence is retained and labelled expected rather than removed.'
|
|
408
|
-
: 'Two complete source-attributed observations contain different values.',
|
|
409
|
-
}));
|
|
467
|
+
latest.set(key, { field, collectionDepth: item.collectionDepth, complete });
|
|
468
|
+
let kind;
|
|
469
|
+
let explanation;
|
|
470
|
+
let before = [];
|
|
471
|
+
let approved = null;
|
|
472
|
+
if (!complete) {
|
|
473
|
+
incompleteFields += 1;
|
|
474
|
+
kind = previous?.complete ? 'collection_change' : 'incomplete_observation';
|
|
475
|
+
before = previous?.complete ? previous.field.values : [];
|
|
476
|
+
explanation = `${limitation} Retained values are incomplete; this is not evidence that a prior value disappeared or changed.`;
|
|
410
477
|
}
|
|
411
|
-
else if (previous
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
domain: item.domain,
|
|
415
|
-
field: field.id,
|
|
416
|
-
observedAt: item.observedAt,
|
|
417
|
-
kind: 'recovered',
|
|
418
|
-
state: 'observed',
|
|
419
|
-
before: Object.freeze([]),
|
|
420
|
-
after: field.values,
|
|
421
|
-
source: field.source,
|
|
422
|
-
approvedWindow: null,
|
|
423
|
-
explanation: 'Collection returned a complete value after an earlier incomplete source state.',
|
|
424
|
-
}));
|
|
478
|
+
else if (!previous) {
|
|
479
|
+
kind = 'first_observation';
|
|
480
|
+
explanation = 'The first complete source-timed value for this field was retained.';
|
|
425
481
|
}
|
|
426
|
-
else if (previous.
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
domain: item.domain,
|
|
430
|
-
field: field.id,
|
|
431
|
-
observedAt: item.observedAt,
|
|
432
|
-
kind: 'collection_change',
|
|
433
|
-
state: 'partial',
|
|
434
|
-
before: previous.values,
|
|
435
|
-
after: Object.freeze([]),
|
|
436
|
-
source: field.source,
|
|
437
|
-
approvedWindow: null,
|
|
438
|
-
explanation: `The source became ${field.state}; this is a collection-quality change, not evidence that the prior value disappeared.`,
|
|
439
|
-
}));
|
|
482
|
+
else if (!previous.complete) {
|
|
483
|
+
kind = 'recovered';
|
|
484
|
+
explanation = 'A complete source-timed value is available after an incomplete observation; this does not establish a value change.';
|
|
440
485
|
}
|
|
486
|
+
else {
|
|
487
|
+
if (sameValues(previous.field.values, field.values))
|
|
488
|
+
continue;
|
|
489
|
+
kind = 'observed_change';
|
|
490
|
+
before = previous.field.values;
|
|
491
|
+
approved = matchingWindow(windows, item.domain, field.id, field.observedAt);
|
|
492
|
+
explanation = approved
|
|
493
|
+
? 'Two complete observations from the same source changed during the recorded change window.'
|
|
494
|
+
: 'Two complete observations from the same source, with increasing source times, contain different values.';
|
|
495
|
+
}
|
|
496
|
+
events.push(Object.freeze({
|
|
497
|
+
id: `${item.domain}:${field.id}:${item.capturedAt}:${events.length + 1}`,
|
|
498
|
+
domain: item.domain, field: field.id, observedAt: field.observedAt, capturedAt: item.capturedAt,
|
|
499
|
+
kind, state: complete ? 'observed' : 'partial', before: Object.freeze([...before]), after: field.values,
|
|
500
|
+
source: field.source, approvedWindow: approved ? Object.freeze({ id: approved.id, reason: approved.reason }) : null,
|
|
501
|
+
explanation,
|
|
502
|
+
}));
|
|
441
503
|
}
|
|
442
504
|
}
|
|
443
505
|
const domains = [...new Set(observations.map((item) => item.domain))].sort(ordinalCompare);
|
|
@@ -457,6 +519,7 @@ export function buildDomainControlFlightRecorder(inputRaw, generatedAtValue = ne
|
|
|
457
519
|
unexpectedChanges: observedChanges.filter((event) => event.approvedWindow === null).length,
|
|
458
520
|
collectionChanges: collectionChanges.length,
|
|
459
521
|
recoveredSources: events.filter((event) => event.kind === 'recovered').length,
|
|
522
|
+
incompleteFields,
|
|
460
523
|
}),
|
|
461
524
|
limitations: DOMAIN_CONTROL_FLIGHT_RECORDER_LIMITATIONS,
|
|
462
525
|
});
|
|
@@ -475,9 +538,15 @@ function exactRecorderInteger(value, minimum, maximum, label) {
|
|
|
475
538
|
}
|
|
476
539
|
return Number(value);
|
|
477
540
|
}
|
|
478
|
-
function exactRecorderValues(value, label) {
|
|
479
|
-
const input = boundedDataArray(value, label, 0, MAX_FLIGHT_RECORDER_VALUES);
|
|
480
|
-
const normalized = input.map((item, index) =>
|
|
541
|
+
function exactRecorderValues(value, label, legacy, field, complete) {
|
|
542
|
+
const input = boundedDataArray(value, label, 0, legacy ? PUBLIC_MAX_FLIGHT_RECORDER_VALUES : MAX_FLIGHT_RECORDER_VALUES);
|
|
543
|
+
const normalized = input.map((item, index) => {
|
|
544
|
+
const result = legacy ? exactRecorderText(item, `${label}[${index}]`, PUBLIC_MAX_FLIGHT_RECORDER_VALUE_LENGTH)
|
|
545
|
+
: recorderValue(item, `${label}[${index}]`, false, field, complete);
|
|
546
|
+
if (result !== item)
|
|
547
|
+
throw new TypeError(`${label} must use canonical values.`);
|
|
548
|
+
return result;
|
|
549
|
+
});
|
|
481
550
|
if (new Set(normalized).size !== normalized.length
|
|
482
551
|
|| normalized.some((item, index) => index > 0 && normalized[index - 1] >= item)) {
|
|
483
552
|
throw new TypeError(`${label} must contain unique canonically ordered values.`);
|
|
@@ -487,9 +556,10 @@ function exactRecorderValues(value, label) {
|
|
|
487
556
|
export function validateDomainControlFlightRecorderDocument(value) {
|
|
488
557
|
const root = exactRecord(value, DOCUMENT_ROOT_KEYS, 'Domain control flight-recorder document');
|
|
489
558
|
if (root.schema !== DOMAIN_CONTROL_FLIGHT_RECORDER_SCHEMA
|
|
490
|
-
||
|
|
491
|
-
throw new TypeError(`Domain control flight-recorder document must use ${DOMAIN_CONTROL_FLIGHT_RECORDER_SCHEMA} version ${
|
|
559
|
+
|| !SUPPORTED_DOMAIN_CONTROL_FLIGHT_RECORDER_VERSIONS.some((version) => version === root.version)) {
|
|
560
|
+
throw new TypeError(`Domain control flight-recorder document must use ${DOMAIN_CONTROL_FLIGHT_RECORDER_SCHEMA} supported version ${SUPPORTED_DOMAIN_CONTROL_FLIGHT_RECORDER_VERSIONS.join(' or ')}.`);
|
|
492
561
|
}
|
|
562
|
+
const legacy = root.version === PUBLIC_DOMAIN_CONTROL_FLIGHT_RECORDER_VERSION;
|
|
493
563
|
const generatedAt = requireIsoTimestamp(root.generatedAt, 'Domain control flight-recorder document generatedAt');
|
|
494
564
|
if (generatedAt !== root.generatedAt) {
|
|
495
565
|
throw new TypeError('Domain control flight-recorder document generatedAt must use its canonical timestamp form.');
|
|
@@ -517,10 +587,14 @@ export function validateDomainControlFlightRecorderDocument(value) {
|
|
|
517
587
|
collection_change: 0,
|
|
518
588
|
recovered: 0,
|
|
519
589
|
approved_change: 0,
|
|
590
|
+
incomplete_observation: 0,
|
|
520
591
|
};
|
|
592
|
+
const seenIds = new Set();
|
|
593
|
+
const latest = new Map();
|
|
594
|
+
let lastCapture = -Infinity;
|
|
521
595
|
const events = eventInput.map((eventValue, index) => {
|
|
522
596
|
const label = `Domain control flight-recorder document event ${index + 1}`;
|
|
523
|
-
const event = exactRecord(eventValue, EVENT_KEYS, label);
|
|
597
|
+
const event = exactRecord(eventValue, legacy ? new Set(PUBLIC_DOMAIN_CONTROL_FLIGHT_RECORDER_EVENT_KEYS) : EVENT_KEYS, label);
|
|
524
598
|
const eventDomain = requireDomainName(event.domain, `${label}.domain`);
|
|
525
599
|
if (eventDomain !== event.domain || !domains.includes(eventDomain)) {
|
|
526
600
|
throw new TypeError(`${label}.domain must be present in the document domain list.`);
|
|
@@ -528,23 +602,32 @@ export function validateDomainControlFlightRecorderDocument(value) {
|
|
|
528
602
|
if (typeof event.field !== 'string' || !FIELD_IDS.has(event.field)) {
|
|
529
603
|
throw new TypeError(`${label}.field is unsupported.`);
|
|
530
604
|
}
|
|
531
|
-
const observedAt = requireIsoTimestamp(event.observedAt, `${label}.observedAt`);
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
605
|
+
const observedAt = !legacy && event.observedAt === null ? null : requireIsoTimestamp(event.observedAt, `${label}.observedAt`);
|
|
606
|
+
const capturedAt = legacy ? undefined : requireIsoTimestamp(event.capturedAt, `${label}.capturedAt`);
|
|
607
|
+
if (observedAt !== event.observedAt || (legacy && Date.parse(observedAt) > Date.parse(generatedAt))
|
|
608
|
+
|| (!legacy && (capturedAt !== event.capturedAt || Date.parse(capturedAt) > Date.parse(generatedAt)
|
|
609
|
+
|| Date.parse(capturedAt) < lastCapture)))
|
|
610
|
+
throw new TypeError(`${label} contains an invalid or unordered timestamp.`);
|
|
611
|
+
if (capturedAt)
|
|
612
|
+
lastCapture = Date.parse(capturedAt);
|
|
535
613
|
if (event.kind !== 'first_observation'
|
|
536
614
|
&& event.kind !== 'observed_change'
|
|
537
615
|
&& event.kind !== 'collection_change'
|
|
538
|
-
&& event.kind !== 'recovered'
|
|
616
|
+
&& event.kind !== 'recovered'
|
|
617
|
+
&& (legacy || event.kind !== 'incomplete_observation')) {
|
|
539
618
|
throw new TypeError(`${label}.kind is unsupported.`);
|
|
540
619
|
}
|
|
541
|
-
const
|
|
620
|
+
const kind = event.kind;
|
|
621
|
+
const expectedState = kind === 'collection_change' || kind === 'incomplete_observation' ? 'partial' : 'observed';
|
|
542
622
|
if (event.state !== expectedState)
|
|
543
623
|
throw new TypeError(`${label}.state is inconsistent.`);
|
|
544
|
-
|
|
545
|
-
|
|
624
|
+
if (!legacy && expectedState === 'observed' && (observedAt === null || Date.parse(observedAt) > Date.parse(capturedAt))) {
|
|
625
|
+
throw new TypeError(`${label} cannot claim a complete observation without a source time at or before capture.`);
|
|
626
|
+
}
|
|
627
|
+
const before = exactRecorderValues(event.before, `${label}.before`, legacy, event.field, expectedState === 'observed');
|
|
628
|
+
const after = exactRecorderValues(event.after, `${label}.after`, legacy, event.field, expectedState === 'observed');
|
|
546
629
|
if (((event.kind === 'first_observation' || event.kind === 'recovered') && before.length)
|
|
547
|
-
|| (event.kind === 'collection_change' && after.length)) {
|
|
630
|
+
|| (legacy && event.kind === 'collection_change' && after.length)) {
|
|
548
631
|
throw new TypeError(`${label} before/after values are inconsistent.`);
|
|
549
632
|
}
|
|
550
633
|
let approvedWindow = null;
|
|
@@ -558,22 +641,35 @@ export function validateDomainControlFlightRecorderDocument(value) {
|
|
|
558
641
|
});
|
|
559
642
|
kindCounts.approved_change += 1;
|
|
560
643
|
}
|
|
561
|
-
kindCounts[
|
|
562
|
-
|
|
644
|
+
kindCounts[kind] += 1;
|
|
645
|
+
const result = Object.freeze({
|
|
563
646
|
id: exactRecorderText(event.id, `${label}.id`, 400),
|
|
564
647
|
domain: eventDomain,
|
|
565
648
|
field: event.field,
|
|
566
649
|
observedAt,
|
|
567
|
-
|
|
650
|
+
...(capturedAt ? { capturedAt } : {}),
|
|
651
|
+
kind,
|
|
568
652
|
state: expectedState,
|
|
569
653
|
before,
|
|
570
654
|
after,
|
|
571
655
|
source: exactRecorderText(event.source, `${label}.source`, MAX_FLIGHT_RECORDER_SOURCE_LENGTH),
|
|
572
656
|
approvedWindow,
|
|
573
|
-
explanation: exactRecorderText(event.explanation, `${label}.explanation`, MAX_FLIGHT_RECORDER_VALUE_LENGTH),
|
|
657
|
+
explanation: exactRecorderText(event.explanation, `${label}.explanation`, legacy ? PUBLIC_MAX_FLIGHT_RECORDER_VALUE_LENGTH : MAX_FLIGHT_RECORDER_VALUE_LENGTH),
|
|
574
658
|
});
|
|
659
|
+
if (!legacy) {
|
|
660
|
+
const previous = latest.get(`${eventDomain}\u0000${event.field}`);
|
|
661
|
+
if (seenIds.has(result.id) || (kind === 'observed_change' && (!previous || previous.state !== 'observed'
|
|
662
|
+
|| previous.source !== result.source || Date.parse(previous.observedAt) >= Date.parse(result.observedAt)
|
|
663
|
+
|| !sameValues(previous.after, before) || sameValues(before, after)))) {
|
|
664
|
+
throw new TypeError(`${label} has a duplicate identity or an unsupported observed-change claim.`);
|
|
665
|
+
}
|
|
666
|
+
seenIds.add(result.id);
|
|
667
|
+
latest.set(`${eventDomain}\u0000${event.field}`, result);
|
|
668
|
+
}
|
|
669
|
+
return result;
|
|
575
670
|
});
|
|
576
|
-
const
|
|
671
|
+
const summaryKeys = legacy ? PUBLIC_DOMAIN_CONTROL_FLIGHT_RECORDER_SUMMARY_KEYS : DOMAIN_CONTROL_FLIGHT_RECORDER_SUMMARY_KEYS;
|
|
672
|
+
const summaryInput = exactRecord(root.summary, legacy ? new Set(summaryKeys) : SUMMARY_KEYS, 'Domain control flight-recorder document summary');
|
|
577
673
|
const expectedSummary = Object.freeze({
|
|
578
674
|
firstObservations: kindCounts.first_observation,
|
|
579
675
|
observedChanges: kindCounts.observed_change,
|
|
@@ -581,26 +677,28 @@ export function validateDomainControlFlightRecorderDocument(value) {
|
|
|
581
677
|
unexpectedChanges: kindCounts.observed_change - kindCounts.approved_change,
|
|
582
678
|
collectionChanges: kindCounts.collection_change,
|
|
583
679
|
recoveredSources: kindCounts.recovered,
|
|
680
|
+
...(!legacy ? { incompleteFields: kindCounts.collection_change + kindCounts.incomplete_observation } : {}),
|
|
584
681
|
});
|
|
585
|
-
for (const key of
|
|
682
|
+
for (const key of summaryKeys) {
|
|
586
683
|
const supplied = exactRecorderInteger(summaryInput[key], 0, MAX_FLIGHT_RECORDER_EVENTS, `Domain control flight-recorder document summary ${key}`);
|
|
587
684
|
if (supplied !== expectedSummary[key]) {
|
|
588
685
|
throw new TypeError('Domain control flight-recorder document summary is inconsistent.');
|
|
589
686
|
}
|
|
590
687
|
}
|
|
591
|
-
const
|
|
592
|
-
|
|
688
|
+
const limitations = legacy ? PUBLIC_DOMAIN_CONTROL_FLIGHT_RECORDER_LIMITATIONS : DOMAIN_CONTROL_FLIGHT_RECORDER_LIMITATIONS;
|
|
689
|
+
const limitationsInput = boundedDataArray(root.limitations, 'Domain control flight-recorder document limitations', limitations.length, limitations.length);
|
|
690
|
+
if (limitationsInput.some((item, index) => item !== limitations[index])) {
|
|
593
691
|
throw new TypeError('Domain control flight-recorder document limitations are invalid.');
|
|
594
692
|
}
|
|
595
693
|
const document = Object.freeze({
|
|
596
694
|
schema: DOMAIN_CONTROL_FLIGHT_RECORDER_SCHEMA,
|
|
597
|
-
version: DOMAIN_CONTROL_FLIGHT_RECORDER_VERSION,
|
|
695
|
+
version: legacy ? PUBLIC_DOMAIN_CONTROL_FLIGHT_RECORDER_VERSION : DOMAIN_CONTROL_FLIGHT_RECORDER_VERSION,
|
|
598
696
|
generatedAt,
|
|
599
697
|
domains: Object.freeze(domains),
|
|
600
698
|
observationCount,
|
|
601
699
|
events: Object.freeze(events),
|
|
602
700
|
summary: expectedSummary,
|
|
603
|
-
limitations
|
|
701
|
+
limitations,
|
|
604
702
|
});
|
|
605
703
|
assertPrettyJsonByteBudget(document, MAX_DOMAIN_CONTROL_FLIGHT_RECORDER_OUTPUT_BYTES, 'Domain control flight-recorder document');
|
|
606
704
|
return document;
|
|
@@ -621,10 +719,13 @@ export function formatDomainControlFlightRecorder(document) {
|
|
|
621
719
|
`Unexpected changes ${document.summary.unexpectedChanges}`,
|
|
622
720
|
`Approved changes ${document.summary.approvedChanges}`,
|
|
623
721
|
`Collection changes ${document.summary.collectionChanges}`,
|
|
722
|
+
...(document.summary.incompleteFields === undefined ? [] : [`Incomplete fields ${document.summary.incompleteFields}`]),
|
|
624
723
|
'',
|
|
625
724
|
];
|
|
626
725
|
for (const event of document.events.filter((item) => item.kind !== 'first_observation')) {
|
|
627
|
-
lines.push(`${event.observedAt} ${event.domain} ${event.field.replaceAll('_', ' ')} ${event.kind.replaceAll('_', ' ')}`);
|
|
726
|
+
lines.push(`${event.observedAt ?? 'Source time unavailable'} ${event.domain} ${event.field.replaceAll('_', ' ')} ${event.kind.replaceAll('_', ' ')}`);
|
|
727
|
+
if (event.capturedAt)
|
|
728
|
+
lines.push(` Captured ${event.capturedAt}`);
|
|
628
729
|
lines.push(` ${event.explanation}`);
|
|
629
730
|
}
|
|
630
731
|
if (!document.events.some((item) => item.kind !== 'first_observation')) {
|