@slicedearth/whoisleuth-cli 1.47.4 → 2.0.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.
Files changed (235) hide show
  1. package/README.md +74 -177
  2. package/SECURITY.md +6 -3
  3. package/bin/whoisleuth.mjs +2 -1
  4. package/cli/archive-inspect.mjs +28 -10
  5. package/cli/arguments.mjs +204 -174
  6. package/cli/artifact-structure.mjs +837 -163
  7. package/cli/artifact-verify.mjs +90 -61
  8. package/cli/bounded-json.mjs +1 -1
  9. package/cli/bulk-checkpoint.mjs +33 -20
  10. package/cli/bulk.mjs +9 -2
  11. package/cli/case-pack.mjs +180 -576
  12. package/cli/ci-report.mjs +6 -1
  13. package/cli/collection-preflight.mjs +2 -2
  14. package/cli/command-catalogue.mjs +12 -3
  15. package/cli/command-reference.mjs +910 -92
  16. package/cli/completion.mjs +504 -226
  17. package/cli/config-profile.mjs +13 -3
  18. package/cli/discover.mjs +7 -1
  19. package/cli/discovery-observation-snapshot.mjs +24 -28
  20. package/cli/discovery-scan-command-runner.mjs +9 -9
  21. package/cli/discovery-scan.mjs +17 -3
  22. package/cli/discovery-snapshot.mjs +15 -7
  23. package/cli/domain-control-monitor.mjs +203 -30
  24. package/cli/domain-control-observations.mjs +15 -21
  25. package/cli/errors.mjs +8 -3
  26. package/cli/evidence-signing.mjs +10 -20
  27. package/cli/external-observation-mapping.mjs +3 -3
  28. package/cli/formatters/json.mjs +20 -32
  29. package/cli/formatters/terminal.mjs +583 -22
  30. package/cli/input.mjs +7 -2
  31. package/cli/interactive-launcher.mjs +226 -0
  32. package/cli/interchange-report.mjs +19 -19
  33. package/cli/investigation-manifest.mjs +1 -1
  34. package/cli/investigation-plan.mjs +167 -16
  35. package/cli/investigation-run.mjs +9 -1
  36. package/cli/lookalike-calibration.mjs +3 -1
  37. package/cli/lookup-brief.mjs +13 -8
  38. package/cli/lookup-browser.mjs +844 -0
  39. package/cli/lookup-command-runner.mjs +94 -31
  40. package/cli/lookup-diff.mjs +2 -2
  41. package/cli/lookup-plan.mjs +1 -0
  42. package/cli/mail-review.mjs +206 -94
  43. package/cli/manual.mjs +5 -5
  44. package/cli/offline-evidence-review.mjs +18 -7
  45. package/cli/open-asset-model-bridge.mjs +1 -1
  46. package/cli/output-file.mjs +70 -14
  47. package/cli/page-compare.mjs +11 -6
  48. package/cli/progress-events.mjs +2 -1
  49. package/cli/progress.mjs +3 -3
  50. package/cli/registry-cohort.mjs +9 -51
  51. package/cli/registry-doctor.mjs +67 -28
  52. package/cli/retained-artifact-diff.mjs +7 -8
  53. package/cli/risk-calibration.mjs +85 -38
  54. package/cli/runner.mjs +189 -75
  55. package/cli/saved-lookup.mjs +424 -57
  56. package/cli/sharing-review.mjs +6 -1
  57. package/cli/source-reliability.mjs +75 -36
  58. package/cli/terminal-presentation.mjs +43 -18
  59. package/docs/cli-reference.md +125 -1708
  60. package/docs/cli.md +86 -254
  61. package/frontend/src/lib/analysis/domain-control-manifest-core.js +3 -145
  62. package/frontend/src/lib/analysis/domain-control-records.js +3 -88
  63. package/lib/availability.mjs +58 -40
  64. package/lib/bounded-contract-normalizers.mjs +2 -4
  65. package/lib/bounded-file.mjs +78 -4
  66. package/lib/bounded-json.mjs +51 -13
  67. package/lib/browser-library-profile.mjs +1 -1
  68. package/lib/classify.mjs +56 -5
  69. package/lib/client-behavior-profile.mjs +1 -1
  70. package/lib/credential-surface-profile.mjs +1 -1
  71. package/lib/cryptographic-assurance.mjs +35 -17
  72. package/lib/ct-query.mjs +3 -3
  73. package/lib/ct-search.mjs +37 -21
  74. package/lib/distributed-operation-budget.mjs +4 -2
  75. package/lib/dns-delegation-health.mjs +9 -6
  76. package/lib/dns-intelligence.mjs +1 -1
  77. package/lib/dnssec-chain-validation.mjs +2 -3
  78. package/lib/dnssec-evidence-validation.mjs +13 -7
  79. package/lib/domain-assurance.mjs +12 -12
  80. package/lib/domain-change-packet.mjs +1 -1
  81. package/lib/domain-control-flight-recorder.mjs +442 -78
  82. package/lib/domain-control-manifest.mjs +314 -59
  83. package/lib/domain-posture-analysis.mjs +5 -2
  84. package/lib/encrypted-dns-contract.mjs +2 -3
  85. package/lib/evidence-export.mjs +518 -36
  86. package/lib/external-intelligence-risk.mjs +26 -10
  87. package/lib/feature-policy.mjs +1 -1
  88. package/lib/homepage-metadata-contract.mjs +290 -0
  89. package/lib/hostname.mjs +1 -1
  90. package/lib/html-signals.mjs +89 -5
  91. package/lib/http-intelligence.mjs +331 -2
  92. package/lib/interchange-fidelity-registry.mjs +28 -26
  93. package/lib/lookup-cache.mjs +6 -4
  94. package/lib/lookup.mjs +25 -2
  95. package/lib/observed-network-context.mjs +1 -1
  96. package/lib/page-fingerprints.mjs +2 -2
  97. package/lib/page-role-profile.mjs +1 -1
  98. package/lib/portable-generator.mjs +8 -0
  99. package/lib/provider-policy-admission.mjs +3 -1
  100. package/lib/rdap-bootstrap.mjs +76 -16
  101. package/lib/rdap-client.mjs +20 -12
  102. package/lib/rdap-normalization.mjs +306 -75
  103. package/lib/rdap-transport.mjs +2 -2
  104. package/lib/rdap-validation.mjs +65 -14
  105. package/lib/rdap.mjs +2 -2
  106. package/lib/registrable-domain.mjs +36 -0
  107. package/lib/registry-insights.mjs +7 -0
  108. package/lib/response-policy.mjs +27 -4
  109. package/lib/risk-calibration-summary.mjs +35 -33
  110. package/lib/risk-scoring.mjs +4 -3
  111. package/lib/safe-fetch.mjs +62 -40
  112. package/lib/security-txt.mjs +1 -1
  113. package/lib/smtp-transport-review.mjs +8 -5
  114. package/lib/sslbl-intelligence.mjs +6 -8
  115. package/lib/static-html-analysis.mjs +301 -4
  116. package/lib/structured-data-identity.mjs +51 -19
  117. package/lib/threat-intelligence-fixtures.mjs +2 -4
  118. package/lib/threat-intelligence-manifests.mjs +2 -7
  119. package/lib/threat-intelligence-runtime.mjs +3 -5
  120. package/lib/threat-intelligence-types.mjs +3 -0
  121. package/lib/threatfox-intelligence.mjs +7 -3
  122. package/lib/tls-intelligence.mjs +4 -4
  123. package/lib/urlhaus-intelligence.mjs +4 -2
  124. package/lib/urlscan-intelligence.mjs +6 -5
  125. package/lib/website-security-posture.mjs +1 -1
  126. package/lib/website-technology.mjs +1 -1
  127. package/lib/whois-authority.mjs +43 -8
  128. package/lib/whois-chain.mjs +25 -3
  129. package/lib/whois-normalization.mjs +3 -1
  130. package/lib/whois-transport.mjs +12 -3
  131. package/lib/zone-intent-review.mjs +1 -1
  132. package/package.json +12 -4
  133. package/{frontend/src/lib/analysis/case-brand-profile-references.js → packages/cases/case-brand-profile-references.mjs} +2 -2
  134. package/{frontend/src/lib/analysis/case-evidence-model.js → packages/cases/case-evidence-model.mjs} +35 -14
  135. package/{frontend/src/lib/analysis/case-investigation-branch-model.js → packages/cases/case-investigation-branch-model.mjs} +10 -12
  136. package/packages/cases/case-migration-model.mjs +476 -0
  137. package/{frontend/src/lib/analysis/case-model.js → packages/cases/case-model.mjs} +3 -3
  138. package/{frontend/src/lib/analysis/case-record-contracts.js → packages/cases/case-record-contracts.mjs} +3 -21
  139. package/{frontend/src/lib/analysis/case-record-core.js → packages/cases/case-record-core.mjs} +58 -31
  140. package/packages/cases/case-record-model.mjs +9 -0
  141. package/{frontend/src/lib/analysis/case-record-operations.js → packages/cases/case-record-operations.mjs} +72 -29
  142. package/{frontend/src/lib/analysis/case-report.js → packages/cases/case-report.mjs} +108 -14
  143. package/packages/cases/case-response-model.mjs +1604 -0
  144. package/packages/cases/case-response-packet.mjs +1312 -0
  145. package/{frontend/src/lib/analysis/case-storage-model.js → packages/cases/case-storage-model.mjs} +2 -2
  146. package/{frontend/src/lib/analysis/http-summary.js → packages/cases/http-summary.mjs} +1 -1
  147. package/{frontend/src/lib/analysis/comparison-ledger-bulk.js → packages/comparison/comparison-ledger-bulk.mjs} +2 -2
  148. package/{frontend/src/lib/analysis/comparison-ledger-contract.js → packages/comparison/comparison-ledger-contract.mjs} +4 -4
  149. package/{frontend/src/lib/analysis/comparison-ledger-json.js → packages/comparison/comparison-ledger-json.mjs} +1 -0
  150. package/{frontend/src/lib/analysis/comparison-ledger-serialization.js → packages/comparison/comparison-ledger-serialization.mjs} +1 -0
  151. package/packages/comparison/favicon-similarity.mjs +78 -0
  152. package/{frontend/src/lib/analysis/page-similarity.js → packages/comparison/page-similarity.mjs} +4 -3
  153. package/{frontend/src/lib/analysis/relationship-evidence.js → packages/comparison/relationship-evidence.mjs} +6 -7
  154. package/packages/contracts/analyst-review-state-contract.mjs +61 -0
  155. package/packages/contracts/analyst-review-state.mjs +50 -0
  156. package/packages/contracts/case-portability.mjs +1310 -0
  157. package/packages/contracts/cli-command-catalogue.mjs +2 -0
  158. package/packages/contracts/cli-lookup.mjs +394 -0
  159. package/packages/contracts/domain-control-flight-recorder.mjs +461 -0
  160. package/packages/contracts/domain-control-manifest.mjs +1027 -0
  161. package/packages/contracts/domain-control-monitor.mjs +310 -0
  162. package/packages/contracts/domain-control-review.mjs +828 -0
  163. package/packages/contracts/domain-name.mjs +4 -0
  164. package/packages/contracts/external-observation-interchange.mjs +224 -0
  165. package/packages/contracts/extracted-domain-lifecycle.mjs +218 -0
  166. package/packages/contracts/investigation-portability.mjs +331 -0
  167. package/packages/contracts/offline-comparison.mjs +160 -0
  168. package/packages/contracts/risk-calibration.mjs +1288 -0
  169. package/packages/contracts/schema-compatibility.mjs +71 -0
  170. package/packages/contracts/schema-lifecycle.mjs +1702 -0
  171. package/packages/contracts/sslbl-snapshot.mjs +17 -0
  172. package/packages/contracts/workspace-portability.mjs +1347 -0
  173. package/{frontend/src/lib/analysis/artifact-integrity.js → packages/evidence/artifact-integrity.mjs} +13 -11
  174. package/packages/evidence/decision-fact.mjs +735 -0
  175. package/packages/evidence/domain-control-runtime.mjs +547 -0
  176. package/packages/evidence/domain-name.mjs +41 -0
  177. package/packages/evidence/observation.mjs +384 -0
  178. package/{frontend/src/lib/analysis/external-findings-import.js → packages/interchange/external-findings-import.mjs} +24 -22
  179. package/packages/interchange/retained-text.mjs +8 -0
  180. package/{frontend/src/lib/analysis/acquisition-decision-packet.js → packages/investigation/acquisition-decision-packet.mjs} +9 -6
  181. package/{frontend/src/lib/analysis/bulk-domain-comparison.js → packages/investigation/bulk-domain-comparison.mjs} +5 -6
  182. package/{frontend/src/lib/analysis/bulk-mail-exposure.js → packages/investigation/bulk-mail-exposure.mjs} +4 -6
  183. package/{frontend/src/lib/analysis/bulk-review-export.js → packages/investigation/bulk-review-export.mjs} +5 -5
  184. package/packages/investigation/bulk-review-policy.mjs +1 -0
  185. package/{frontend/src/lib/analysis/investigation-capsule.js → packages/investigation/investigation-capsule.mjs} +30 -32
  186. package/packages/investigation/lookup-artefact-inputs.mjs +1 -0
  187. package/{frontend/src/lib/analysis/lookup-asset-graph.js → packages/investigation/lookup-asset-graph.mjs} +4 -2
  188. package/{frontend/src/lib/analysis/lookup-claim-passport.js → packages/investigation/lookup-claim-passport.mjs} +6 -10
  189. package/{frontend/src/lib/analysis/lookup-claim-readiness.js → packages/investigation/lookup-claim-readiness.mjs} +9 -2
  190. package/packages/investigation/lookup-investigation-brief.mjs +172 -0
  191. package/packages/monitoring/analyst-review-state.mjs +680 -0
  192. package/{frontend/src/lib/analysis/brand-profile-model.js → packages/workspace/brand-profile-model.mjs} +70 -47
  193. package/{frontend/src/lib/analysis/bulk-review-model.js → packages/workspace/bulk-review-model.mjs} +13 -14
  194. package/{frontend/src/lib/analysis/bulk-session-model.js → packages/workspace/bulk-session-model.mjs} +33 -59
  195. package/{frontend/src/lib/analysis/bulk-sort.js → packages/workspace/bulk-sort.mjs} +8 -5
  196. package/{frontend/src/lib/analysis/campaign-model.js → packages/workspace/campaign-model.mjs} +20 -22
  197. package/{frontend/src/lib/analysis/detection-rule-model.js → packages/workspace/detection-rule-model.mjs} +15 -17
  198. package/packages/workspace/hostile-input.mjs +172 -0
  199. package/{frontend/src/lib/analysis/investigation-guide.js → packages/workspace/investigation-guide.mjs} +8 -45
  200. package/{frontend/src/lib/analysis/investigation-template-model.js → packages/workspace/investigation-template-model.mjs} +18 -21
  201. package/{frontend/src/lib/analysis/page-baseline.js → packages/workspace/page-baseline.mjs} +6 -13
  202. package/{frontend/src/lib/analysis/relationship-observation-model.js → packages/workspace/relationship-observation-model.mjs} +13 -17
  203. package/{frontend/src/lib/analysis/shortlist-model.js → packages/workspace/shortlist-model.mjs} +20 -18
  204. package/{frontend/src/lib/analysis/watchlist-history.js → packages/workspace/watchlist-history.mjs} +9 -16
  205. package/{frontend/src/lib/analysis/watchlist-store.js → packages/workspace/watchlist-store.mjs} +24 -17
  206. package/{frontend/src/lib/analysis/website-snapshot-model.js → packages/workspace/website-snapshot-model.mjs} +13 -13
  207. package/{frontend/src/lib/analysis/workspace-archive-crypto.js → packages/workspace/workspace-archive-crypto.mjs} +49 -19
  208. package/{frontend/src/lib/analysis/workspace-archive.js → packages/workspace/workspace-archive.mjs} +110 -89
  209. package/third-party-notices.txt +14 -14
  210. package/frontend/src/lib/analysis/bulk-retry-plan.js +0 -79
  211. package/frontend/src/lib/analysis/bulk-source-coverage.js +0 -76
  212. package/frontend/src/lib/analysis/case-migration-model.js +0 -305
  213. package/frontend/src/lib/analysis/case-record-model.js +0 -9
  214. package/frontend/src/lib/analysis/case-response-model.js +0 -728
  215. package/frontend/src/lib/analysis/case-response-packet.js +0 -583
  216. package/frontend/src/lib/analysis/lookup-decision-support.js +0 -542
  217. package/frontend/src/lib/analysis/lookup-investigation-brief.js +0 -153
  218. package/frontend/src/lib/analysis/lookup-presentation.js +0 -120
  219. package/frontend/src/lib/analysis/lookup-response.js +0 -2
  220. package/frontend/src/lib/analysis/lookup-source-refresh.js +0 -277
  221. package/frontend/src/lib/analysis/lookup-summary-model.js +0 -398
  222. package/frontend/src/lib/analysis/scoring.js +0 -55
  223. package/frontend/src/lib/analysis/utils.js +0 -255
  224. package/frontend/src/lib/analysis/visualization-bounds.js +0 -26
  225. package/frontend/src/lib/analysis/visualization-force-graph.js +0 -352
  226. package/frontend/src/lib/analysis/visualization-matrix.js +0 -131
  227. package/frontend/src/lib/analysis/visualization-models.js +0 -338
  228. package/frontend/src/lib/analysis/visualization-time-series.js +0 -185
  229. package/lib/bounded-json-response.mjs +0 -170
  230. package/lib/lookup-response-contract.mjs +0 -1034
  231. package/lib/observation.mjs +0 -115
  232. /package/{frontend/src/lib/analysis/opaque-reference-id.js → packages/cases/opaque-reference-id.mjs} +0 -0
  233. /package/{frontend/src/lib/analysis/acquisition-due-diligence.js → packages/investigation/acquisition-due-diligence.mjs} +0 -0
  234. /package/{frontend/src/lib/analysis/evidence-coverage-ledger.js → packages/investigation/evidence-coverage-ledger.mjs} +0 -0
  235. /package/{frontend/src/lib/analysis/dns-record-normalization.js → packages/workspace/dns-record-normalization.mjs} +0 -0
@@ -1,305 +0,0 @@
1
- import { CASE_DISPOSITIONS, CASE_IMPORT_VERSIONS, CASE_SCHEMA_VERSION, CASE_SOURCES, CASE_STATUSES, DEFAULT_DISPOSITION, DEFAULT_SOURCE, DEFAULT_STATUS, MAX_CASES, MAX_NOTES_PER_CASE, deterministicId, isoOrNull, normalizeCase, normalizeDomain, normalizeEvidenceHistory, normalizeNotes, normalizeReviewReasonCode, normalizeTags, objectRecord, safeId, } from "./case-record-model.js";
2
- import { inspectCaseBrandProfileIds, unionCaseBrandProfileIds, } from "./case-brand-profile-references.js";
3
- import { caseInvestigationBranchReferences, mergeCaseInvestigationBranches, normalizeCaseInvestigationBranches, } from "./case-investigation-branch-model.js";
4
- import { mergeCaseActions, mergeCaseAssertions, mergeCaseDecisions, mergeCaseEvidencePins, mergeCaseManualTrail, mergeCaseSightings, normalizeCaseActions, normalizeCaseAssertions, normalizeCaseDecisions, normalizeCaseEvidencePins, normalizeCaseManualTrail, normalizeCaseSightings, } from "./case-response-model.js";
5
- const STATUS_VALUES = new Set(CASE_STATUSES.map((item) => item.value));
6
- const DISPOSITION_VALUES = new Set(CASE_DISPOSITIONS.map((item) => item.value));
7
- const SOURCE_VALUES = new Set(CASE_SOURCES.map((item) => item.value));
8
- export const MAX_CASE_INPUT_RECORDS = 2_000;
9
- function assignUniqueIds(cases) {
10
- const used = new Set();
11
- for (const record of [...cases].sort((a, b) => a.domain.localeCompare(b.domain))) {
12
- let id = safeId(record.id) || deterministicId(record.domain);
13
- if (used.has(id)) {
14
- const base = deterministicId(record.domain);
15
- id = base;
16
- let suffix = 2;
17
- while (used.has(id))
18
- id = `${base}-${suffix++}`;
19
- }
20
- used.add(id);
21
- record.id = id;
22
- }
23
- }
24
- /**
25
- * Recovers a clean, bounded store from an arbitrary parsed value. Accepts the
26
- * versioned envelope or a bare array, drops malformed records, keeps a single
27
- * case per domain (most recently updated wins), caps to MAX_CASES by recency,
28
- * and guarantees globally unique safe ids. Never throws.
29
- * @param {unknown} raw
30
- * @returns {CaseStore}
31
- */
32
- export function normalizeCaseStore(raw) {
33
- const now = new Date().toISOString();
34
- const sourceVersion = parseStoreVersion(raw);
35
- const acceptsBrandProfileIds = Array.isArray(raw)
36
- || sourceVersion === CASE_SCHEMA_VERSION;
37
- const byDomain = new Map();
38
- for (const item of boundedCaseList(raw).items) {
39
- const normalized = normalizeCase(item, undefined, now, Array.isArray(raw) ? CASE_SCHEMA_VERSION : sourceVersion);
40
- if (!normalized)
41
- continue;
42
- if (!acceptsBrandProfileIds)
43
- normalized.brandProfileIds = [];
44
- const existing = byDomain.get(normalized.domain);
45
- if (!existing || Date.parse(normalized.updatedAt) >= Date.parse(existing.updatedAt)) {
46
- byDomain.set(normalized.domain, normalized);
47
- }
48
- }
49
- const cases = [...byDomain.values()]
50
- .sort((a, b) => Date.parse(b.updatedAt) - Date.parse(a.updatedAt))
51
- .slice(0, MAX_CASES);
52
- assignUniqueIds(cases);
53
- return { version: CASE_SCHEMA_VERSION, cases };
54
- }
55
- /**
56
- * The schema version declared by a stored/parsed value, or null. The storage
57
- * wrapper uses this to refuse overwriting data written by a newer, unsupported
58
- * version instead of silently downgrading it.
59
- * @param {unknown} raw
60
- * @returns {number | null}
61
- */
62
- export function parseStoreVersion(raw) {
63
- const value = objectRecord(raw).version;
64
- return typeof value === 'number' ? value : null;
65
- }
66
- /** @param {unknown} raw @returns {unknown[]} */
67
- function asCaseList(raw) {
68
- if (Array.isArray(raw))
69
- return raw;
70
- const cases = objectRecord(raw).cases;
71
- if (Array.isArray(cases))
72
- return cases;
73
- return [];
74
- }
75
- function boundedCaseList(raw) {
76
- const source = asCaseList(raw);
77
- return {
78
- items: source.slice(0, MAX_CASE_INPUT_RECORDS),
79
- omitted: Math.max(0, source.length - MAX_CASE_INPUT_RECORDS),
80
- };
81
- }
82
- /**
83
- * @param {{ domain: unknown, status?: unknown, disposition?: unknown, source?: unknown, tags?: unknown, evidence?: unknown, note?: unknown }} input
84
- * @param {string} [nowIso]
85
- * @returns {CaseRecord}
86
- */
87
- function importScalar(value, valid) {
88
- return typeof value === 'string' && valid.has(value) ? value : undefined;
89
- }
90
- /**
91
- * Validates one imported record into a patch. Unlike normalizeCase, absent or
92
- * invalid scalar fields stay `undefined` (never defaulted) and a missing/invalid
93
- * updatedAt stays `null` (treated as older than any real local timestamp), so an
94
- * incomplete import can never win a merge over valid local data. Imported
95
- * evidence is normalized additively; a snapshot with no captured time falls back
96
- * only to the imported record's own (older) timestamps, never to "now".
97
- * @param {unknown} raw
98
- * @returns {ImportPatch | null}
99
- */
100
- function extractImportPatch(raw, importedVersion) {
101
- const record = objectRecord(raw);
102
- const domain = normalizeDomain(record.domain);
103
- if (!domain)
104
- return null;
105
- const importFallback = isoOrNull(record.updatedAt) || isoOrNull(record.createdAt) || null;
106
- const normalizedFallback = importFallback || '1970-01-01T00:00:00.000Z';
107
- const rawEvidence = Array.isArray(record.evidenceHistory) ? record.evidenceHistory : [];
108
- const evidencePins = normalizeCaseEvidencePins(record.evidencePins, normalizedFallback, {
109
- allowCertificateObservation: importedVersion >= 11,
110
- });
111
- const pinIds = new Set(evidencePins.map((item) => item.id));
112
- const actions = normalizeCaseActions(record.actions, normalizedFallback);
113
- const assertions = normalizeCaseAssertions(record.assertions, normalizedFallback, pinIds);
114
- const branchReferences = caseInvestigationBranchReferences({ evidencePins, actions, assertions });
115
- const brandProfileReferences = importedVersion >= 12
116
- ? inspectCaseBrandProfileIds(record.brandProfileIds)
117
- : { ids: [], omitted: 0 };
118
- return {
119
- domain,
120
- rawId: typeof record.id === 'string' ? record.id : null,
121
- status: importScalar(record.status, STATUS_VALUES),
122
- disposition: importScalar(record.disposition, DISPOSITION_VALUES),
123
- reviewReasonCode: Object.hasOwn(record, 'reviewReasonCode')
124
- ? normalizeReviewReasonCode(record.reviewReasonCode)
125
- : undefined,
126
- brandProfileIds: brandProfileReferences.ids,
127
- brandProfileReferencesOmitted: brandProfileReferences.omitted,
128
- source: importScalar(record.source, SOURCE_VALUES),
129
- evidenceHistory: normalizeEvidenceHistory(rawEvidence, {
130
- source: 'import',
131
- fallback: importFallback,
132
- sourceVersion: importedVersion,
133
- }),
134
- evidencePins,
135
- decisions: normalizeCaseDecisions(record.decisions, normalizedFallback, pinIds),
136
- actions,
137
- assertions,
138
- manualTrail: normalizeCaseManualTrail(record.manualTrail, normalizedFallback),
139
- sightings: normalizeCaseSightings(record.sightings, normalizedFallback, pinIds),
140
- branches: importedVersion >= 11
141
- ? normalizeCaseInvestigationBranches(record.branches, normalizedFallback, branchReferences)
142
- : [],
143
- tags: normalizeTags(record.tags),
144
- // Imported notes fall back only to the imported record's own timestamps
145
- // (never "now"), so a timestamp-less note gets a stable, deterministic time
146
- // and id, and re-importing the same file cannot manufacture a duplicate or a
147
- // spuriously-newer note.
148
- notes: normalizeNotes(record.notes, importFallback),
149
- createdAt: isoOrNull(record.createdAt),
150
- updatedAt: isoOrNull(record.updatedAt),
151
- };
152
- }
153
- /** @param {CaseNote[]} a @param {CaseNote[]} b @returns {CaseNote[]} */
154
- function unionNotes(a, b) {
155
- const byId = new Map();
156
- for (const note of [...a, ...b]) {
157
- if (!byId.has(note.id))
158
- byId.set(note.id, note);
159
- }
160
- const notes = [...byId.values()].sort((left, right) => Date.parse(left.createdAt) - Date.parse(right.createdAt));
161
- return notes.slice(Math.max(0, notes.length - MAX_NOTES_PER_CASE));
162
- }
163
- // Additive, deduplicated union of two evidence histories. Identical material
164
- // collapses (earliest firstCapturedAt, latest capturedAt), distinct snapshots
165
- // are retained subject to the per-case bound, and an older import can never
166
- // move an existing observation backwards.
167
- function mergeEvidenceHistories(local, imported) {
168
- return normalizeEvidenceHistory([...local, ...imported], { source: 'import', fallback: null });
169
- }
170
- /** @param {ImportPatch} patch @param {string} now @returns {CaseRecord} */
171
- function caseFromPatch(patch, now) {
172
- return {
173
- id: '', // assigned by mergeCases so it can guarantee uniqueness against locals
174
- domain: patch.domain,
175
- status: patch.status ?? DEFAULT_STATUS,
176
- disposition: patch.disposition ?? DEFAULT_DISPOSITION,
177
- reviewReasonCode: patch.reviewReasonCode ?? null,
178
- brandProfileIds: patch.brandProfileIds,
179
- tags: patch.tags,
180
- notes: patch.notes,
181
- source: patch.source ?? DEFAULT_SOURCE,
182
- evidenceHistory: patch.evidenceHistory,
183
- evidencePins: patch.evidencePins,
184
- decisions: patch.decisions,
185
- actions: patch.actions,
186
- assertions: patch.assertions,
187
- manualTrail: patch.manualTrail,
188
- sightings: patch.sightings,
189
- branches: patch.branches,
190
- createdAt: patch.createdAt || patch.updatedAt || now,
191
- updatedAt: patch.updatedAt || patch.createdAt || now,
192
- };
193
- }
194
- /**
195
- * Merges an imported patch into an existing local case. Notes, tags, and
196
- * evidence history are merged additively (never destructive); a scalar field is
197
- * only overwritten when the import provided a valid value AND is strictly newer
198
- * than the local record. A patch with no/invalid updatedAt is never newer.
199
- * @param {CaseRecord} local
200
- * @param {ImportPatch} patch
201
- * @returns {CaseRecord}
202
- */
203
- function applyImportPatch(local, patch) {
204
- const importNewer = patch.updatedAt !== null && Date.parse(patch.updatedAt) > Date.parse(local.updatedAt);
205
- const fallback = patch.updatedAt || local.updatedAt;
206
- const evidencePins = mergeCaseEvidencePins(local.evidencePins, patch.evidencePins, fallback);
207
- const pinIds = new Set(evidencePins.map((item) => item.id));
208
- const actions = mergeCaseActions(local.actions, patch.actions, fallback);
209
- const assertions = mergeCaseAssertions(local.assertions, patch.assertions, fallback, pinIds);
210
- const branchReferences = caseInvestigationBranchReferences({ evidencePins, actions, assertions });
211
- const brandProfileReferences = unionCaseBrandProfileIds(local.brandProfileIds, patch.brandProfileIds);
212
- return { record: {
213
- ...local,
214
- status: patch.status !== undefined && importNewer ? patch.status : local.status,
215
- disposition: patch.disposition !== undefined && importNewer ? patch.disposition : local.disposition,
216
- reviewReasonCode: patch.reviewReasonCode !== undefined && importNewer ? patch.reviewReasonCode : local.reviewReasonCode ?? null,
217
- brandProfileIds: brandProfileReferences.ids,
218
- source: patch.source !== undefined && importNewer ? patch.source : local.source,
219
- evidenceHistory: mergeEvidenceHistories(local.evidenceHistory, patch.evidenceHistory),
220
- evidencePins,
221
- decisions: mergeCaseDecisions(local.decisions, patch.decisions, fallback, pinIds),
222
- actions,
223
- assertions,
224
- manualTrail: mergeCaseManualTrail(local.manualTrail, patch.manualTrail, fallback),
225
- sightings: mergeCaseSightings(local.sightings, patch.sightings, fallback, pinIds),
226
- branches: mergeCaseInvestigationBranches(local.branches ?? [], patch.branches, fallback, branchReferences),
227
- tags: normalizeTags([...local.tags, ...patch.tags]),
228
- notes: unionNotes(local.notes, patch.notes),
229
- createdAt: patch.createdAt && Date.parse(patch.createdAt) < Date.parse(local.createdAt) ? patch.createdAt : local.createdAt,
230
- updatedAt: importNewer ? (patch.updatedAt ?? local.updatedAt) : local.updatedAt,
231
- }, brandProfileReferencesOmitted: brandProfileReferences.omitted };
232
- }
233
- function pickFreeId(preferred, domain, used) {
234
- const wanted = safeId(preferred);
235
- let id = wanted && !used.has(wanted) ? wanted : deterministicId(domain);
236
- const base = deterministicId(domain);
237
- let suffix = 2;
238
- while (used.has(id))
239
- id = `${base}-${suffix++}`;
240
- return id;
241
- }
242
- /**
243
- * Merges an imported (already parsed) value into the local cases. Predictable
244
- * and idempotent: unknown records are skipped, existing domains merge without
245
- * losing newer local decisions, and new ones are added until the store bound is
246
- * reached. An imported envelope that declares a schema version newer than we
247
- * support is rejected up front (before any local data is touched) rather than
248
- * reinterpreted.
249
- * @param {CaseRecord[]} localCases
250
- * @param {unknown} importedRaw
251
- * @returns {{ cases: CaseRecord[], added: number, updated: number, skipped: number, brandProfileReferencesOmitted: number }}
252
- */
253
- export function mergeCases(localCases, importedRaw) {
254
- const importedVersion = parseStoreVersion(importedRaw);
255
- if (importedVersion !== null && Number.isInteger(importedVersion) && importedVersion > CASE_SCHEMA_VERSION) {
256
- throw new Error(`This case file was exported by a newer version of WHOISleuth (schema ${importedVersion}). Update the app before importing it.`);
257
- }
258
- if (!CASE_IMPORT_VERSIONS.includes(importedVersion)
259
- || !importedRaw || typeof importedRaw !== 'object'
260
- || !Array.isArray(objectRecord(importedRaw).cases)) {
261
- throw new Error(`Expected a WHOISleuth case export using schema ${CASE_IMPORT_VERSIONS.join(' or ')}.`);
262
- }
263
- const local = normalizeCaseStore(localCases).cases;
264
- const supportedImportedVersion = importedVersion ?? 0;
265
- const byDomain = new Map(local.map((item) => [item.domain, item]));
266
- const usedIds = new Set(local.map((item) => item.id));
267
- let added = 0;
268
- let updated = 0;
269
- const imported = boundedCaseList(importedRaw);
270
- let skipped = imported.omitted;
271
- let brandProfileReferencesOmitted = 0;
272
- const now = new Date().toISOString();
273
- for (const item of imported.items) {
274
- const patch = extractImportPatch(item, supportedImportedVersion);
275
- if (!patch) {
276
- skipped += 1;
277
- continue;
278
- }
279
- const existing = byDomain.get(patch.domain);
280
- if (existing) {
281
- const merged = applyImportPatch(existing, patch);
282
- byDomain.set(patch.domain, merged.record);
283
- brandProfileReferencesOmitted += patch.brandProfileReferencesOmitted + merged.brandProfileReferencesOmitted;
284
- updated += 1;
285
- }
286
- else if (byDomain.size < MAX_CASES) {
287
- const record = caseFromPatch(patch, now);
288
- record.id = pickFreeId(patch.rawId, patch.domain, usedIds);
289
- usedIds.add(record.id);
290
- byDomain.set(patch.domain, record);
291
- brandProfileReferencesOmitted += patch.brandProfileReferencesOmitted;
292
- added += 1;
293
- }
294
- else {
295
- skipped += 1;
296
- }
297
- }
298
- return {
299
- cases: normalizeCaseStore([...byDomain.values()]).cases,
300
- added,
301
- updated,
302
- skipped,
303
- brandProfileReferencesOmitted,
304
- };
305
- }
@@ -1,9 +0,0 @@
1
- // Stable case-record facade. Core validation, evidence history/comparison,
2
- // and analyst-record operations remain framework-neutral and independently
3
- // testable while existing consumers keep one compatibility import.
4
- export * from "./case-record-contracts.js";
5
- export * from "./case-record-core.js";
6
- export * from "./case-brand-profile-references.js";
7
- export * from "./case-evidence-model.js";
8
- export * from "./case-record-operations.js";
9
- export * from "./case-investigation-branch-model.js";