@slicedearth/whoisleuth-cli 2.2.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 +20 -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 +454 -1748
- package/cli/artifact-validation/case-response.mjs +66 -35
- 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 +112 -72
- 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-checkpoint.mjs +34 -3
- 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 +66 -111
- package/cli/command-argument-grammar.mjs +215 -0
- package/cli/command-reference.mjs +342 -285
- package/cli/completion.mjs +303 -80
- 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 +59 -18
- package/cli/discovery-scan-command-runner.mjs +21 -0
- package/cli/discovery-scan.mjs +13 -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 +2 -0
- package/cli/input.mjs +116 -0
- package/cli/interactive-launcher.mjs +2 -160
- package/cli/interchange-report.mjs +18 -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/lookalike-calibration.mjs +7 -2
- package/cli/lookup-brief.mjs +4 -2
- package/cli/lookup-browser-view.mjs +1 -1
- 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-header-review.mjs +467 -0
- 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 +70 -0
- package/cli/risk-calibration.mjs +19 -17
- package/cli/runner.mjs +62 -7
- 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 +77 -24
- package/docs/cli.md +300 -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 +51 -8
- 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-convergence-review.mjs +1 -0
- package/lib/dns-delegation-health.mjs +24 -14
- package/lib/dns-inheritance-review.mjs +217 -0
- package/lib/dns-intelligence.mjs +73 -26
- package/lib/dns-mx.mjs +1 -1
- package/lib/dnssec-chain-validation.mjs +6 -0
- package/lib/dnssec-evidence-validation.mjs +11 -3
- package/lib/domain-change-packet.mjs +49 -16
- package/lib/domain-change-review.mjs +14 -8
- package/lib/domain-control-flight-recorder.mjs +221 -117
- package/lib/domain-control-manifest.mjs +129 -69
- package/lib/domain-posture-analysis.mjs +20 -7
- package/lib/domain-posture-parsers.mjs +201 -8
- package/lib/domain-posture.mjs +147 -29
- package/lib/evidence-export-privacy.mjs +561 -0
- package/lib/evidence-export.mjs +11 -543
- package/lib/evidence-report.mjs +5 -3
- 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 +111 -108
- package/lib/http-evidence-bounds.mjs +2 -1
- package/lib/http-intelligence.mjs +39 -26
- package/lib/local-geoip-evidence.mjs +19 -3
- 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 +8 -10
- package/lib/lookup-source-progress.mjs +40 -14
- package/lib/lookup.mjs +48 -27
- package/lib/nameserver-preflight-review.mjs +1 -0
- 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-capabilities.mjs +1 -1
- 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 +85 -34
- package/lib/response-policy.mjs +86 -48
- package/lib/risk-calibration-summary.mjs +18 -6
- package/lib/rpki-evidence.mjs +8 -5
- package/lib/safe-fetch.mjs +54 -47
- package/lib/security-txt.mjs +42 -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 +178 -151
- 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 +153 -45
- 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/lib/zone-intent-review.mjs +16 -6
- 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 +127 -44
- package/packages/cases/case-model.mjs +1 -0
- package/packages/cases/case-recheck-model.mjs +91 -0
- package/packages/cases/case-record-contracts.mjs +5 -20
- package/packages/cases/case-record-core.mjs +12 -22
- package/packages/cases/case-record-decisions.mjs +72 -0
- package/packages/cases/case-record-model.mjs +1 -0
- package/packages/cases/case-record-operations.mjs +95 -31
- package/packages/cases/case-record-projection.mjs +221 -0
- package/packages/cases/case-report.mjs +70 -54
- package/packages/cases/case-response-actions.mjs +654 -0
- package/packages/cases/case-response-model.mjs +64 -1115
- 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 +74 -572
- 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 +31 -2
- package/packages/cases/response-route-freshness.mjs +16 -0
- package/packages/comparison/capture-context.mjs +61 -0
- package/packages/comparison/comparison-ledger-bulk.mjs +13 -11
- package/packages/comparison/page-similarity.mjs +5 -2
- 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 +366 -83
- package/packages/contracts/case-views-contract.mjs +12 -0
- package/packages/contracts/case-views.mjs +26 -0
- package/packages/contracts/cli-command-semantics.mjs +94 -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 +15 -11
- 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 +183 -211
- 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 +201 -33
- 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 +92 -51
- 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 +193 -165
- 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 +276 -22
- package/packages/workspace/bulk-columns.mjs +17 -0
- package/packages/workspace/bulk-review-model.mjs +23 -11
- package/packages/workspace/bulk-session-model.mjs +100 -24
- package/packages/workspace/bulk-sort.mjs +1 -0
- package/packages/workspace/case-views.mjs +102 -0
- package/packages/workspace/detection-rule-model.mjs +70 -22
- 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 -23
- package/packages/workspace/watchlist-history.mjs +2 -2
- package/packages/workspace/watchlist-store.mjs +8 -2
- package/packages/workspace/website-snapshot-model.mjs +164 -26
- package/packages/workspace/workspace-archive-crypto.mjs +41 -61
- package/packages/workspace/workspace-archive.mjs +87 -21
- package/third-party-notices.txt +29 -2
- package/cli/evidence-command-arguments.mjs +0 -156
- 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,22 +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 =
|
|
270
|
-
if (normalized
|
|
288
|
+
const normalized = recorderValue(input[index], `${label}[${index}]`, legacy, field, complete);
|
|
289
|
+
if (normalized)
|
|
271
290
|
output.add(normalized);
|
|
291
|
+
if (output.size > maximum) {
|
|
292
|
+
throw new TypeError(`${label} must contain at most ${maximum} unique normalized values.`);
|
|
293
|
+
}
|
|
272
294
|
}
|
|
273
295
|
return [...output].sort(ordinalCompare);
|
|
274
296
|
}
|
|
275
|
-
function observation(value, index) {
|
|
276
|
-
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'}`);
|
|
277
300
|
const inputFields = boundedDataArray(item.fields, `observations[${index}].fields`, 0, MAX_FLIGHT_RECORDER_FIELDS);
|
|
278
301
|
const seen = new Set();
|
|
279
302
|
const fields = inputFields.map((raw, fieldIndex) => {
|
|
280
|
-
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}]`);
|
|
281
304
|
if (typeof field.id !== 'string' || !FIELD_IDS.has(field.id) || seen.has(field.id)) {
|
|
282
305
|
throw new TypeError(`observations[${index}] contains an unsupported or duplicate field.`);
|
|
283
306
|
}
|
|
@@ -285,11 +308,27 @@ function observation(value, index) {
|
|
|
285
308
|
throw new TypeError(`observations[${index}].fields[${fieldIndex}].state is unsupported.`);
|
|
286
309
|
}
|
|
287
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
|
+
}
|
|
288
325
|
return Object.freeze({
|
|
289
326
|
id: field.id,
|
|
290
327
|
source: text(field.source, `observations[${index}].fields[${fieldIndex}].source`, MAX_FLIGHT_RECORDER_SOURCE_LENGTH),
|
|
291
|
-
state:
|
|
292
|
-
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`),
|
|
293
332
|
});
|
|
294
333
|
}).sort((left, right) => ordinalCompare(left.id, right.id));
|
|
295
334
|
const collectionDepth = item.collectionDepth === 'deep' || item.collectionDepth === 'fast'
|
|
@@ -301,11 +340,18 @@ function observation(value, index) {
|
|
|
301
340
|
throw new TypeError(`observations[${index}].collectionDepth is unsupported.`);
|
|
302
341
|
return Object.freeze({
|
|
303
342
|
domain: requireDomainName(item.domain, `observations[${index}].domain`),
|
|
304
|
-
|
|
343
|
+
capturedAt,
|
|
305
344
|
collectionDepth,
|
|
306
345
|
fields: Object.freeze(fields),
|
|
307
346
|
});
|
|
308
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
|
+
}
|
|
309
355
|
function approvedWindow(value, index) {
|
|
310
356
|
const item = exactRecord(value, WINDOW_KEYS, `approvedWindows[${index}]`);
|
|
311
357
|
const inputFields = boundedDataArray(item.fields, `approvedWindows[${index}].fields`, 1, MAX_FLIGHT_RECORDER_FIELDS);
|
|
@@ -339,102 +385,121 @@ function matchingWindow(windows, domain, field, observedAt) {
|
|
|
339
385
|
&& Date.parse(window.startsAt) <= time
|
|
340
386
|
&& time <= Date.parse(window.endsAt))) ?? null;
|
|
341
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
|
+
}
|
|
342
425
|
export function buildDomainControlFlightRecorder(inputRaw, generatedAtValue = new Date().toISOString()) {
|
|
343
426
|
const inputRoot = exactRecord(inputRaw, ROOT_KEYS, 'Domain control flight-recorder input');
|
|
344
427
|
if (inputRoot.schema !== DOMAIN_CONTROL_FLIGHT_RECORDER_INPUT_SCHEMA
|
|
345
|
-
||
|
|
346
|
-
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 ')}.`);
|
|
347
430
|
}
|
|
348
|
-
const input = snapshotFlightRecorderInput(inputRoot);
|
|
431
|
+
const input = snapshotFlightRecorderInput(inputRoot, inputRoot.version === PUBLIC_DOMAIN_CONTROL_FLIGHT_RECORDER_VERSION);
|
|
349
432
|
assertPrettyJsonByteBudget(input, MAX_DOMAIN_CONTROL_FLIGHT_RECORDER_INPUT_BYTES, 'Domain control flight-recorder input');
|
|
350
|
-
const inputObservations = input.observations;
|
|
351
433
|
const inputWindows = input.approvedWindows;
|
|
352
|
-
const observations =
|
|
353
|
-
.sort((left, right) => Date.parse(left.observedAt) - Date.parse(right.observedAt)
|
|
354
|
-
|| ordinalCompare(left.domain, right.domain));
|
|
434
|
+
const observations = normalizeDomainControlFlightRecorderObservations(input.observations, Number(input.version));
|
|
355
435
|
const windows = inputWindows.map(approvedWindow);
|
|
356
436
|
if (new Set(windows.map((window) => window.id)).size !== windows.length) {
|
|
357
437
|
throw new TypeError('Approved change-window ids must be unique.');
|
|
358
438
|
}
|
|
359
439
|
const generatedAt = requireIsoTimestamp(generatedAtValue, 'generatedAt');
|
|
360
|
-
if (observations.some((item) => Date.parse(item.
|
|
361
|
-
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.');
|
|
362
442
|
}
|
|
363
443
|
const latest = new Map();
|
|
364
444
|
const events = [];
|
|
365
|
-
|
|
445
|
+
let incompleteFields = 0;
|
|
446
|
+
for (const item of observationTimeline(observations)) {
|
|
366
447
|
for (const field of item.fields) {
|
|
367
448
|
const key = `${item.domain}\u0000${field.id}`;
|
|
368
449
|
const previous = latest.get(key);
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
domain: item.domain,
|
|
376
|
-
field: field.id,
|
|
377
|
-
observedAt: item.observedAt,
|
|
378
|
-
kind: 'first_observation',
|
|
379
|
-
state: 'observed',
|
|
380
|
-
before: Object.freeze([]),
|
|
381
|
-
after: field.values,
|
|
382
|
-
source: field.source,
|
|
383
|
-
approvedWindow: null,
|
|
384
|
-
explanation: 'The first complete value for this source-attributed field was retained.',
|
|
385
|
-
}));
|
|
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)
|
|
386
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.';
|
|
387
466
|
}
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
state: 'observed',
|
|
399
|
-
before: previous.values,
|
|
400
|
-
after: field.values,
|
|
401
|
-
source: field.source,
|
|
402
|
-
approvedWindow: window ? Object.freeze({ id: window.id, reason: window.reason }) : null,
|
|
403
|
-
explanation: window
|
|
404
|
-
? 'A complete observed value changed during an analyst-approved change window. The evidence is retained and labelled expected rather than removed.'
|
|
405
|
-
: 'Two complete source-attributed observations contain different values.',
|
|
406
|
-
}));
|
|
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.`;
|
|
407
477
|
}
|
|
408
|
-
else if (previous
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
domain: item.domain,
|
|
412
|
-
field: field.id,
|
|
413
|
-
observedAt: item.observedAt,
|
|
414
|
-
kind: 'recovered',
|
|
415
|
-
state: 'observed',
|
|
416
|
-
before: Object.freeze([]),
|
|
417
|
-
after: field.values,
|
|
418
|
-
source: field.source,
|
|
419
|
-
approvedWindow: null,
|
|
420
|
-
explanation: 'Collection returned a complete value after an earlier incomplete source state.',
|
|
421
|
-
}));
|
|
478
|
+
else if (!previous) {
|
|
479
|
+
kind = 'first_observation';
|
|
480
|
+
explanation = 'The first complete source-timed value for this field was retained.';
|
|
422
481
|
}
|
|
423
|
-
else if (previous.
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
domain: item.domain,
|
|
427
|
-
field: field.id,
|
|
428
|
-
observedAt: item.observedAt,
|
|
429
|
-
kind: 'collection_change',
|
|
430
|
-
state: 'partial',
|
|
431
|
-
before: previous.values,
|
|
432
|
-
after: Object.freeze([]),
|
|
433
|
-
source: field.source,
|
|
434
|
-
approvedWindow: null,
|
|
435
|
-
explanation: `The source became ${field.state}; this is a collection-quality change, not evidence that the prior value disappeared.`,
|
|
436
|
-
}));
|
|
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.';
|
|
437
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
|
+
}));
|
|
438
503
|
}
|
|
439
504
|
}
|
|
440
505
|
const domains = [...new Set(observations.map((item) => item.domain))].sort(ordinalCompare);
|
|
@@ -454,6 +519,7 @@ export function buildDomainControlFlightRecorder(inputRaw, generatedAtValue = ne
|
|
|
454
519
|
unexpectedChanges: observedChanges.filter((event) => event.approvedWindow === null).length,
|
|
455
520
|
collectionChanges: collectionChanges.length,
|
|
456
521
|
recoveredSources: events.filter((event) => event.kind === 'recovered').length,
|
|
522
|
+
incompleteFields,
|
|
457
523
|
}),
|
|
458
524
|
limitations: DOMAIN_CONTROL_FLIGHT_RECORDER_LIMITATIONS,
|
|
459
525
|
});
|
|
@@ -472,9 +538,15 @@ function exactRecorderInteger(value, minimum, maximum, label) {
|
|
|
472
538
|
}
|
|
473
539
|
return Number(value);
|
|
474
540
|
}
|
|
475
|
-
function exactRecorderValues(value, label) {
|
|
476
|
-
const input = boundedDataArray(value, label, 0, MAX_FLIGHT_RECORDER_VALUES);
|
|
477
|
-
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
|
+
});
|
|
478
550
|
if (new Set(normalized).size !== normalized.length
|
|
479
551
|
|| normalized.some((item, index) => index > 0 && normalized[index - 1] >= item)) {
|
|
480
552
|
throw new TypeError(`${label} must contain unique canonically ordered values.`);
|
|
@@ -484,9 +556,10 @@ function exactRecorderValues(value, label) {
|
|
|
484
556
|
export function validateDomainControlFlightRecorderDocument(value) {
|
|
485
557
|
const root = exactRecord(value, DOCUMENT_ROOT_KEYS, 'Domain control flight-recorder document');
|
|
486
558
|
if (root.schema !== DOMAIN_CONTROL_FLIGHT_RECORDER_SCHEMA
|
|
487
|
-
||
|
|
488
|
-
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 ')}.`);
|
|
489
561
|
}
|
|
562
|
+
const legacy = root.version === PUBLIC_DOMAIN_CONTROL_FLIGHT_RECORDER_VERSION;
|
|
490
563
|
const generatedAt = requireIsoTimestamp(root.generatedAt, 'Domain control flight-recorder document generatedAt');
|
|
491
564
|
if (generatedAt !== root.generatedAt) {
|
|
492
565
|
throw new TypeError('Domain control flight-recorder document generatedAt must use its canonical timestamp form.');
|
|
@@ -514,10 +587,14 @@ export function validateDomainControlFlightRecorderDocument(value) {
|
|
|
514
587
|
collection_change: 0,
|
|
515
588
|
recovered: 0,
|
|
516
589
|
approved_change: 0,
|
|
590
|
+
incomplete_observation: 0,
|
|
517
591
|
};
|
|
592
|
+
const seenIds = new Set();
|
|
593
|
+
const latest = new Map();
|
|
594
|
+
let lastCapture = -Infinity;
|
|
518
595
|
const events = eventInput.map((eventValue, index) => {
|
|
519
596
|
const label = `Domain control flight-recorder document event ${index + 1}`;
|
|
520
|
-
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);
|
|
521
598
|
const eventDomain = requireDomainName(event.domain, `${label}.domain`);
|
|
522
599
|
if (eventDomain !== event.domain || !domains.includes(eventDomain)) {
|
|
523
600
|
throw new TypeError(`${label}.domain must be present in the document domain list.`);
|
|
@@ -525,23 +602,32 @@ export function validateDomainControlFlightRecorderDocument(value) {
|
|
|
525
602
|
if (typeof event.field !== 'string' || !FIELD_IDS.has(event.field)) {
|
|
526
603
|
throw new TypeError(`${label}.field is unsupported.`);
|
|
527
604
|
}
|
|
528
|
-
const observedAt = requireIsoTimestamp(event.observedAt, `${label}.observedAt`);
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
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);
|
|
532
613
|
if (event.kind !== 'first_observation'
|
|
533
614
|
&& event.kind !== 'observed_change'
|
|
534
615
|
&& event.kind !== 'collection_change'
|
|
535
|
-
&& event.kind !== 'recovered'
|
|
616
|
+
&& event.kind !== 'recovered'
|
|
617
|
+
&& (legacy || event.kind !== 'incomplete_observation')) {
|
|
536
618
|
throw new TypeError(`${label}.kind is unsupported.`);
|
|
537
619
|
}
|
|
538
|
-
const
|
|
620
|
+
const kind = event.kind;
|
|
621
|
+
const expectedState = kind === 'collection_change' || kind === 'incomplete_observation' ? 'partial' : 'observed';
|
|
539
622
|
if (event.state !== expectedState)
|
|
540
623
|
throw new TypeError(`${label}.state is inconsistent.`);
|
|
541
|
-
|
|
542
|
-
|
|
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');
|
|
543
629
|
if (((event.kind === 'first_observation' || event.kind === 'recovered') && before.length)
|
|
544
|
-
|| (event.kind === 'collection_change' && after.length)) {
|
|
630
|
+
|| (legacy && event.kind === 'collection_change' && after.length)) {
|
|
545
631
|
throw new TypeError(`${label} before/after values are inconsistent.`);
|
|
546
632
|
}
|
|
547
633
|
let approvedWindow = null;
|
|
@@ -555,22 +641,35 @@ export function validateDomainControlFlightRecorderDocument(value) {
|
|
|
555
641
|
});
|
|
556
642
|
kindCounts.approved_change += 1;
|
|
557
643
|
}
|
|
558
|
-
kindCounts[
|
|
559
|
-
|
|
644
|
+
kindCounts[kind] += 1;
|
|
645
|
+
const result = Object.freeze({
|
|
560
646
|
id: exactRecorderText(event.id, `${label}.id`, 400),
|
|
561
647
|
domain: eventDomain,
|
|
562
648
|
field: event.field,
|
|
563
649
|
observedAt,
|
|
564
|
-
|
|
650
|
+
...(capturedAt ? { capturedAt } : {}),
|
|
651
|
+
kind,
|
|
565
652
|
state: expectedState,
|
|
566
653
|
before,
|
|
567
654
|
after,
|
|
568
655
|
source: exactRecorderText(event.source, `${label}.source`, MAX_FLIGHT_RECORDER_SOURCE_LENGTH),
|
|
569
656
|
approvedWindow,
|
|
570
|
-
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),
|
|
571
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;
|
|
572
670
|
});
|
|
573
|
-
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');
|
|
574
673
|
const expectedSummary = Object.freeze({
|
|
575
674
|
firstObservations: kindCounts.first_observation,
|
|
576
675
|
observedChanges: kindCounts.observed_change,
|
|
@@ -578,26 +677,28 @@ export function validateDomainControlFlightRecorderDocument(value) {
|
|
|
578
677
|
unexpectedChanges: kindCounts.observed_change - kindCounts.approved_change,
|
|
579
678
|
collectionChanges: kindCounts.collection_change,
|
|
580
679
|
recoveredSources: kindCounts.recovered,
|
|
680
|
+
...(!legacy ? { incompleteFields: kindCounts.collection_change + kindCounts.incomplete_observation } : {}),
|
|
581
681
|
});
|
|
582
|
-
for (const key of
|
|
682
|
+
for (const key of summaryKeys) {
|
|
583
683
|
const supplied = exactRecorderInteger(summaryInput[key], 0, MAX_FLIGHT_RECORDER_EVENTS, `Domain control flight-recorder document summary ${key}`);
|
|
584
684
|
if (supplied !== expectedSummary[key]) {
|
|
585
685
|
throw new TypeError('Domain control flight-recorder document summary is inconsistent.');
|
|
586
686
|
}
|
|
587
687
|
}
|
|
588
|
-
const
|
|
589
|
-
|
|
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])) {
|
|
590
691
|
throw new TypeError('Domain control flight-recorder document limitations are invalid.');
|
|
591
692
|
}
|
|
592
693
|
const document = Object.freeze({
|
|
593
694
|
schema: DOMAIN_CONTROL_FLIGHT_RECORDER_SCHEMA,
|
|
594
|
-
version: DOMAIN_CONTROL_FLIGHT_RECORDER_VERSION,
|
|
695
|
+
version: legacy ? PUBLIC_DOMAIN_CONTROL_FLIGHT_RECORDER_VERSION : DOMAIN_CONTROL_FLIGHT_RECORDER_VERSION,
|
|
595
696
|
generatedAt,
|
|
596
697
|
domains: Object.freeze(domains),
|
|
597
698
|
observationCount,
|
|
598
699
|
events: Object.freeze(events),
|
|
599
700
|
summary: expectedSummary,
|
|
600
|
-
limitations
|
|
701
|
+
limitations,
|
|
601
702
|
});
|
|
602
703
|
assertPrettyJsonByteBudget(document, MAX_DOMAIN_CONTROL_FLIGHT_RECORDER_OUTPUT_BYTES, 'Domain control flight-recorder document');
|
|
603
704
|
return document;
|
|
@@ -618,10 +719,13 @@ export function formatDomainControlFlightRecorder(document) {
|
|
|
618
719
|
`Unexpected changes ${document.summary.unexpectedChanges}`,
|
|
619
720
|
`Approved changes ${document.summary.approvedChanges}`,
|
|
620
721
|
`Collection changes ${document.summary.collectionChanges}`,
|
|
722
|
+
...(document.summary.incompleteFields === undefined ? [] : [`Incomplete fields ${document.summary.incompleteFields}`]),
|
|
621
723
|
'',
|
|
622
724
|
];
|
|
623
725
|
for (const event of document.events.filter((item) => item.kind !== 'first_observation')) {
|
|
624
|
-
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}`);
|
|
625
729
|
lines.push(` ${event.explanation}`);
|
|
626
730
|
}
|
|
627
731
|
if (!document.events.some((item) => item.kind !== 'first_observation')) {
|