@slicedearth/whoisleuth-cli 1.34.0 → 1.39.1

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 (131) hide show
  1. package/README.md +19 -2
  2. package/bin/whoisleuth.mjs +41 -24
  3. package/cli/arguments.mjs +326 -3
  4. package/cli/artifact-verify.mjs +60 -14
  5. package/cli/bulk.mjs +1 -2
  6. package/cli/compare.mjs +1 -1
  7. package/cli/completion.mjs +36 -2
  8. package/cli/doctor.mjs +11 -5
  9. package/cli/evidence-command-arguments.mjs +1 -1
  10. package/cli/evidence-command-runner.mjs +2 -2
  11. package/cli/evidence-signing.mjs +4 -4
  12. package/cli/export-evidence.mjs +9 -2
  13. package/cli/formatters/html.mjs +1 -1
  14. package/cli/formatters/json.mjs +13 -1
  15. package/cli/formatters/terminal.mjs +14 -4
  16. package/cli/investigation-plan.mjs +112 -0
  17. package/cli/local-mmdb-review.mjs +76 -0
  18. package/cli/lookalike-calibration.mjs +121 -0
  19. package/cli/lookup-command-runner.mjs +4 -1
  20. package/cli/lookup-plan.mjs +3 -3
  21. package/cli/lookup-reconcile.mjs +141 -0
  22. package/cli/lookup-timeline.mjs +1 -1
  23. package/cli/mail-review.mjs +48 -3
  24. package/cli/offline-evidence-review.mjs +159 -0
  25. package/cli/registry-doctor.mjs +125 -0
  26. package/cli/registry-support.mjs +1 -4
  27. package/cli/risk-calibration.mjs +117 -9
  28. package/cli/runner.mjs +342 -15
  29. package/cli/saved-lookup.mjs +2 -2
  30. package/cli/sharing-review.mjs +202 -0
  31. package/cli/source-reliability.mjs +1 -1
  32. package/docs/cli-reference.md +1110 -0
  33. package/docs/cli.md +224 -847
  34. package/frontend/src/lib/analysis/acquisition-decision-packet.js +1 -1
  35. package/frontend/src/lib/analysis/acquisition-due-diligence.js +6 -6
  36. package/frontend/src/lib/analysis/bulk-domain-comparison.js +12 -12
  37. package/frontend/src/lib/analysis/bulk-mail-exposure.js +1 -1
  38. package/frontend/src/lib/analysis/bulk-retry-plan.js +3 -10
  39. package/frontend/src/lib/analysis/bulk-session-model.js +7 -2
  40. package/frontend/src/lib/analysis/bulk-source-coverage.js +76 -0
  41. package/frontend/src/lib/analysis/case-evidence-model.js +26 -4
  42. package/frontend/src/lib/analysis/case-migration-model.js +6 -1
  43. package/frontend/src/lib/analysis/case-record-contracts.js +4 -2
  44. package/frontend/src/lib/analysis/case-record-core.js +5 -1
  45. package/frontend/src/lib/analysis/case-record-operations.js +6 -1
  46. package/frontend/src/lib/analysis/case-response-packet.js +7 -7
  47. package/frontend/src/lib/analysis/evidence-coverage-ledger.js +328 -0
  48. package/frontend/src/lib/analysis/http-summary.js +2 -1
  49. package/frontend/src/lib/analysis/investigation-capsule.js +105 -0
  50. package/frontend/src/lib/analysis/lookup-asset-graph.js +806 -0
  51. package/frontend/src/lib/analysis/lookup-decision-support.js +480 -0
  52. package/frontend/src/lib/analysis/lookup-investigation-brief.js +153 -0
  53. package/frontend/src/lib/analysis/lookup-presentation.js +74 -0
  54. package/frontend/src/lib/analysis/lookup-response.js +2 -0
  55. package/frontend/src/lib/analysis/lookup-source-refresh.js +304 -0
  56. package/frontend/src/lib/analysis/lookup-summary-model.js +398 -0
  57. package/frontend/src/lib/analysis/page-similarity.js +1 -1
  58. package/frontend/src/lib/analysis/relationship-evidence.js +3 -3
  59. package/frontend/src/lib/analysis/relationship-observation-model.js +4 -4
  60. package/frontend/src/lib/analysis/scoring.js +8 -88
  61. package/frontend/src/lib/analysis/shortlist-model.js +8 -4
  62. package/frontend/src/lib/analysis/utils.js +7 -40
  63. package/frontend/src/lib/analysis/visualization-bounds.js +26 -0
  64. package/frontend/src/lib/analysis/visualization-force-graph.js +352 -0
  65. package/frontend/src/lib/analysis/visualization-matrix.js +88 -0
  66. package/frontend/src/lib/analysis/visualization-models.js +370 -0
  67. package/frontend/src/lib/analysis/visualization-time-series.js +144 -0
  68. package/frontend/src/lib/analysis/website-snapshot-model.js +57 -3
  69. package/frontend/src/lib/analysis/workspace-archive-crypto.js +1 -1
  70. package/frontend/src/lib/analysis/workspace-archive.js +2 -2
  71. package/lib/analyst-taxonomy.mjs +40 -0
  72. package/lib/availability.mjs +30 -10
  73. package/lib/browser-library-profile.mjs +15 -2
  74. package/lib/bulk-limits.mjs +13 -0
  75. package/lib/certificate-extension-profile.mjs +71 -0
  76. package/lib/credential-surface-profile.mjs +1 -1
  77. package/lib/ct-response-bounds.mjs +9 -0
  78. package/lib/ct-search.mjs +66 -12
  79. package/lib/dns-delegation-health.mjs +56 -5
  80. package/lib/dnssec-evidence-validation.mjs +235 -0
  81. package/lib/domain-assurance.mjs +368 -0
  82. package/lib/domain-control-manifest.mjs +382 -0
  83. package/lib/domain-posture.mjs +11 -10
  84. package/lib/encrypted-dns-contract.mjs +81 -0
  85. package/lib/evidence-export.mjs +17 -3
  86. package/lib/evidence-report-markdown.mjs +1 -1
  87. package/lib/evidence-report.mjs +4 -1
  88. package/lib/favicon.mjs +3 -8
  89. package/lib/generated/cisa-kev-catalog.mjs +1668 -0
  90. package/lib/html-signals.mjs +4 -0
  91. package/lib/http-evidence-bounds.mjs +4 -0
  92. package/lib/http-intelligence.mjs +2 -1
  93. package/lib/ip-prefix.mjs +87 -0
  94. package/lib/local-geoip-evidence.mjs +89 -0
  95. package/lib/lookup-response-contract.mjs +346 -0
  96. package/lib/observed-network-context.mjs +1 -1
  97. package/lib/opportunity-scoring.mjs +107 -0
  98. package/lib/outbound-identity.mjs +9 -0
  99. package/lib/outbound-request-bounds.mjs +9 -0
  100. package/lib/page-fingerprints.mjs +1 -1
  101. package/lib/perceptual-hash-comparison.mjs +34 -0
  102. package/lib/perceptual-hash.mjs +3 -31
  103. package/lib/project-metadata.mjs +8 -0
  104. package/lib/provider-policy-admission.mjs +49 -0
  105. package/lib/rdap-capabilities.mjs +6 -6
  106. package/lib/rdap-search-workbench.mjs +247 -0
  107. package/lib/rdap.mjs +7 -2
  108. package/lib/registry-access.mjs +1 -1
  109. package/lib/registry-capabilities.mjs +31 -1
  110. package/lib/registry-capability-catalogue.mjs +15 -14
  111. package/lib/registry-insights.mjs +6 -6
  112. package/lib/response-policy.mjs +81 -6
  113. package/lib/risk-scoring.mjs +206 -83
  114. package/lib/rpki-evidence.mjs +105 -0
  115. package/lib/safe-fetch.mjs +2 -1
  116. package/lib/scoring-evidence-quality.mjs +107 -0
  117. package/lib/security-txt.mjs +3 -3
  118. package/lib/semantic-version.mjs +54 -0
  119. package/lib/static-html-analysis.mjs +27 -0
  120. package/lib/threat-intelligence-manifests.mjs +1 -1
  121. package/lib/threatfox-intelligence.mjs +11 -6
  122. package/lib/tls-intelligence.mjs +13 -3
  123. package/lib/tlsa-evidence.mjs +194 -0
  124. package/lib/urlhaus-intelligence.mjs +11 -6
  125. package/lib/urlscan-intelligence.mjs +10 -5
  126. package/lib/website-security-posture.mjs +16 -9
  127. package/lib/website-technology.mjs +1 -1
  128. package/lib/whois-chain.mjs +14 -152
  129. package/lib/whois.mjs +2 -2
  130. package/package.json +6 -2
  131. package/third-party-notices.txt +911 -19
@@ -0,0 +1,74 @@
1
+ export const LOOKUP_PRESENTATION_STORAGE_KEY = 'whoisleuth:lookup-presentation:v1';
2
+ export const LOOKUP_EVIDENCE_DENSITIES = Object.freeze([
3
+ Object.freeze({ id: 'summary', label: 'Summary', detail: 'Overview and source-state ledger, with detailed sections reduced to headings.' }),
4
+ Object.freeze({ id: 'standard', label: 'Standard', detail: 'Evidence sections remain available while the raw response stays collapsed until you choose to inspect it.' }),
5
+ Object.freeze({ id: 'full', label: 'Full', detail: 'Every settled evidence section, including the bounded raw response view.' }),
6
+ ]);
7
+ export const LOOKUP_TASK_VIEWS = Object.freeze([
8
+ Object.freeze({ id: 'general', label: 'General investigation' }),
9
+ Object.freeze({ id: 'acquisition', label: 'Acquisition review' }),
10
+ Object.freeze({ id: 'brand', label: 'Brand review' }),
11
+ Object.freeze({ id: 'incident', label: 'Incident response' }),
12
+ Object.freeze({ id: 'owned', label: 'Owned-domain posture' }),
13
+ ]);
14
+ const DENSITIES = new Set(LOOKUP_EVIDENCE_DENSITIES.map((option) => option.id));
15
+ const TASKS = new Set(LOOKUP_TASK_VIEWS.map((option) => option.id));
16
+ export function normalizeLookupEvidenceDensity(value) {
17
+ return typeof value === 'string' && DENSITIES.has(value)
18
+ ? value
19
+ : 'standard';
20
+ }
21
+ export function normalizeLookupTaskView(value) {
22
+ return typeof value === 'string' && TASKS.has(value)
23
+ ? value
24
+ : 'general';
25
+ }
26
+ export function readLookupPresentation(storage) {
27
+ try {
28
+ const stored = JSON.parse(storage.getItem(LOOKUP_PRESENTATION_STORAGE_KEY) || 'null');
29
+ if (stored?.version === 1) {
30
+ return {
31
+ density: normalizeLookupEvidenceDensity(stored.density),
32
+ task: normalizeLookupTaskView(stored.task),
33
+ };
34
+ }
35
+ }
36
+ catch {
37
+ // Invalid or unavailable browser storage falls back to the stable defaults.
38
+ }
39
+ return { density: 'standard', task: 'general' };
40
+ }
41
+ export function writeLookupPresentation(storage, state) {
42
+ try {
43
+ storage.setItem(LOOKUP_PRESENTATION_STORAGE_KEY, JSON.stringify({
44
+ version: 1,
45
+ density: normalizeLookupEvidenceDensity(state.density),
46
+ task: normalizeLookupTaskView(state.task),
47
+ }));
48
+ }
49
+ catch {
50
+ // The caller's in-memory selection remains valid when storage is unavailable.
51
+ }
52
+ }
53
+ const PRIORITY = Object.freeze({
54
+ general: Object.freeze(['overview', 'web-evidence', 'registry', 'external-intelligence', 'case-response', 'raw-data']),
55
+ acquisition: Object.freeze(['overview', 'registry', 'web-evidence', 'case-response', 'external-intelligence', 'raw-data']),
56
+ brand: Object.freeze(['overview', 'web-evidence', 'external-intelligence', 'registry', 'case-response', 'raw-data']),
57
+ incident: Object.freeze(['overview', 'external-intelligence', 'web-evidence', 'registry', 'case-response', 'raw-data']),
58
+ owned: Object.freeze(['overview', 'registry', 'web-evidence', 'case-response', 'external-intelligence', 'raw-data']),
59
+ });
60
+ const INITIAL_PANELS = {
61
+ general: [],
62
+ acquisition: ['dns', 'network-context', 'tls'],
63
+ brand: ['http', 'page-identity', 'credential-surface', 'structured-identity', 'technology'],
64
+ incident: ['http', 'tls', 'credential-surface', 'security-posture'],
65
+ owned: ['dns', 'network-context', 'tls', 'security-posture'],
66
+ };
67
+ export function lookupTaskInitiallyExpands(task, panel) {
68
+ return INITIAL_PANELS[normalizeLookupTaskView(task)].includes(panel);
69
+ }
70
+ export function prioritizeLookupSectionLinks(links, task) {
71
+ const order = PRIORITY[normalizeLookupTaskView(task)];
72
+ const rank = new Map(order.map((id, index) => [`#${id}`, index]));
73
+ return [...links].sort((left, right) => ((rank.get(left.href) ?? order.length) - (rank.get(right.href) ?? order.length)));
74
+ }
@@ -0,0 +1,2 @@
1
+ // Environment-neutral Lookup response contract shared with the backend.
2
+ export * from "../../../../lib/lookup-response-contract.mjs";
@@ -0,0 +1,304 @@
1
+ export const LOOKUP_SOURCE_REFRESH_VERSION = 1;
2
+ export const LOOKUP_SOURCE_STALE_AFTER_DAYS = 7;
3
+ export const LOOKUP_FRESHNESS_POLICY_VERSION = 1;
4
+ export const LOOKUP_SOURCE_REFRESH_TIMEOUT_MS = 40_000;
5
+ export const MAX_LOOKUP_SOURCE_REFRESH_KEYS = 512;
6
+ export const MAX_LOOKUP_SOURCE_REFRESH_BYTES = 2 * 1024 * 1024;
7
+ export const MAX_LOOKUP_SOURCE_REFRESH_HISTORY = 12;
8
+ const DOMAIN_EVIDENCE_IDS = new Set([
9
+ 'availability',
10
+ 'client-behavior',
11
+ 'dns',
12
+ 'http',
13
+ 'page-identity',
14
+ 'page-role',
15
+ 'security-posture',
16
+ 'technology',
17
+ 'tls',
18
+ ]);
19
+ const NETWORK_EVIDENCE_IDS = new Set(['availability', 'dns', 'reverse-dns', 'network-context']);
20
+ const WEB_EVIDENCE_IDS = new Set(['http', 'tls', 'page-identity', 'page-role', 'client-behavior', 'security-posture', 'technology']);
21
+ const TASK_FRESHNESS_THRESHOLDS = Object.freeze({
22
+ general: Object.freeze({ registration: 30, network: 7, web: 3 }),
23
+ acquisition: Object.freeze({ registration: 7, network: 3, web: 3 }),
24
+ brand: Object.freeze({ registration: 30, network: 3, web: 1 }),
25
+ incident: Object.freeze({ registration: 14, network: 1, web: 1 }),
26
+ owned: Object.freeze({ registration: 30, network: 7, web: 3 }),
27
+ });
28
+ function freshnessDays(value, fallback) {
29
+ const parsed = Number(value);
30
+ return Number.isFinite(parsed) ? Math.max(1, Math.min(365, Math.round(parsed))) : fallback;
31
+ }
32
+ export function buildLookupFreshnessPolicy(task, input) {
33
+ const defaults = TASK_FRESHNESS_THRESHOLDS[task];
34
+ const custom = input?.id === 'analyst-custom';
35
+ return {
36
+ version: LOOKUP_FRESHNESS_POLICY_VERSION,
37
+ id: custom ? 'analyst-custom' : 'task-default',
38
+ task,
39
+ thresholdsDays: custom ? {
40
+ registration: freshnessDays(input?.thresholdsDays?.registration, defaults.registration),
41
+ network: freshnessDays(input?.thresholdsDays?.network, defaults.network),
42
+ web: freshnessDays(input?.thresholdsDays?.web, defaults.web),
43
+ } : { ...defaults },
44
+ };
45
+ }
46
+ function observedAgeDays(observedAt, now) {
47
+ if (typeof observedAt !== 'string' || typeof now !== 'string')
48
+ return null;
49
+ const observedMs = Date.parse(observedAt);
50
+ const nowMs = Date.parse(now);
51
+ if (!Number.isFinite(observedMs) || !Number.isFinite(nowMs))
52
+ return null;
53
+ return Math.max(0, Math.floor((nowMs - observedMs) / 86_400_000));
54
+ }
55
+ function limited(entries, ids) {
56
+ return entries.some((entry) => ids.has(entry.id) && entry.manualReviewSuggested);
57
+ }
58
+ function availableIds(entries, ids) {
59
+ return entries.filter((entry) => ids.has(entry.id)).map((entry) => entry.id).slice(0, 12);
60
+ }
61
+ export function buildLookupSourceRefreshPlan(ledger, observedAt, now = new Date().toISOString(), options = {}) {
62
+ const ageDays = observedAgeDays(observedAt, now);
63
+ const freshnessPolicy = buildLookupFreshnessPolicy(options.task ?? 'general', options.freshnessPolicy);
64
+ const entries = ledger.entries.slice(0, 24);
65
+ const plans = [];
66
+ const groups = [
67
+ {
68
+ id: 'rdap',
69
+ label: 'Registry RDAP',
70
+ endpoint: '/api/rdap',
71
+ ids: new Set(['rdap']),
72
+ disclosure: 'Starts one bounded registry RDAP operation for this target.',
73
+ threshold: 'registration',
74
+ },
75
+ {
76
+ id: 'whois',
77
+ label: 'WHOIS',
78
+ endpoint: '/api/whois',
79
+ ids: new Set(['whois']),
80
+ disclosure: 'Starts one bounded referral-aware WHOIS operation for this target.',
81
+ threshold: 'registration',
82
+ },
83
+ {
84
+ id: 'availability',
85
+ label: 'Domain evidence',
86
+ endpoint: '/api/availability',
87
+ ids: DOMAIN_EVIDENCE_IDS,
88
+ disclosure: 'Repeats the bounded domain-evidence branch, including eligible DNS, HTTP, page, and TLS work for the selected depth.',
89
+ threshold: 'network',
90
+ },
91
+ ];
92
+ for (const group of groups) {
93
+ const evidenceIds = availableIds(entries, group.ids);
94
+ if (!evidenceIds.length)
95
+ continue;
96
+ const isLimited = limited(entries, group.ids);
97
+ const thresholds = evidenceIds.map((id) => WEB_EVIDENCE_IDS.has(id)
98
+ ? freshnessPolicy.thresholdsDays.web
99
+ : NETWORK_EVIDENCE_IDS.has(id)
100
+ ? freshnessPolicy.thresholdsDays.network
101
+ : freshnessPolicy.thresholdsDays[group.threshold]);
102
+ const staleAfterDays = Math.min(...thresholds);
103
+ const evidenceAges = evidenceIds
104
+ .map((id) => observedAgeDays(options.observedAtByEvidence?.[id] ?? observedAt, now))
105
+ .filter((value) => value !== null);
106
+ const groupAgeDays = evidenceAges.length ? Math.max(...evidenceAges) : ageDays;
107
+ const groupStale = groupAgeDays !== null && groupAgeDays >= staleAfterDays;
108
+ if (!isLimited && !groupStale)
109
+ continue;
110
+ plans.push({
111
+ id: group.id,
112
+ label: group.label,
113
+ endpoint: group.endpoint,
114
+ evidenceIds,
115
+ reason: isLimited ? 'limited' : 'stale',
116
+ requestDisclosure: group.disclosure,
117
+ supersedesObservedAt: typeof observedAt === 'string' && Number.isFinite(Date.parse(observedAt))
118
+ ? new Date(observedAt).toISOString()
119
+ : null,
120
+ ageDays: groupAgeDays,
121
+ staleAfterDays,
122
+ });
123
+ }
124
+ const stale = plans.some((item) => item.reason === 'stale');
125
+ return {
126
+ version: LOOKUP_SOURCE_REFRESH_VERSION,
127
+ stale,
128
+ ageDays,
129
+ freshnessPolicy,
130
+ items: plans,
131
+ limitations: [
132
+ 'A source refresh is displayed separately and never merged into the original unified Lookup envelope.',
133
+ 'Run a complete Lookup before saving, comparing, or exporting replacement evidence collected at one review time.',
134
+ 'A retry can remain partial or unavailable and never proves that missing evidence is absent.',
135
+ 'Freshness thresholds organise review only. They do not make an older observation false or a newer observation complete.',
136
+ ],
137
+ };
138
+ }
139
+ function record(value) {
140
+ return value && typeof value === 'object' && !Array.isArray(value)
141
+ ? value
142
+ : {};
143
+ }
144
+ async function boundedJson(response) {
145
+ const declaredLength = Number(response.headers.get('content-length'));
146
+ if (Number.isFinite(declaredLength) && declaredLength > MAX_LOOKUP_SOURCE_REFRESH_BYTES) {
147
+ throw new Error('oversized');
148
+ }
149
+ if (!response.body)
150
+ return {};
151
+ const reader = response.body.getReader();
152
+ const chunks = [];
153
+ let total = 0;
154
+ try {
155
+ while (true) {
156
+ const { done, value } = await reader.read();
157
+ if (done)
158
+ break;
159
+ if (!value)
160
+ continue;
161
+ total += value.byteLength;
162
+ if (total > MAX_LOOKUP_SOURCE_REFRESH_BYTES)
163
+ throw new Error('oversized');
164
+ chunks.push(value);
165
+ }
166
+ }
167
+ finally {
168
+ if (total > MAX_LOOKUP_SOURCE_REFRESH_BYTES)
169
+ await reader.cancel().catch(() => { });
170
+ reader.releaseLock();
171
+ }
172
+ const bytes = new Uint8Array(total);
173
+ let offset = 0;
174
+ for (const chunk of chunks) {
175
+ bytes.set(chunk, offset);
176
+ offset += chunk.byteLength;
177
+ }
178
+ try {
179
+ return JSON.parse(new TextDecoder().decode(bytes));
180
+ }
181
+ catch {
182
+ return {};
183
+ }
184
+ }
185
+ function text(value, maximum = 180) {
186
+ return String(value ?? '')
187
+ .replace(/[\u0000-\u001f\u007f]+/gu, ' ')
188
+ .replace(/\s+/gu, ' ')
189
+ .trim()
190
+ .slice(0, maximum);
191
+ }
192
+ function summarizeSource(plan, body, observedAt, depth, supersedesObservedAt) {
193
+ const base = {
194
+ version: LOOKUP_SOURCE_REFRESH_VERSION,
195
+ id: plan.id,
196
+ observedAt,
197
+ reason: plan.reason,
198
+ evidenceIds: plan.evidenceIds.slice(0, 12),
199
+ supersedesObservedAt,
200
+ };
201
+ if (plan.id === 'rdap') {
202
+ const upstreamStatus = Number(body.upstreamStatus);
203
+ const parsed = record(body.parsed);
204
+ const complete = upstreamStatus === 200 && Object.keys(parsed).length > 0;
205
+ return {
206
+ ...base,
207
+ state: complete ? 'complete' : 'limited',
208
+ detail: complete
209
+ ? `Registry RDAP returned a validated ${upstreamStatus} response.`
210
+ : `Registry RDAP returned ${Number.isFinite(upstreamStatus) ? `HTTP ${upstreamStatus}` : 'no complete structured record'}.`,
211
+ observedAt,
212
+ };
213
+ }
214
+ if (plan.id === 'whois') {
215
+ const chain = Array.isArray(body.chain) ? body.chain : [];
216
+ const chainStatus = text(record(body.parsed).chainStatus, 40);
217
+ const complete = chain.length > 0 && chainStatus === 'complete';
218
+ return {
219
+ ...base,
220
+ state: complete ? 'complete' : 'limited',
221
+ detail: complete
222
+ ? `WHOIS returned a complete ${chain.length}-hop referral chain.`
223
+ : `WHOIS returned ${chain.length} hop${chain.length === 1 ? '' : 's'} with ${chainStatus || 'unknown'} chain status.`,
224
+ observedAt,
225
+ };
226
+ }
227
+ const state = text(body.state, 40) || 'unknown';
228
+ const complete = body.deepScanComplete === true
229
+ || (depth === 'fast' && state !== 'unknown');
230
+ const sourceStates = ['dns', 'http', 'tls']
231
+ .map((key) => text(record(body[key]).status, 40))
232
+ .filter(Boolean);
233
+ return {
234
+ ...base,
235
+ state: complete ? 'complete' : 'limited',
236
+ detail: `Domain evidence returned ${state}${sourceStates.length ? `; DNS, HTTP, and TLS states: ${sourceStates.join(', ')}` : ''}.`,
237
+ observedAt,
238
+ };
239
+ }
240
+ export async function requestLookupSourceRefresh(plan, query, depth, options = {}) {
241
+ const normalizedQuery = text(query, 4096);
242
+ if (!normalizedQuery)
243
+ return { ok: false, message: 'A valid Lookup target is required.' };
244
+ const timeoutMs = Number.isFinite(options.timeoutMs)
245
+ ? Math.max(1, Math.min(LOOKUP_SOURCE_REFRESH_TIMEOUT_MS, Math.round(Number(options.timeoutMs))))
246
+ : LOOKUP_SOURCE_REFRESH_TIMEOUT_MS;
247
+ const controller = new AbortController();
248
+ const timeout = setTimeout(() => controller.abort('timeout'), timeoutMs);
249
+ try {
250
+ const url = `${plan.endpoint}?q=${encodeURIComponent(normalizedQuery)}${plan.id === 'availability' && depth === 'fast' ? '&fast=true' : ''}`;
251
+ const response = await (options.fetchImpl ?? fetch)(url, {
252
+ credentials: 'same-origin',
253
+ signal: controller.signal,
254
+ });
255
+ const body = record(await boundedJson(response));
256
+ if (!response.ok) {
257
+ return {
258
+ ok: false,
259
+ message: text(body.error, 240) || `Source refresh failed with HTTP ${response.status}.`,
260
+ };
261
+ }
262
+ if (Object.keys(body).length > MAX_LOOKUP_SOURCE_REFRESH_KEYS) {
263
+ return { ok: false, message: 'Source refresh returned an oversized record.' };
264
+ }
265
+ return {
266
+ ok: true,
267
+ value: summarizeSource(plan, body, options.now?.() ?? new Date().toISOString(), depth, options.supersedesObservedAt === undefined
268
+ ? plan.supersedesObservedAt
269
+ : options.supersedesObservedAt),
270
+ };
271
+ }
272
+ catch (cause) {
273
+ if (cause instanceof Error && cause.message === 'oversized') {
274
+ return { ok: false, message: 'Source refresh returned an oversized response.' };
275
+ }
276
+ return {
277
+ ok: false,
278
+ message: controller.signal.aborted ? 'Source refresh timed out.' : 'Source refresh could not be completed.',
279
+ };
280
+ }
281
+ finally {
282
+ clearTimeout(timeout);
283
+ }
284
+ }
285
+ export function mergeLookupSourceRefreshLedger(current, result) {
286
+ const candidates = [
287
+ ...(current?.version === LOOKUP_SOURCE_REFRESH_VERSION ? current.entries : []),
288
+ result,
289
+ ];
290
+ const byObservation = new Map();
291
+ for (const entry of candidates.slice(-MAX_LOOKUP_SOURCE_REFRESH_HISTORY * 4)) {
292
+ byObservation.set(`${entry.id}:${entry.observedAt}`, entry);
293
+ }
294
+ const ordered = [...byObservation.values()]
295
+ .sort((left, right) => Date.parse(left.observedAt) - Date.parse(right.observedAt));
296
+ const truncated = Boolean(current?.truncated)
297
+ || ordered.length > MAX_LOOKUP_SOURCE_REFRESH_HISTORY
298
+ || candidates.length > MAX_LOOKUP_SOURCE_REFRESH_HISTORY;
299
+ return {
300
+ version: LOOKUP_SOURCE_REFRESH_VERSION,
301
+ entries: ordered.slice(-MAX_LOOKUP_SOURCE_REFRESH_HISTORY),
302
+ truncated,
303
+ };
304
+ }