@slicedearth/whoisleuth-cli 1.45.0 → 1.47.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (133) hide show
  1. package/DISCLOSURE +7 -3
  2. package/NOTICE +5 -4
  3. package/README.md +59 -6
  4. package/bin/whoisleuth.mjs +5 -3
  5. package/cli/arguments.mjs +256 -9
  6. package/cli/artifact-structure.mjs +1833 -0
  7. package/cli/artifact-verify.mjs +242 -49
  8. package/cli/bounded-json.mjs +1 -0
  9. package/cli/bulk-checkpoint.mjs +2 -3
  10. package/cli/bulk-command-runner.mjs +2 -5
  11. package/cli/case-pack.mjs +819 -34
  12. package/cli/command-reference.mjs +48 -24
  13. package/cli/completion.mjs +16 -8
  14. package/cli/ct-event-intake.mjs +32 -2
  15. package/cli/discovery-scan-command-runner.mjs +4 -5
  16. package/cli/discovery-workflow.mjs +2 -3
  17. package/cli/doctor.mjs +2 -2
  18. package/cli/domain-control-monitor.mjs +21 -15
  19. package/cli/domain-control-observations.mjs +25 -2
  20. package/cli/evidence-command-runner.mjs +17 -11
  21. package/cli/evidence-signing.mjs +73 -45
  22. package/cli/export-evidence.mjs +11 -4
  23. package/cli/formatters/html.mjs +3 -3
  24. package/cli/formatters/terminal.mjs +3 -0
  25. package/cli/input.mjs +57 -0
  26. package/cli/interchange-report.mjs +226 -0
  27. package/cli/investigation-manifest.mjs +16 -17
  28. package/cli/investigation-run.mjs +71 -23
  29. package/cli/local-mmdb-review.mjs +45 -14
  30. package/cli/lookup-command-runner.mjs +2 -2
  31. package/cli/lookup-diff.mjs +9 -0
  32. package/cli/network-command-runner.mjs +78 -0
  33. package/cli/offline-evidence-review.mjs +28 -8
  34. package/cli/page-compare.mjs +8 -5
  35. package/cli/registry-cohort.mjs +385 -61
  36. package/cli/registry-support.mjs +2 -1
  37. package/cli/retained-artifact-diff.mjs +376 -0
  38. package/cli/risk-calibration.mjs +7 -5
  39. package/cli/runner.mjs +147 -185
  40. package/cli/saved-lookup.mjs +26 -3
  41. package/cli/sharing-review.mjs +12 -6
  42. package/docs/cli-reference.md +444 -70
  43. package/docs/cli.md +24 -5
  44. package/frontend/src/lib/analysis/acquisition-decision-packet.js +4 -4
  45. package/frontend/src/lib/analysis/artifact-integrity.js +42 -0
  46. package/frontend/src/lib/analysis/brand-profile-model.js +29 -5
  47. package/frontend/src/lib/analysis/bulk-domain-comparison.js +25 -5
  48. package/frontend/src/lib/analysis/bulk-mail-exposure.js +66 -15
  49. package/frontend/src/lib/analysis/bulk-retry-plan.js +1 -1
  50. package/frontend/src/lib/analysis/bulk-review-export.js +7 -6
  51. package/frontend/src/lib/analysis/bulk-review-model.js +3 -2
  52. package/frontend/src/lib/analysis/bulk-session-model.js +192 -26
  53. package/frontend/src/lib/analysis/bulk-sort.js +4 -0
  54. package/frontend/src/lib/analysis/case-brand-profile-references.js +85 -0
  55. package/frontend/src/lib/analysis/case-evidence-model.js +8 -0
  56. package/frontend/src/lib/analysis/case-investigation-branch-model.js +134 -0
  57. package/frontend/src/lib/analysis/case-migration-model.js +83 -31
  58. package/frontend/src/lib/analysis/case-record-contracts.js +4 -2
  59. package/frontend/src/lib/analysis/case-record-model.js +2 -0
  60. package/frontend/src/lib/analysis/case-record-operations.js +42 -12
  61. package/frontend/src/lib/analysis/case-report.js +39 -6
  62. package/frontend/src/lib/analysis/case-response-model.js +66 -7
  63. package/frontend/src/lib/analysis/case-response-packet.js +15 -5
  64. package/frontend/src/lib/analysis/comparison-ledger-bulk.js +366 -0
  65. package/frontend/src/lib/analysis/comparison-ledger-contract.js +241 -0
  66. package/frontend/src/lib/analysis/comparison-ledger-json.js +79 -0
  67. package/frontend/src/lib/analysis/comparison-ledger-serialization.js +51 -0
  68. package/frontend/src/lib/analysis/domain-control-manifest-core.js +145 -0
  69. package/frontend/src/lib/analysis/domain-control-records.js +88 -0
  70. package/frontend/src/lib/analysis/external-findings-import.js +70 -6
  71. package/frontend/src/lib/analysis/investigation-capsule.js +47 -13
  72. package/frontend/src/lib/analysis/investigation-guide.js +52 -10
  73. package/frontend/src/lib/analysis/investigation-template-model.js +9 -6
  74. package/frontend/src/lib/analysis/lookup-claim-passport.js +236 -0
  75. package/frontend/src/lib/analysis/lookup-claim-readiness.js +280 -0
  76. package/frontend/src/lib/analysis/lookup-decision-support.js +61 -6
  77. package/frontend/src/lib/analysis/lookup-presentation.js +62 -0
  78. package/frontend/src/lib/analysis/opaque-reference-id.js +9 -0
  79. package/frontend/src/lib/analysis/utils.js +26 -3
  80. package/frontend/src/lib/analysis/visualization-matrix.js +71 -28
  81. package/frontend/src/lib/analysis/visualization-models.js +63 -94
  82. package/frontend/src/lib/analysis/visualization-time-series.js +53 -12
  83. package/frontend/src/lib/analysis/website-snapshot-model.js +6 -0
  84. package/frontend/src/lib/analysis/workspace-archive.js +104 -14
  85. package/lib/application-version.mjs +3 -0
  86. package/lib/availability.mjs +14 -12
  87. package/lib/bounded-file.mjs +13 -0
  88. package/lib/bounded-json-response.mjs +17 -4
  89. package/lib/bounded-json.mjs +249 -0
  90. package/lib/certificate-extension-profile.mjs +2 -1
  91. package/lib/cryptographic-assurance.mjs +155 -0
  92. package/lib/ct-search.mjs +87 -47
  93. package/lib/dns-intelligence.mjs +3 -2
  94. package/lib/dns-resolver-selection.mjs +6 -4
  95. package/lib/dnssec-chain-validation.mjs +1053 -0
  96. package/lib/dnssec-evidence-validation.mjs +10 -3
  97. package/lib/domain-change-packet.mjs +7 -5
  98. package/lib/domain-control-manifest.mjs +17 -129
  99. package/lib/domain-posture.mjs +7 -4
  100. package/lib/evidence-export.mjs +751 -78
  101. package/lib/evidence-report-markdown.mjs +3 -0
  102. package/lib/evidence-report.mjs +5 -0
  103. package/lib/favicon.mjs +8 -5
  104. package/lib/http-evidence-bounds.mjs +3 -0
  105. package/lib/http-intelligence.mjs +2 -5
  106. package/lib/interchange-fidelity-registry.mjs +122 -0
  107. package/lib/local-geoip-evidence.mjs +2 -1
  108. package/lib/lookup-network-evidence-bounds.mjs +9 -0
  109. package/lib/lookup-response-contract.mjs +691 -3
  110. package/lib/lookup.mjs +2 -2
  111. package/lib/observation.mjs +7 -6
  112. package/lib/outbound-identity.mjs +2 -3
  113. package/lib/perceptual-hash.mjs +12 -4
  114. package/lib/portable-generator.mjs +21 -0
  115. package/lib/provider-policy-admission.mjs +9 -4
  116. package/lib/rdap-normalization.mjs +18 -5
  117. package/lib/rdap-registrar.mjs +5 -1
  118. package/lib/registry-capabilities.mjs +3 -0
  119. package/lib/registry-capability-catalogue.mjs +12 -2
  120. package/lib/registry-comparison.mjs +28 -11
  121. package/lib/risk-calibration-summary.mjs +338 -0
  122. package/lib/rpki-evidence.mjs +9 -8
  123. package/lib/safe-fetch.mjs +59 -10
  124. package/lib/security-txt.mjs +26 -3
  125. package/lib/service-binding-dns.mjs +6 -1
  126. package/lib/smtp-transport-review.mjs +784 -0
  127. package/lib/sslbl-certificates.generated.mjs +136 -134
  128. package/lib/threat-intelligence-runtime.mjs +3 -11
  129. package/lib/threat-intelligence-types.mjs +18 -1
  130. package/lib/tls-intelligence.mjs +11 -5
  131. package/lib/tlsa-evidence.mjs +37 -7
  132. package/package.json +1 -1
  133. package/third-party-notices.txt +95 -5817
@@ -1,119 +1,443 @@
1
1
  import { Buffer } from 'node:buffer';
2
+ import { canonicalArtifactJsonV2 } from "../frontend/src/lib/analysis/artifact-integrity.js";
3
+ import { scanBoundedJson } from "./bounded-json.mjs";
2
4
  import { CliUsageError } from "./errors.mjs";
3
5
  import { buildRegistryDoctorReport } from "./registry-doctor.mjs";
6
+ import { SAVED_LOOKUP_SCHEMA, parseSavedLookupDocument, } from "./saved-lookup.mjs";
4
7
  export const REGISTRY_COHORT_SCHEMA = 'whoisleuth.cli.registry-cohort';
5
- export const REGISTRY_COHORT_VERSION = 1;
8
+ export const LEGACY_REGISTRY_COHORT_VERSION = 1;
9
+ export const REGISTRY_COHORT_VERSION = 2;
6
10
  export const MAX_REGISTRY_COHORT_INPUT_BYTES = 16 * 1024 * 1024;
7
11
  export const MAX_REGISTRY_COHORT_SAMPLES = 500;
12
+ export const MAX_REGISTRY_COHORT_GROUPS = 500;
13
+ export const MAX_REGISTRY_COHORT_INPUT_POINTS = 2_000;
14
+ export const MAX_REGISTRY_COHORT_TIMELINE_POINTS = 50;
15
+ export const MAX_REGISTRY_COHORT_OMISSIONS = 1_000_000;
8
16
  export const MIN_REGISTRY_COHORT_SAMPLE = 5;
9
- function record(value) {
10
- return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
17
+ const LEGACY_ROOT_KEYS = new Set(['schema', 'version', 'generatedAt', 'sampleCount', 'minimumCohortSample', 'cohorts', 'limitations']);
18
+ const LEGACY_COHORT_KEYS = new Set(['suffix', 'profileId', 'sampleCount', 'state', 'sourceAlignment', 'publication']);
19
+ const ROOT_KEYS = new Set(['schema', 'version', 'generatedAt', 'inputFamily', 'sampleCount', 'reportsMerged', 'minimumCohortSample', 'sampleWindow', 'cohorts', 'omissions', 'truncated', 'limitations']);
20
+ const COHORT_KEYS = new Set(['suffix', 'profileId', 'sampleCount', 'state', 'latestState', 'sampleWindow', 'sourceAlignment', 'publication', 'timeline', 'timelineOmitted']);
21
+ const POINT_KEYS = new Set(['reportGeneratedAt', 'sampleWindow', 'sampleCount', 'state', 'sourceAlignment', 'publication']);
22
+ const SOURCE_KEYS = new Set(['investigate', 'expectedConstraints', 'observed']);
23
+ const PUBLICATION_KEYS = new Set(['reviewItems', 'objectIdentifiersObserved', 'baseConformanceObserved', 'selfLinksObserved', 'redactionMetadataObserved']);
24
+ const WINDOW_KEYS = new Set(['from', 'to']);
25
+ const OMISSION_KEYS = new Set(['duplicateTimelinePoints', 'timelinePoints']);
26
+ const LIMITATIONS = Object.freeze([
27
+ 'The report intentionally omits domains, queries and raw evidence. It groups retained observations only by suffix and capability profile.',
28
+ `Every timeline point retains its original state. Cohorts with fewer than ${MIN_REGISTRY_COHORT_SAMPLE} observations remain insufficient sample and are never promoted by summing overlapping reports.`,
29
+ 'A merged cohort keeps the most conservative retained state and separately reports the latest point. Duplicate points are suppressed and inherited omission counts are not summed across overlapping retained reports.',
30
+ 'Review fixture provenance and source health before changing a parser or registry access profile.',
31
+ ]);
32
+ const LEGACY_LIMITATIONS = Object.freeze([
33
+ 'The report intentionally omits domains, queries and raw evidence. It groups saved observations only by suffix and capability profile.',
34
+ `Cohorts with fewer than ${MIN_REGISTRY_COHORT_SAMPLE} observations remain insufficient sample and must not drive catalogue changes.`,
35
+ 'Repeated observations from one environment are not representative by themselves. Review fixture provenance and source health before changing a parser or access profile.',
36
+ ]);
37
+ function record(value, label) {
38
+ if (!value || typeof value !== 'object' || Array.isArray(value))
39
+ throw new CliUsageError(`${label} must be an object.`);
40
+ return value;
11
41
  }
12
- function documents(text) {
13
- if (Buffer.byteLength(text, 'utf8') > MAX_REGISTRY_COHORT_INPUT_BYTES) {
14
- throw new CliUsageError(`Registry cohort input is limited to ${MAX_REGISTRY_COHORT_INPUT_BYTES} bytes.`);
42
+ function exact(value, keys, label) {
43
+ const item = record(value, label);
44
+ if (Object.keys(item).some((key) => !keys.has(key)) || [...keys].some((key) => !Object.hasOwn(item, key))) {
45
+ throw new CliUsageError(`${label} does not match its exact retained contract.`);
46
+ }
47
+ return item;
48
+ }
49
+ function integer(value, minimum, maximum, label) {
50
+ if (!Number.isSafeInteger(value) || Number(value) < minimum || Number(value) > maximum) {
51
+ throw new CliUsageError(`${label} is outside its supported bound.`);
52
+ }
53
+ return Number(value);
54
+ }
55
+ function timestamp(value, label) {
56
+ if (typeof value !== 'string' || value.length > 64 || /[\u0000-\u001f\u007f]/u.test(value)) {
57
+ throw new CliUsageError(`${label} is invalid.`);
58
+ }
59
+ const parsed = Date.parse(value);
60
+ if (!Number.isFinite(parsed))
61
+ throw new CliUsageError(`${label} is invalid.`);
62
+ return new Date(parsed).toISOString();
63
+ }
64
+ function identifier(value, label) {
65
+ if (typeof value !== 'string' || !/^[a-z0-9][a-z0-9_-]{0,119}$/u.test(value))
66
+ throw new CliUsageError(`${label} is invalid.`);
67
+ return value;
68
+ }
69
+ function suffix(value) {
70
+ if (typeof value !== 'string' || !/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/u.test(value))
71
+ throw new CliUsageError('Registry cohort suffix is invalid.');
72
+ return value;
73
+ }
74
+ function state(value, label) {
75
+ if (value !== 'consistent' && value !== 'insufficient_sample' && value !== 'review')
76
+ throw new CliUsageError(`${label} is invalid.`);
77
+ return value;
78
+ }
79
+ function window(value, label) {
80
+ const item = exact(value, WINDOW_KEYS, label);
81
+ const from = timestamp(item.from, `${label} from`);
82
+ const to = timestamp(item.to, `${label} to`);
83
+ if (from > to)
84
+ throw new CliUsageError(`${label} is reversed.`);
85
+ return Object.freeze({ from, to });
86
+ }
87
+ function sourceAlignment(value, sampleCount, label) {
88
+ const item = exact(value, SOURCE_KEYS, label);
89
+ return Object.freeze({
90
+ investigate: integer(item.investigate, 0, sampleCount * 2, `${label} investigate`),
91
+ expectedConstraints: integer(item.expectedConstraints, 0, sampleCount * 2, `${label} expected constraints`),
92
+ observed: integer(item.observed, 0, sampleCount * 2, `${label} observed`),
93
+ });
94
+ }
95
+ function publication(value, sampleCount, label) {
96
+ const item = exact(value, PUBLICATION_KEYS, label);
97
+ return Object.freeze({
98
+ reviewItems: integer(item.reviewItems, 0, sampleCount * 4, `${label} review items`),
99
+ objectIdentifiersObserved: integer(item.objectIdentifiersObserved, 0, sampleCount, `${label} object identifiers`),
100
+ baseConformanceObserved: integer(item.baseConformanceObserved, 0, sampleCount, `${label} base conformance`),
101
+ selfLinksObserved: integer(item.selfLinksObserved, 0, sampleCount, `${label} self links`),
102
+ redactionMetadataObserved: integer(item.redactionMetadataObserved, 0, sampleCount, `${label} redaction metadata`),
103
+ });
104
+ }
105
+ function derivedState(sampleCount, sources, published) {
106
+ return sampleCount < MIN_REGISTRY_COHORT_SAMPLE
107
+ ? 'insufficient_sample'
108
+ : sources.investigate || published.reviewItems
109
+ ? 'review'
110
+ : 'consistent';
111
+ }
112
+ function conservativeState(states) {
113
+ return states.includes('review') ? 'review' : states.includes('insufficient_sample') ? 'insufficient_sample' : 'consistent';
114
+ }
115
+ function timelinePoint(value, label) {
116
+ const item = exact(value, POINT_KEYS, label);
117
+ const sampleCount = integer(item.sampleCount, 1, MAX_REGISTRY_COHORT_SAMPLES, `${label} sample count`);
118
+ const sources = sourceAlignment(item.sourceAlignment, sampleCount, `${label} source alignment`);
119
+ const published = publication(item.publication, sampleCount, `${label} publication`);
120
+ const pointState = state(item.state, `${label} state`);
121
+ if (pointState !== derivedState(sampleCount, sources, published))
122
+ throw new CliUsageError(`${label} state does not match its retained counts.`);
123
+ return Object.freeze({
124
+ reportGeneratedAt: timestamp(item.reportGeneratedAt, `${label} report generatedAt`),
125
+ sampleWindow: window(item.sampleWindow, `${label} sample window`),
126
+ sampleCount,
127
+ state: pointState,
128
+ sourceAlignment: sources,
129
+ publication: published,
130
+ });
131
+ }
132
+ function boundedLimitations(value, expected, label) {
133
+ if (!Array.isArray(value) || value.length !== expected.length || value.some((item, index) => item !== expected[index])) {
134
+ throw new CliUsageError(`${label} limitations do not match the supported target-free contract.`);
15
135
  }
136
+ }
137
+ function parseInputDocuments(text) {
138
+ if (Buffer.byteLength(text, 'utf8') > MAX_REGISTRY_COHORT_INPUT_BYTES)
139
+ throw new CliUsageError(`Registry cohort input is limited to ${MAX_REGISTRY_COHORT_INPUT_BYTES} bytes.`);
16
140
  const trimmed = text.replace(/^\uFEFF/u, '').trim();
17
141
  if (!trimmed)
18
142
  throw new CliUsageError('Registry cohort input is empty.');
19
143
  let values;
20
144
  if (trimmed.startsWith('[')) {
21
- let parsed;
22
145
  try {
23
- parsed = JSON.parse(trimmed);
146
+ scanBoundedJson(trimmed);
147
+ const parsed = JSON.parse(trimmed);
148
+ if (!Array.isArray(parsed))
149
+ throw new Error('not array');
150
+ values = parsed;
24
151
  }
25
- catch {
26
- throw new CliUsageError('Registry cohort input must be valid JSON or JSONL.');
152
+ catch (error) {
153
+ if (error instanceof TypeError && /duplicate|nesting|value limit|key limit/iu.test(error.message))
154
+ throw new CliUsageError(error.message);
155
+ throw new CliUsageError('Registry cohort JSON input must be a valid array.');
27
156
  }
28
- if (!Array.isArray(parsed))
29
- throw new CliUsageError('Registry cohort JSON input must be an array.');
30
- values = parsed;
31
157
  }
32
158
  else {
33
159
  const lines = trimmed.split(/\r?\n/u).filter((line) => line.trim());
160
+ if (!lines.length || lines.length > MAX_REGISTRY_COHORT_SAMPLES) {
161
+ throw new CliUsageError(`Registry cohort input must contain from 1 to ${MAX_REGISTRY_COHORT_SAMPLES} saved Lookups or retained cohort reports.`);
162
+ }
34
163
  values = lines.map((line, index) => {
35
164
  try {
165
+ scanBoundedJson(line);
36
166
  return JSON.parse(line);
37
167
  }
38
- catch {
168
+ catch (error) {
169
+ if (error instanceof TypeError && /duplicate|nesting|value limit|key limit/iu.test(error.message))
170
+ throw new CliUsageError(`Registry cohort JSONL line ${index + 1}: ${error.message}`);
39
171
  throw new CliUsageError(`Registry cohort JSONL line ${index + 1} is invalid.`);
40
172
  }
41
173
  });
42
174
  }
43
- if (!values.length || values.length > MAX_REGISTRY_COHORT_SAMPLES) {
44
- throw new CliUsageError(`Registry cohort input must contain from 1 to ${MAX_REGISTRY_COHORT_SAMPLES} saved Lookups.`);
45
- }
46
- const output = values.map(record);
47
- if (output.some((item) => !Object.keys(item).length))
48
- throw new CliUsageError('Every registry cohort item must be one saved Lookup object.');
49
- return output;
175
+ if (!values.length || values.length > MAX_REGISTRY_COHORT_SAMPLES)
176
+ throw new CliUsageError(`Registry cohort input must contain from 1 to ${MAX_REGISTRY_COHORT_SAMPLES} saved Lookups or retained cohort reports.`);
177
+ return values.map((value, index) => record(value, `Registry cohort item ${index + 1}`));
50
178
  }
51
- export function buildRegistryCohortReport(text, generatedAt = new Date().toISOString()) {
52
- const reports = documents(text).map((item) => buildRegistryDoctorReport(JSON.stringify(item), generatedAt));
179
+ function groupPoint(reports, reportGeneratedAt) {
180
+ const sampleCount = reports.length;
181
+ const sources = Object.freeze({
182
+ investigate: reports.reduce((total, item) => total + item.summary.investigate, 0),
183
+ expectedConstraints: reports.reduce((total, item) => total + item.summary.expectedConstraints, 0),
184
+ observed: reports.reduce((total, item) => total + item.summary.observed, 0),
185
+ });
186
+ const published = Object.freeze({
187
+ reviewItems: reports.reduce((total, item) => total + item.publication.reviewItems, 0),
188
+ objectIdentifiersObserved: reports.filter((item) => item.publication.objectIdentifier.state === 'observed').length,
189
+ baseConformanceObserved: reports.filter((item) => item.publication.baseConformance.state === 'observed').length,
190
+ selfLinksObserved: reports.filter((item) => item.publication.selfLink.state === 'observed').length,
191
+ redactionMetadataObserved: reports.filter((item) => item.publication.redactionMetadata.state === 'observed').length,
192
+ });
193
+ const observed = reports.map((report) => report.generatedAt).sort();
194
+ return Object.freeze({
195
+ reportGeneratedAt,
196
+ sampleWindow: Object.freeze({ from: observed[0], to: observed.at(-1) }),
197
+ sampleCount,
198
+ state: derivedState(sampleCount, sources, published),
199
+ sourceAlignment: sources,
200
+ publication: published,
201
+ });
202
+ }
203
+ function sourceReportsFromLookups(documents, generatedAt) {
204
+ const reports = documents.map((item) => {
205
+ const raw = JSON.stringify(item);
206
+ const lookup = parseSavedLookupDocument(raw, { label: 'Registry cohort saved Lookup' });
207
+ return buildRegistryDoctorReport(raw, timestamp(lookup.generatedAt, 'Saved Lookup generatedAt'));
208
+ });
53
209
  const groups = new Map();
54
210
  for (const report of reports) {
55
211
  const key = `${report.suffix}\u0000${report.profile.id}`;
56
- const group = groups.get(key) ?? [];
57
- group.push(report);
58
- groups.set(key, group);
212
+ groups.set(key, [...(groups.get(key) ?? []), report]);
59
213
  }
60
214
  const cohorts = [...groups.values()].map((group) => {
61
215
  const sample = group[0];
62
- if (!sample)
63
- throw new CliUsageError('Registry cohort grouping failed.');
64
- const sampleCount = group.length;
65
- const investigate = group.reduce((total, item) => total + item.summary.investigate, 0);
66
- const publicationReviewItems = group.reduce((total, item) => total + item.publication.reviewItems, 0);
216
+ const point = groupPoint(group, generatedAt);
217
+ return Object.freeze({ suffix: sample.suffix, profileId: sample.profile.id, sampleWindow: point.sampleWindow, points: Object.freeze([point]), inheritedState: point.state, upstreamTimelineOmitted: 0 });
218
+ });
219
+ const observed = reports.map((report) => report.generatedAt).sort();
220
+ return [Object.freeze({ generatedAt, sampleWindow: Object.freeze({ from: observed[0], to: observed.at(-1) }), cohorts: Object.freeze(cohorts), duplicateTimelinePoints: 0 })];
221
+ }
222
+ function parseLegacyReport(value) {
223
+ const root = exact(value, LEGACY_ROOT_KEYS, 'Registry cohort v1 report');
224
+ if (root.schema !== REGISTRY_COHORT_SCHEMA || root.version !== LEGACY_REGISTRY_COHORT_VERSION)
225
+ throw new CliUsageError('Registry cohort report version is unsupported.');
226
+ const generatedAt = timestamp(root.generatedAt, 'Registry cohort v1 generatedAt');
227
+ const sampleCount = integer(root.sampleCount, 1, MAX_REGISTRY_COHORT_SAMPLES, 'Registry cohort v1 sample count');
228
+ if (root.minimumCohortSample !== MIN_REGISTRY_COHORT_SAMPLE)
229
+ throw new CliUsageError('Registry cohort v1 minimum sample is unsupported.');
230
+ boundedLimitations(root.limitations, LEGACY_LIMITATIONS, 'Registry cohort v1');
231
+ if (!Array.isArray(root.cohorts) || !root.cohorts.length || root.cohorts.length > MAX_REGISTRY_COHORT_GROUPS)
232
+ throw new CliUsageError('Registry cohort v1 groups are invalid.');
233
+ const seen = new Set();
234
+ let represented = 0;
235
+ const cohorts = root.cohorts.map((candidate, index) => {
236
+ const item = exact(candidate, LEGACY_COHORT_KEYS, `Registry cohort v1 group ${index + 1}`);
237
+ const count = integer(item.sampleCount, 1, sampleCount, 'Registry cohort v1 group sample count');
238
+ const sources = sourceAlignment(item.sourceAlignment, count, 'Registry cohort v1 source alignment');
239
+ const published = publication(item.publication, count, 'Registry cohort v1 publication');
240
+ const pointState = state(item.state, 'Registry cohort v1 state');
241
+ if (pointState !== derivedState(count, sources, published))
242
+ throw new CliUsageError('Registry cohort v1 state does not match its retained counts.');
243
+ const key = `${suffix(item.suffix)}\u0000${identifier(item.profileId, 'Registry cohort v1 profile ID')}`;
244
+ if (seen.has(key))
245
+ throw new CliUsageError('Registry cohort v1 groups must be unique.');
246
+ seen.add(key);
247
+ represented += count;
248
+ const point = Object.freeze({ reportGeneratedAt: generatedAt, sampleWindow: Object.freeze({ from: generatedAt, to: generatedAt }), sampleCount: count, state: pointState, sourceAlignment: sources, publication: published });
249
+ return Object.freeze({ suffix: key.split('\u0000')[0], profileId: key.split('\u0000')[1], sampleWindow: point.sampleWindow, points: Object.freeze([point]), inheritedState: pointState, upstreamTimelineOmitted: 0 });
250
+ });
251
+ if (represented !== sampleCount)
252
+ throw new CliUsageError('Registry cohort v1 sample count does not match its groups.');
253
+ return Object.freeze({ generatedAt, sampleWindow: Object.freeze({ from: generatedAt, to: generatedAt }), cohorts: Object.freeze(cohorts), duplicateTimelinePoints: 0 });
254
+ }
255
+ function parseCurrentReport(value) {
256
+ const root = exact(value, ROOT_KEYS, 'Registry cohort v2 report');
257
+ if (root.schema !== REGISTRY_COHORT_SCHEMA || root.version !== REGISTRY_COHORT_VERSION)
258
+ throw new CliUsageError('Registry cohort report version is unsupported.');
259
+ const generatedAt = timestamp(root.generatedAt, 'Registry cohort v2 generatedAt');
260
+ if (root.inputFamily !== 'saved_lookups' && root.inputFamily !== 'retained_reports')
261
+ throw new CliUsageError('Registry cohort v2 input family is invalid.');
262
+ const reportsMerged = integer(root.reportsMerged, 0, MAX_REGISTRY_COHORT_SAMPLES, 'Registry cohort v2 reports merged');
263
+ if ((root.inputFamily === 'saved_lookups') !== (reportsMerged === 0))
264
+ throw new CliUsageError('Registry cohort v2 report count does not match its input family.');
265
+ if (root.minimumCohortSample !== MIN_REGISTRY_COHORT_SAMPLE)
266
+ throw new CliUsageError('Registry cohort v2 minimum sample is unsupported.');
267
+ const reportWindow = window(root.sampleWindow, 'Registry cohort v2 sample window');
268
+ boundedLimitations(root.limitations, LIMITATIONS, 'Registry cohort v2');
269
+ const omissions = exact(root.omissions, OMISSION_KEYS, 'Registry cohort v2 omissions');
270
+ const duplicateTimelinePoints = integer(omissions.duplicateTimelinePoints, 0, MAX_REGISTRY_COHORT_OMISSIONS, 'Registry cohort v2 duplicate points');
271
+ const omittedTimelinePoints = integer(omissions.timelinePoints, 0, MAX_REGISTRY_COHORT_OMISSIONS, 'Registry cohort v2 omitted points');
272
+ if (root.truncated !== (omittedTimelinePoints > 0))
273
+ throw new CliUsageError('Registry cohort v2 truncation state is inconsistent.');
274
+ if (!Array.isArray(root.cohorts) || !root.cohorts.length || root.cohorts.length > MAX_REGISTRY_COHORT_GROUPS)
275
+ throw new CliUsageError('Registry cohort v2 groups are invalid.');
276
+ const seen = new Set();
277
+ let represented = 0;
278
+ let cohortOmitted = 0;
279
+ const cohorts = root.cohorts.map((candidate, index) => {
280
+ const item = exact(candidate, COHORT_KEYS, `Registry cohort v2 group ${index + 1}`);
281
+ const groupSuffix = suffix(item.suffix);
282
+ const profileId = identifier(item.profileId, 'Registry cohort v2 profile ID');
283
+ const key = `${groupSuffix}\u0000${profileId}`;
284
+ if (seen.has(key))
285
+ throw new CliUsageError('Registry cohort v2 groups must be unique.');
286
+ seen.add(key);
287
+ const count = integer(item.sampleCount, 1, MAX_REGISTRY_COHORT_SAMPLES, 'Registry cohort v2 group sample count');
288
+ const groupWindow = window(item.sampleWindow, 'Registry cohort v2 group sample window');
289
+ const timelineOmitted = integer(item.timelineOmitted, 0, MAX_REGISTRY_COHORT_OMISSIONS, 'Registry cohort v2 group omitted points');
290
+ cohortOmitted += timelineOmitted;
291
+ if (!Array.isArray(item.timeline) || !item.timeline.length || item.timeline.length > MAX_REGISTRY_COHORT_TIMELINE_POINTS)
292
+ throw new CliUsageError('Registry cohort v2 timeline is invalid.');
293
+ const points = item.timeline.map((point, pointIndex) => timelinePoint(point, `Registry cohort v2 point ${pointIndex + 1}`));
294
+ const identities = points.map(canonicalArtifactJsonV2);
295
+ if (new Set(identities).size !== points.length || identities.some((identity, pointIndex) => pointIndex > 0 && pointOrder(points[pointIndex - 1], points[pointIndex]) > 0))
296
+ throw new CliUsageError('Registry cohort v2 timeline must be unique and ordered.');
297
+ const latest = points.at(-1);
298
+ const groupState = state(item.state, 'Registry cohort v2 group state');
299
+ const latestState = state(item.latestState, 'Registry cohort v2 latest state');
300
+ const sources = sourceAlignment(item.sourceAlignment, count, 'Registry cohort v2 group source alignment');
301
+ const published = publication(item.publication, count, 'Registry cohort v2 group publication');
302
+ const visibleState = conservativeState(points.map((point) => point.state));
303
+ const pointFrom = points.map((point) => point.sampleWindow.from).sort()[0];
304
+ const pointTo = points.map((point) => point.sampleWindow.to).sort().at(-1);
305
+ if (latestState !== latest.state || count !== latest.sampleCount
306
+ || canonicalArtifactJsonV2(sources) !== canonicalArtifactJsonV2(latest.sourceAlignment)
307
+ || canonicalArtifactJsonV2(published) !== canonicalArtifactJsonV2(latest.publication)
308
+ || (timelineOmitted === 0 && groupState !== visibleState)
309
+ || (timelineOmitted > 0 && conservativeState([groupState, visibleState]) !== groupState)
310
+ || groupWindow.from > pointFrom || groupWindow.to < pointTo) {
311
+ throw new CliUsageError('Registry cohort v2 current group projection is inconsistent with its timeline.');
312
+ }
313
+ represented += count;
314
+ return Object.freeze({ suffix: groupSuffix, profileId, sampleWindow: groupWindow, points: Object.freeze(points), inheritedState: groupState, upstreamTimelineOmitted: timelineOmitted });
315
+ });
316
+ if (integer(root.sampleCount, 1, MAX_REGISTRY_COHORT_GROUPS * MAX_REGISTRY_COHORT_SAMPLES, 'Registry cohort v2 sample count') !== represented
317
+ || cohortOmitted !== omittedTimelinePoints
318
+ || reportWindow.from !== [...cohorts].sort((a, b) => a.sampleWindow.from.localeCompare(b.sampleWindow.from))[0]?.sampleWindow.from
319
+ || reportWindow.to !== [...cohorts].sort((a, b) => b.sampleWindow.to.localeCompare(a.sampleWindow.to))[0]?.sampleWindow.to) {
320
+ throw new CliUsageError('Registry cohort v2 report summary is inconsistent with its groups.');
321
+ }
322
+ return Object.freeze({ generatedAt, sampleWindow: reportWindow, cohorts: Object.freeze(cohorts), duplicateTimelinePoints });
323
+ }
324
+ function pointOrder(left, right) {
325
+ return left.sampleWindow.to.localeCompare(right.sampleWindow.to)
326
+ || left.reportGeneratedAt.localeCompare(right.reportGeneratedAt)
327
+ || canonicalArtifactJsonV2(left).localeCompare(canonicalArtifactJsonV2(right));
328
+ }
329
+ function buildCurrentReport(sourceReports, generatedAt, inputFamily, directSampleCount) {
330
+ const totalPoints = sourceReports.reduce((sum, report) => sum + report.cohorts.reduce((groupSum, cohort) => groupSum + cohort.points.length, 0), 0);
331
+ if (totalPoints > MAX_REGISTRY_COHORT_INPUT_POINTS)
332
+ throw new CliUsageError(`Retained registry cohort input contains more than ${MAX_REGISTRY_COHORT_INPUT_POINTS} timeline points.`);
333
+ const groups = new Map();
334
+ for (const report of sourceReports)
335
+ for (const cohort of report.cohorts) {
336
+ const key = `${cohort.suffix}\u0000${cohort.profileId}`;
337
+ groups.set(key, [...(groups.get(key) ?? []), cohort]);
338
+ }
339
+ if (!groups.size || groups.size > MAX_REGISTRY_COHORT_GROUPS)
340
+ throw new CliUsageError(`Registry cohort output is limited to ${MAX_REGISTRY_COHORT_GROUPS} suffix/profile groups.`);
341
+ let duplicateTimelinePoints = Math.max(0, ...sourceReports.map((report) => report.duplicateTimelinePoints));
342
+ const cohorts = [...groups.entries()].map(([key, inputs]) => {
343
+ const unique = new Map();
344
+ for (const input of inputs)
345
+ for (const point of input.points) {
346
+ const identity = canonicalArtifactJsonV2(point);
347
+ if (unique.has(identity))
348
+ duplicateTimelinePoints += 1;
349
+ else
350
+ unique.set(identity, point);
351
+ }
352
+ const allPoints = [...unique.values()].sort(pointOrder);
353
+ const locallyOmitted = Math.max(0, allPoints.length - MAX_REGISTRY_COHORT_TIMELINE_POINTS);
354
+ const timeline = allPoints.slice(-MAX_REGISTRY_COHORT_TIMELINE_POINTS);
355
+ const latest = timeline.at(-1);
356
+ const upstreamTimelineOmitted = Math.max(0, ...inputs.map((input) => input.upstreamTimelineOmitted));
357
+ const groupWindow = Object.freeze({
358
+ from: inputs.map((input) => input.sampleWindow.from).sort()[0],
359
+ to: inputs.map((input) => input.sampleWindow.to).sort().at(-1),
360
+ });
67
361
  return Object.freeze({
68
- suffix: sample.suffix,
69
- profileId: sample.profile.id,
70
- sampleCount,
71
- state: sampleCount < MIN_REGISTRY_COHORT_SAMPLE
72
- ? 'insufficient_sample'
73
- : investigate || publicationReviewItems
74
- ? 'review'
75
- : 'consistent',
76
- sourceAlignment: Object.freeze({
77
- investigate,
78
- expectedConstraints: group.reduce((total, item) => total + item.summary.expectedConstraints, 0),
79
- observed: group.reduce((total, item) => total + item.summary.observed, 0),
80
- }),
81
- publication: Object.freeze({
82
- reviewItems: publicationReviewItems,
83
- objectIdentifiersObserved: group.filter((item) => item.publication.objectIdentifier.state === 'observed').length,
84
- baseConformanceObserved: group.filter((item) => item.publication.baseConformance.state === 'observed').length,
85
- selfLinksObserved: group.filter((item) => item.publication.selfLink.state === 'observed').length,
86
- redactionMetadataObserved: group.filter((item) => item.publication.redactionMetadata.state === 'observed').length,
87
- }),
362
+ suffix: key.split('\u0000')[0],
363
+ profileId: key.split('\u0000')[1],
364
+ sampleCount: latest.sampleCount,
365
+ state: conservativeState([...inputs.map((input) => input.inheritedState), ...allPoints.map((point) => point.state)]),
366
+ latestState: latest.state,
367
+ sampleWindow: groupWindow,
368
+ sourceAlignment: latest.sourceAlignment,
369
+ publication: latest.publication,
370
+ timeline: Object.freeze(timeline),
371
+ timelineOmitted: upstreamTimelineOmitted + locallyOmitted,
88
372
  });
89
373
  }).sort((left, right) => left.suffix.localeCompare(right.suffix) || left.profileId.localeCompare(right.profileId));
374
+ const timelinePoints = cohorts.reduce((sum, cohort) => sum + cohort.timelineOmitted, 0);
375
+ if (duplicateTimelinePoints > MAX_REGISTRY_COHORT_OMISSIONS || timelinePoints > MAX_REGISTRY_COHORT_OMISSIONS) {
376
+ throw new CliUsageError(`Registry cohort omission accounting exceeds the ${MAX_REGISTRY_COHORT_OMISSIONS}-item bound.`);
377
+ }
378
+ const allWindows = sourceReports.map((report) => report.sampleWindow);
90
379
  return Object.freeze({
91
380
  schema: REGISTRY_COHORT_SCHEMA,
92
381
  version: REGISTRY_COHORT_VERSION,
93
- generatedAt,
94
- sampleCount: reports.length,
382
+ generatedAt: timestamp(generatedAt, 'Registry cohort generatedAt'),
383
+ inputFamily,
384
+ sampleCount: inputFamily === 'saved_lookups' ? directSampleCount : cohorts.reduce((sum, cohort) => sum + cohort.sampleCount, 0),
385
+ reportsMerged: inputFamily === 'retained_reports' ? sourceReports.length : 0,
95
386
  minimumCohortSample: MIN_REGISTRY_COHORT_SAMPLE,
387
+ sampleWindow: Object.freeze({
388
+ from: allWindows.map((item) => item.from).sort()[0],
389
+ to: allWindows.map((item) => item.to).sort().at(-1),
390
+ }),
96
391
  cohorts: Object.freeze(cohorts),
97
- limitations: Object.freeze([
98
- 'The report intentionally omits domains, queries and raw evidence. It groups saved observations only by suffix and capability profile.',
99
- `Cohorts with fewer than ${MIN_REGISTRY_COHORT_SAMPLE} observations remain insufficient sample and must not drive catalogue changes.`,
100
- 'Repeated observations from one environment are not representative by themselves. Review fixture provenance and source health before changing a parser or access profile.',
101
- ]),
392
+ omissions: Object.freeze({ duplicateTimelinePoints, timelinePoints }),
393
+ truncated: timelinePoints > 0,
394
+ limitations: LIMITATIONS,
102
395
  });
103
396
  }
397
+ export function buildRegistryCohortReport(text, generatedAt = new Date().toISOString()) {
398
+ const documents = parseInputDocuments(text);
399
+ const families = new Set(documents.map((document) => document.schema === SAVED_LOOKUP_SCHEMA ? 'saved_lookups' : document.schema === REGISTRY_COHORT_SCHEMA ? 'retained_reports' : 'unsupported'));
400
+ if (families.size !== 1 || families.has('unsupported'))
401
+ throw new CliUsageError('Registry cohort input must contain only saved Lookups or only retained registry cohort reports; mixed families are rejected.');
402
+ const inputFamily = [...families][0];
403
+ if (inputFamily === 'saved_lookups') {
404
+ return buildCurrentReport(sourceReportsFromLookups(documents, timestamp(generatedAt, 'Registry cohort generatedAt')), generatedAt, inputFamily, documents.length);
405
+ }
406
+ const reports = [];
407
+ let retainedPoints = 0;
408
+ for (const document of documents) {
409
+ const report = document.version === LEGACY_REGISTRY_COHORT_VERSION
410
+ ? parseLegacyReport(document)
411
+ : document.version === REGISTRY_COHORT_VERSION
412
+ ? parseCurrentReport(document)
413
+ : (() => { throw new CliUsageError('Registry cohort report version is unsupported.'); })();
414
+ retainedPoints += report.cohorts.reduce((total, cohort) => total + cohort.points.length, 0);
415
+ if (retainedPoints > MAX_REGISTRY_COHORT_INPUT_POINTS) {
416
+ throw new CliUsageError(`Retained registry cohort input contains more than ${MAX_REGISTRY_COHORT_INPUT_POINTS} timeline points.`);
417
+ }
418
+ reports.push(report);
419
+ }
420
+ return buildCurrentReport(reports, generatedAt, inputFamily, 0);
421
+ }
104
422
  export function formatRegistryCohortReport(report) {
105
423
  const output = [
106
- 'Registry quality cohort',
107
- `Saved observations ${report.sampleCount}`,
424
+ 'Registry quality cohort timeline',
425
+ `Input family ${report.inputFamily.replaceAll('_', ' ')}`,
426
+ `Current samples ${report.sampleCount}`,
427
+ `Reports merged ${report.reportsMerged}`,
428
+ `Sample window ${report.sampleWindow.from} → ${report.sampleWindow.to}`,
108
429
  `Minimum sample ${report.minimumCohortSample}`,
109
430
  '',
110
431
  ];
111
432
  for (const cohort of report.cohorts) {
112
433
  output.push(`.${cohort.suffix} · ${cohort.profileId} [${cohort.state.replaceAll('_', ' ')}]`);
113
- output.push(` Samples: ${cohort.sampleCount}`);
434
+ output.push(` Latest: ${cohort.latestState.replaceAll('_', ' ')} · ${cohort.sampleCount} samples`);
435
+ output.push(` Timeline points: ${cohort.timeline.length}${cohort.timelineOmitted ? ` · ${cohort.timelineOmitted} omitted` : ''}`);
114
436
  output.push(` Source review items: ${cohort.sourceAlignment.investigate}`);
115
437
  output.push(` Publication review items: ${cohort.publication.reviewItems}`);
116
438
  }
439
+ if (report.omissions.duplicateTimelinePoints)
440
+ output.push('', `Duplicate timeline points suppressed: ${report.omissions.duplicateTimelinePoints}`);
117
441
  output.push('', 'Limitations:');
118
442
  for (const limitation of report.limitations)
119
443
  output.push(` - ${limitation}`);
@@ -1,6 +1,6 @@
1
1
  import { registryStandardsCoverageSnapshot, } from "../lib/registry-capabilities.mjs";
2
2
  const REGISTRY_SUPPORT_SCHEMA = 'whoisleuth.cli.registry-support';
3
- const REGISTRY_SUPPORT_SCHEMA_VERSION = 3;
3
+ const REGISTRY_SUPPORT_SCHEMA_VERSION = 4;
4
4
  const REGISTRY_STANDARDS_COVERAGE_SCHEMA = 'whoisleuth.registry-standards-coverage';
5
5
  const MAX_REGISTRY_SUPPORT_TEXT_LENGTH = 2048;
6
6
  const MAX_REGISTRY_SUPPORT_REFERENCES = 20;
@@ -88,6 +88,7 @@ function buildRegistrySupportDocument(requestedInput, capability, catalogueVersi
88
88
  explicitSuffixProfile: capability.explicitSuffixProfile === true,
89
89
  registryClass: boundedText(capability.registryClass, 'unknown', 64),
90
90
  coverageState: boundedText(capability.coverageState, 'unknown', 64),
91
+ officialLookupUrl: boundedList([capability.officialLookupUrl], 'url')[0] ?? null,
91
92
  rdap: {
92
93
  discovery: boundedText(capability.rdapDiscovery, 'unknown', 64),
93
94
  accessProfile: boundedText(capability.rdapAccessProfile, 'unknown', 128),