@slicedearth/whoisleuth-cli 1.45.0 → 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 (268) hide show
  1. package/DISCLOSURE +7 -3
  2. package/NOTICE +5 -4
  3. package/README.md +74 -124
  4. package/SECURITY.md +6 -3
  5. package/bin/whoisleuth.mjs +7 -4
  6. package/cli/archive-inspect.mjs +28 -10
  7. package/cli/arguments.mjs +450 -173
  8. package/cli/artifact-structure.mjs +2507 -0
  9. package/cli/artifact-verify.mjs +286 -64
  10. package/cli/bounded-json.mjs +1 -0
  11. package/cli/bulk-checkpoint.mjs +35 -23
  12. package/cli/bulk-command-runner.mjs +2 -5
  13. package/cli/bulk.mjs +9 -2
  14. package/cli/case-pack.mjs +435 -46
  15. package/cli/ci-report.mjs +6 -1
  16. package/cli/collection-preflight.mjs +2 -2
  17. package/cli/command-catalogue.mjs +12 -3
  18. package/cli/command-reference.mjs +948 -106
  19. package/cli/completion.mjs +504 -218
  20. package/cli/config-profile.mjs +13 -3
  21. package/cli/ct-event-intake.mjs +32 -2
  22. package/cli/discover.mjs +7 -1
  23. package/cli/discovery-observation-snapshot.mjs +24 -28
  24. package/cli/discovery-scan-command-runner.mjs +13 -14
  25. package/cli/discovery-scan.mjs +17 -3
  26. package/cli/discovery-snapshot.mjs +15 -7
  27. package/cli/discovery-workflow.mjs +2 -3
  28. package/cli/doctor.mjs +2 -2
  29. package/cli/domain-control-monitor.mjs +215 -36
  30. package/cli/domain-control-observations.mjs +34 -17
  31. package/cli/errors.mjs +8 -3
  32. package/cli/evidence-command-runner.mjs +17 -11
  33. package/cli/evidence-signing.mjs +68 -50
  34. package/cli/export-evidence.mjs +11 -4
  35. package/cli/external-observation-mapping.mjs +3 -3
  36. package/cli/formatters/html.mjs +3 -3
  37. package/cli/formatters/json.mjs +20 -32
  38. package/cli/formatters/terminal.mjs +586 -22
  39. package/cli/input.mjs +62 -0
  40. package/cli/interactive-launcher.mjs +226 -0
  41. package/cli/interchange-report.mjs +226 -0
  42. package/cli/investigation-manifest.mjs +16 -17
  43. package/cli/investigation-plan.mjs +167 -16
  44. package/cli/investigation-run.mjs +80 -24
  45. package/cli/local-mmdb-review.mjs +45 -14
  46. package/cli/lookalike-calibration.mjs +3 -1
  47. package/cli/lookup-brief.mjs +13 -8
  48. package/cli/lookup-browser.mjs +844 -0
  49. package/cli/lookup-command-runner.mjs +96 -33
  50. package/cli/lookup-diff.mjs +11 -2
  51. package/cli/lookup-plan.mjs +1 -0
  52. package/cli/mail-review.mjs +206 -94
  53. package/cli/manual.mjs +5 -5
  54. package/cli/network-command-runner.mjs +78 -0
  55. package/cli/offline-evidence-review.mjs +45 -14
  56. package/cli/open-asset-model-bridge.mjs +1 -1
  57. package/cli/output-file.mjs +70 -14
  58. package/cli/page-compare.mjs +18 -10
  59. package/cli/progress-events.mjs +2 -1
  60. package/cli/progress.mjs +3 -3
  61. package/cli/registry-cohort.mjs +343 -61
  62. package/cli/registry-doctor.mjs +67 -28
  63. package/cli/registry-support.mjs +2 -1
  64. package/cli/retained-artifact-diff.mjs +375 -0
  65. package/cli/risk-calibration.mjs +89 -40
  66. package/cli/runner.mjs +322 -246
  67. package/cli/saved-lookup.mjs +439 -49
  68. package/cli/sharing-review.mjs +18 -7
  69. package/cli/source-reliability.mjs +75 -36
  70. package/cli/terminal-presentation.mjs +43 -18
  71. package/docs/cli-reference.md +125 -1334
  72. package/docs/cli.md +87 -236
  73. package/frontend/src/lib/analysis/domain-control-manifest-core.js +3 -0
  74. package/frontend/src/lib/analysis/domain-control-records.js +3 -0
  75. package/lib/application-version.mjs +3 -0
  76. package/lib/availability.mjs +72 -52
  77. package/lib/bounded-contract-normalizers.mjs +2 -4
  78. package/lib/bounded-file.mjs +91 -4
  79. package/lib/bounded-json.mjs +287 -0
  80. package/lib/browser-library-profile.mjs +1 -1
  81. package/lib/certificate-extension-profile.mjs +2 -1
  82. package/lib/classify.mjs +56 -5
  83. package/lib/client-behavior-profile.mjs +1 -1
  84. package/lib/credential-surface-profile.mjs +1 -1
  85. package/lib/cryptographic-assurance.mjs +173 -0
  86. package/lib/ct-query.mjs +3 -3
  87. package/lib/ct-search.mjs +120 -64
  88. package/lib/distributed-operation-budget.mjs +4 -2
  89. package/lib/dns-delegation-health.mjs +9 -6
  90. package/lib/dns-intelligence.mjs +4 -3
  91. package/lib/dns-resolver-selection.mjs +6 -4
  92. package/lib/dnssec-chain-validation.mjs +1052 -0
  93. package/lib/dnssec-evidence-validation.mjs +23 -10
  94. package/lib/domain-assurance.mjs +12 -12
  95. package/lib/domain-change-packet.mjs +7 -5
  96. package/lib/domain-control-flight-recorder.mjs +442 -78
  97. package/lib/domain-control-manifest.mjs +314 -171
  98. package/lib/domain-posture-analysis.mjs +5 -2
  99. package/lib/domain-posture.mjs +7 -4
  100. package/lib/encrypted-dns-contract.mjs +2 -3
  101. package/lib/evidence-export.mjs +1243 -88
  102. package/lib/evidence-report-markdown.mjs +3 -0
  103. package/lib/evidence-report.mjs +5 -0
  104. package/lib/external-intelligence-risk.mjs +26 -10
  105. package/lib/favicon.mjs +8 -5
  106. package/lib/feature-policy.mjs +1 -1
  107. package/lib/homepage-metadata-contract.mjs +290 -0
  108. package/lib/hostname.mjs +1 -1
  109. package/lib/html-signals.mjs +89 -5
  110. package/lib/http-evidence-bounds.mjs +3 -0
  111. package/lib/http-intelligence.mjs +332 -6
  112. package/lib/interchange-fidelity-registry.mjs +124 -0
  113. package/lib/local-geoip-evidence.mjs +2 -1
  114. package/lib/lookup-cache.mjs +6 -4
  115. package/lib/lookup-network-evidence-bounds.mjs +9 -0
  116. package/lib/lookup.mjs +27 -4
  117. package/lib/observed-network-context.mjs +1 -1
  118. package/lib/outbound-identity.mjs +2 -3
  119. package/lib/page-fingerprints.mjs +2 -2
  120. package/lib/page-role-profile.mjs +1 -1
  121. package/lib/perceptual-hash.mjs +12 -4
  122. package/lib/portable-generator.mjs +29 -0
  123. package/lib/provider-policy-admission.mjs +11 -4
  124. package/lib/rdap-bootstrap.mjs +76 -16
  125. package/lib/rdap-client.mjs +20 -12
  126. package/lib/rdap-normalization.mjs +324 -80
  127. package/lib/rdap-registrar.mjs +5 -1
  128. package/lib/rdap-transport.mjs +2 -2
  129. package/lib/rdap-validation.mjs +65 -14
  130. package/lib/rdap.mjs +2 -2
  131. package/lib/registrable-domain.mjs +36 -0
  132. package/lib/registry-capabilities.mjs +3 -0
  133. package/lib/registry-capability-catalogue.mjs +12 -2
  134. package/lib/registry-comparison.mjs +28 -11
  135. package/lib/registry-insights.mjs +7 -0
  136. package/lib/response-policy.mjs +27 -4
  137. package/lib/risk-calibration-summary.mjs +340 -0
  138. package/lib/risk-scoring.mjs +4 -3
  139. package/lib/rpki-evidence.mjs +9 -8
  140. package/lib/safe-fetch.mjs +121 -50
  141. package/lib/security-txt.mjs +27 -4
  142. package/lib/service-binding-dns.mjs +6 -1
  143. package/lib/smtp-transport-review.mjs +787 -0
  144. package/lib/sslbl-certificates.generated.mjs +136 -134
  145. package/lib/sslbl-intelligence.mjs +6 -8
  146. package/lib/static-html-analysis.mjs +301 -4
  147. package/lib/structured-data-identity.mjs +51 -19
  148. package/lib/threat-intelligence-fixtures.mjs +2 -4
  149. package/lib/threat-intelligence-manifests.mjs +2 -7
  150. package/lib/threat-intelligence-runtime.mjs +5 -15
  151. package/lib/threat-intelligence-types.mjs +21 -1
  152. package/lib/threatfox-intelligence.mjs +7 -3
  153. package/lib/tls-intelligence.mjs +15 -9
  154. package/lib/tlsa-evidence.mjs +37 -7
  155. package/lib/urlhaus-intelligence.mjs +4 -2
  156. package/lib/urlscan-intelligence.mjs +6 -5
  157. package/lib/website-security-posture.mjs +1 -1
  158. package/lib/website-technology.mjs +1 -1
  159. package/lib/whois-authority.mjs +43 -8
  160. package/lib/whois-chain.mjs +25 -3
  161. package/lib/whois-normalization.mjs +3 -1
  162. package/lib/whois-transport.mjs +12 -3
  163. package/lib/zone-intent-review.mjs +1 -1
  164. package/package.json +12 -4
  165. package/packages/cases/case-brand-profile-references.mjs +85 -0
  166. package/{frontend/src/lib/analysis/case-evidence-model.js → packages/cases/case-evidence-model.mjs} +43 -14
  167. package/packages/cases/case-investigation-branch-model.mjs +132 -0
  168. package/packages/cases/case-migration-model.mjs +476 -0
  169. package/{frontend/src/lib/analysis/case-model.js → packages/cases/case-model.mjs} +3 -3
  170. package/{frontend/src/lib/analysis/case-record-contracts.js → packages/cases/case-record-contracts.mjs} +3 -19
  171. package/{frontend/src/lib/analysis/case-record-core.js → packages/cases/case-record-core.mjs} +58 -31
  172. package/packages/cases/case-record-model.mjs +9 -0
  173. package/{frontend/src/lib/analysis/case-record-operations.js → packages/cases/case-record-operations.mjs} +106 -33
  174. package/{frontend/src/lib/analysis/case-report.js → packages/cases/case-report.mjs} +143 -16
  175. package/packages/cases/case-response-model.mjs +1604 -0
  176. package/packages/cases/case-response-packet.mjs +1312 -0
  177. package/{frontend/src/lib/analysis/case-storage-model.js → packages/cases/case-storage-model.mjs} +2 -2
  178. package/{frontend/src/lib/analysis/http-summary.js → packages/cases/http-summary.mjs} +1 -1
  179. package/packages/cases/opaque-reference-id.mjs +9 -0
  180. package/packages/comparison/comparison-ledger-bulk.mjs +366 -0
  181. package/packages/comparison/comparison-ledger-contract.mjs +241 -0
  182. package/packages/comparison/comparison-ledger-json.mjs +80 -0
  183. package/packages/comparison/comparison-ledger-serialization.mjs +52 -0
  184. package/packages/comparison/favicon-similarity.mjs +78 -0
  185. package/{frontend/src/lib/analysis/page-similarity.js → packages/comparison/page-similarity.mjs} +4 -3
  186. package/{frontend/src/lib/analysis/relationship-evidence.js → packages/comparison/relationship-evidence.mjs} +6 -7
  187. package/packages/contracts/analyst-review-state-contract.mjs +61 -0
  188. package/packages/contracts/analyst-review-state.mjs +50 -0
  189. package/packages/contracts/case-portability.mjs +1310 -0
  190. package/packages/contracts/cli-command-catalogue.mjs +2 -0
  191. package/packages/contracts/cli-lookup.mjs +394 -0
  192. package/packages/contracts/domain-control-flight-recorder.mjs +461 -0
  193. package/packages/contracts/domain-control-manifest.mjs +1027 -0
  194. package/packages/contracts/domain-control-monitor.mjs +310 -0
  195. package/packages/contracts/domain-control-review.mjs +828 -0
  196. package/packages/contracts/domain-name.mjs +4 -0
  197. package/packages/contracts/external-observation-interchange.mjs +224 -0
  198. package/packages/contracts/extracted-domain-lifecycle.mjs +218 -0
  199. package/packages/contracts/investigation-portability.mjs +331 -0
  200. package/packages/contracts/offline-comparison.mjs +160 -0
  201. package/packages/contracts/risk-calibration.mjs +1288 -0
  202. package/packages/contracts/schema-compatibility.mjs +71 -0
  203. package/packages/contracts/schema-lifecycle.mjs +1702 -0
  204. package/packages/contracts/sslbl-snapshot.mjs +17 -0
  205. package/packages/contracts/workspace-portability.mjs +1347 -0
  206. package/packages/evidence/artifact-integrity.mjs +65 -0
  207. package/packages/evidence/decision-fact.mjs +735 -0
  208. package/packages/evidence/domain-control-runtime.mjs +547 -0
  209. package/packages/evidence/domain-name.mjs +41 -0
  210. package/packages/evidence/observation.mjs +384 -0
  211. package/{frontend/src/lib/analysis/external-findings-import.js → packages/interchange/external-findings-import.mjs} +86 -20
  212. package/packages/interchange/retained-text.mjs +8 -0
  213. package/{frontend/src/lib/analysis/acquisition-decision-packet.js → packages/investigation/acquisition-decision-packet.mjs} +11 -8
  214. package/{frontend/src/lib/analysis/bulk-domain-comparison.js → packages/investigation/bulk-domain-comparison.mjs} +28 -9
  215. package/{frontend/src/lib/analysis/bulk-mail-exposure.js → packages/investigation/bulk-mail-exposure.mjs} +67 -18
  216. package/{frontend/src/lib/analysis/bulk-review-export.js → packages/investigation/bulk-review-export.mjs} +10 -9
  217. package/packages/investigation/bulk-review-policy.mjs +1 -0
  218. package/{frontend/src/lib/analysis/investigation-capsule.js → packages/investigation/investigation-capsule.mjs} +50 -18
  219. package/packages/investigation/lookup-artefact-inputs.mjs +1 -0
  220. package/{frontend/src/lib/analysis/lookup-asset-graph.js → packages/investigation/lookup-asset-graph.mjs} +4 -2
  221. package/packages/investigation/lookup-claim-passport.mjs +232 -0
  222. package/packages/investigation/lookup-claim-readiness.mjs +287 -0
  223. package/packages/investigation/lookup-investigation-brief.mjs +172 -0
  224. package/packages/monitoring/analyst-review-state.mjs +680 -0
  225. package/{frontend/src/lib/analysis/brand-profile-model.js → packages/workspace/brand-profile-model.mjs} +97 -50
  226. package/{frontend/src/lib/analysis/bulk-review-model.js → packages/workspace/bulk-review-model.mjs} +15 -15
  227. package/{frontend/src/lib/analysis/bulk-session-model.js → packages/workspace/bulk-session-model.mjs} +179 -39
  228. package/{frontend/src/lib/analysis/bulk-sort.js → packages/workspace/bulk-sort.mjs} +12 -5
  229. package/{frontend/src/lib/analysis/campaign-model.js → packages/workspace/campaign-model.mjs} +20 -22
  230. package/{frontend/src/lib/analysis/detection-rule-model.js → packages/workspace/detection-rule-model.mjs} +15 -17
  231. package/packages/workspace/hostile-input.mjs +172 -0
  232. package/{frontend/src/lib/analysis/investigation-guide.js → packages/workspace/investigation-guide.mjs} +51 -46
  233. package/{frontend/src/lib/analysis/investigation-template-model.js → packages/workspace/investigation-template-model.mjs} +15 -15
  234. package/{frontend/src/lib/analysis/page-baseline.js → packages/workspace/page-baseline.mjs} +6 -13
  235. package/{frontend/src/lib/analysis/relationship-observation-model.js → packages/workspace/relationship-observation-model.mjs} +13 -17
  236. package/{frontend/src/lib/analysis/shortlist-model.js → packages/workspace/shortlist-model.mjs} +20 -18
  237. package/{frontend/src/lib/analysis/watchlist-history.js → packages/workspace/watchlist-history.mjs} +9 -16
  238. package/{frontend/src/lib/analysis/watchlist-store.js → packages/workspace/watchlist-store.mjs} +24 -17
  239. package/{frontend/src/lib/analysis/website-snapshot-model.js → packages/workspace/website-snapshot-model.mjs} +18 -12
  240. package/{frontend/src/lib/analysis/workspace-archive-crypto.js → packages/workspace/workspace-archive-crypto.mjs} +49 -19
  241. package/{frontend/src/lib/analysis/workspace-archive.js → packages/workspace/workspace-archive.mjs} +195 -84
  242. package/third-party-notices.txt +112 -5834
  243. package/frontend/src/lib/analysis/artifact-integrity.js +0 -21
  244. package/frontend/src/lib/analysis/bulk-retry-plan.js +0 -79
  245. package/frontend/src/lib/analysis/bulk-source-coverage.js +0 -76
  246. package/frontend/src/lib/analysis/case-migration-model.js +0 -253
  247. package/frontend/src/lib/analysis/case-record-model.js +0 -7
  248. package/frontend/src/lib/analysis/case-response-model.js +0 -669
  249. package/frontend/src/lib/analysis/case-response-packet.js +0 -573
  250. package/frontend/src/lib/analysis/lookup-decision-support.js +0 -487
  251. package/frontend/src/lib/analysis/lookup-investigation-brief.js +0 -153
  252. package/frontend/src/lib/analysis/lookup-presentation.js +0 -58
  253. package/frontend/src/lib/analysis/lookup-response.js +0 -2
  254. package/frontend/src/lib/analysis/lookup-source-refresh.js +0 -277
  255. package/frontend/src/lib/analysis/lookup-summary-model.js +0 -398
  256. package/frontend/src/lib/analysis/scoring.js +0 -55
  257. package/frontend/src/lib/analysis/utils.js +0 -232
  258. package/frontend/src/lib/analysis/visualization-bounds.js +0 -26
  259. package/frontend/src/lib/analysis/visualization-force-graph.js +0 -352
  260. package/frontend/src/lib/analysis/visualization-matrix.js +0 -88
  261. package/frontend/src/lib/analysis/visualization-models.js +0 -369
  262. package/frontend/src/lib/analysis/visualization-time-series.js +0 -144
  263. package/lib/bounded-json-response.mjs +0 -157
  264. package/lib/lookup-response-contract.mjs +0 -346
  265. package/lib/observation.mjs +0 -114
  266. /package/{frontend/src/lib/analysis/acquisition-due-diligence.js → packages/investigation/acquisition-due-diligence.mjs} +0 -0
  267. /package/{frontend/src/lib/analysis/evidence-coverage-ledger.js → packages/investigation/evidence-coverage-ledger.mjs} +0 -0
  268. /package/{frontend/src/lib/analysis/dns-record-normalization.js → packages/workspace/dns-record-normalization.mjs} +0 -0
@@ -0,0 +1,476 @@
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, caseTimestampOrNull, normalizeCase, normalizeDomain, normalizeEvidenceHistory, normalizeNotes, normalizeReviewReasonCode, normalizeTags, objectRecord, safeId, } from "./case-record-model.mjs";
2
+ import { CASE_REPORT_SCHEMA, caseReportVersionMatchesCase, CLI_CASE_PACK_CURRENT_REDACTION_KEYS, CLI_CASE_PACK_INTEGRITY_KEYS, CLI_CASE_PACK_LIMITATIONS, CLI_CASE_PACK_PACKET_KEYS, CLI_CASE_PACK_PUBLIC_REPORT_KEYS, CLI_CASE_PACK_REPORT_KEYS, CLI_CASE_PACK_ROOT_KEYS, CLI_CASE_PACK_SCHEMA, CLI_CASE_PACK_VERSION, } from "../contracts/case-portability.mjs";
3
+ import { canonicalArtifactJsonV2 } from "../evidence/artifact-integrity.mjs";
4
+ import { assertBoundedJsonStructure } from "../../lib/bounded-json.mjs";
5
+ import { inspectCaseBrandProfileIds, unionCaseBrandProfileIds, } from "./case-brand-profile-references.mjs";
6
+ import { caseInvestigationBranchReferences, mergeCaseInvestigationBranches, normalizeCaseInvestigationBranches, } from "./case-investigation-branch-model.mjs";
7
+ import { buildCaseClosureLinkContext, mergeCaseActions, mergeCaseAssertions, mergeCaseClosureHistories, mergeCaseDecisions, mergeCaseEvidencePins, mergeCaseManualTrail, mergeCaseObservedEffectHistories, mergeCaseSightings, normalizeCaseActions, normalizeCaseAssertions, normalizeCaseClosureHistory, normalizeCaseDecisions, normalizeCaseEvidencePins, normalizeCaseManualTrail, normalizeCaseObservedEffectHistory, normalizeCaseSightings, } from "./case-response-model.mjs";
8
+ const STATUS_VALUES = new Set(CASE_STATUSES.map((item) => item.value));
9
+ const DISPOSITION_VALUES = new Set(CASE_DISPOSITIONS.map((item) => item.value));
10
+ const SOURCE_VALUES = new Set(CASE_SOURCES.map((item) => item.value));
11
+ export const MAX_CASE_INPUT_RECORDS = 2_000;
12
+ function compareCodeUnits(left, right) {
13
+ return left < right ? -1 : left > right ? 1 : 0;
14
+ }
15
+ const CASE_INPUT_JSON_LIMITS = Object.freeze({
16
+ maximumDepth: 48,
17
+ maximumKeys: 500_000,
18
+ maximumValues: 1_000_000,
19
+ maximumContainerItems: 10_000,
20
+ });
21
+ function assignUniqueIds(cases) {
22
+ const used = new Set();
23
+ for (const record of [...cases].sort((a, b) => compareCodeUnits(a.domain, b.domain))) {
24
+ let id = safeId(record.id) || deterministicId(record.domain);
25
+ if (used.has(id)) {
26
+ const base = deterministicId(record.domain);
27
+ id = base;
28
+ let suffix = 2;
29
+ while (used.has(id))
30
+ id = `${base}-${suffix++}`;
31
+ }
32
+ used.add(id);
33
+ record.id = id;
34
+ }
35
+ }
36
+ /**
37
+ * Recovers a clean, bounded store from the current versioned envelope or an
38
+ * internal bare array, drops malformed records, keeps a single
39
+ * case per domain (most recently updated wins), caps to MAX_CASES by recency,
40
+ * and guarantees globally unique safe ids. Retired, future, accessor-bearing,
41
+ * sparse, or otherwise non-JSON envelopes fail before normalisation.
42
+ * @param {unknown} raw
43
+ * @returns {CaseStore}
44
+ */
45
+ export function normalizeCaseStore(raw) {
46
+ assertBoundedJsonStructure(raw, 'Case store', CASE_INPUT_JSON_LIMITS);
47
+ const fallback = new Date(0).toISOString();
48
+ const sourceVersion = parseStoreVersion(raw);
49
+ if (!Array.isArray(raw)) {
50
+ if (sourceVersion === null) {
51
+ throw new TypeError(`Unversioned Case stores are retired. Export or reset them explicitly before using Case schema ${CASE_SCHEMA_VERSION}; no data was changed.`);
52
+ }
53
+ if (!CASE_IMPORT_VERSIONS.includes(sourceVersion) && sourceVersion < CASE_SCHEMA_VERSION) {
54
+ throw new TypeError(`Case schema ${sourceVersion} is not part of the public compatibility boundary. Only public schema 12 migrates to schema ${CASE_SCHEMA_VERSION}; no data was changed.`);
55
+ }
56
+ if (sourceVersion > CASE_SCHEMA_VERSION) {
57
+ throw new TypeError(`Case schema ${sourceVersion} is newer than the supported schema ${CASE_SCHEMA_VERSION}; no data was changed.`);
58
+ }
59
+ if (sourceVersion === CASE_SCHEMA_VERSION) {
60
+ assertCurrentEvidenceHostnameShape(raw);
61
+ }
62
+ }
63
+ const byDomain = new Map();
64
+ for (const item of boundedCaseList(raw).items) {
65
+ const normalized = normalizeCase(item, undefined, fallback, sourceVersion ?? CASE_SCHEMA_VERSION);
66
+ if (!normalized)
67
+ continue;
68
+ const existing = byDomain.get(normalized.domain);
69
+ if (!existing || Date.parse(normalized.updatedAt) >= Date.parse(existing.updatedAt)) {
70
+ byDomain.set(normalized.domain, normalized);
71
+ }
72
+ }
73
+ const cases = [...byDomain.values()]
74
+ .sort((a, b) => Date.parse(b.updatedAt) - Date.parse(a.updatedAt))
75
+ .slice(0, MAX_CASES);
76
+ assignUniqueIds(cases);
77
+ return { version: CASE_SCHEMA_VERSION, cases };
78
+ }
79
+ function assertCurrentEvidenceHostnameShape(raw) {
80
+ for (const item of boundedCaseList(raw).items) {
81
+ const evidenceHistory = objectRecord(item).evidenceHistory;
82
+ if (!Array.isArray(evidenceHistory))
83
+ continue;
84
+ for (const snapshot of evidenceHistory) {
85
+ const record = objectRecord(snapshot);
86
+ if (Object.keys(record).length > 0 && !Object.hasOwn(record, 'inputHostname')) {
87
+ throw new TypeError(`Case schema ${CASE_SCHEMA_VERSION} evidence snapshots must declare their exact submitted hostname, including null; unreleased local Case checkpoints are not interpreted as the v2 format and no data was changed.`);
88
+ }
89
+ }
90
+ }
91
+ }
92
+ /**
93
+ * The schema version declared by a stored/parsed value, or null. The storage
94
+ * wrapper uses this to refuse overwriting data written by a newer, unsupported
95
+ * version instead of silently downgrading it.
96
+ * @param {unknown} raw
97
+ * @returns {number | null}
98
+ */
99
+ export function parseStoreVersion(raw) {
100
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw))
101
+ return null;
102
+ let descriptor;
103
+ try {
104
+ descriptor = Object.getOwnPropertyDescriptor(raw, 'version');
105
+ }
106
+ catch {
107
+ throw new TypeError('The Case store version could not be inspected safely.');
108
+ }
109
+ if (!descriptor)
110
+ return null;
111
+ if (!Object.hasOwn(descriptor, 'value')) {
112
+ throw new TypeError('The Case store version must not be an accessor.');
113
+ }
114
+ return typeof descriptor.value === 'number' ? descriptor.value : null;
115
+ }
116
+ /** @param {unknown} raw @returns {unknown[]} */
117
+ function asCaseList(raw) {
118
+ if (Array.isArray(raw))
119
+ return raw;
120
+ const cases = objectRecord(raw).cases;
121
+ if (Array.isArray(cases))
122
+ return cases;
123
+ return [];
124
+ }
125
+ function boundedCaseList(raw) {
126
+ const source = asCaseList(raw);
127
+ return {
128
+ items: source.slice(0, MAX_CASE_INPUT_RECORDS),
129
+ omitted: Math.max(0, source.length - MAX_CASE_INPUT_RECORDS),
130
+ };
131
+ }
132
+ /**
133
+ * @param {{ domain: unknown, status?: unknown, disposition?: unknown, source?: unknown, tags?: unknown, evidence?: unknown, note?: unknown }} input
134
+ * @param {string} [nowIso]
135
+ * @returns {CaseRecord}
136
+ */
137
+ function importScalar(value, valid) {
138
+ return typeof value === 'string' && valid.has(value) ? value : undefined;
139
+ }
140
+ /**
141
+ * Validates one imported record into a patch. Unlike normalizeCase, absent or
142
+ * invalid scalar fields stay `undefined` (never defaulted) and a missing/invalid
143
+ * updatedAt stays `null` (treated as older than any real local timestamp), so an
144
+ * incomplete import can never win a merge over valid local data. Imported
145
+ * evidence is normalized additively; a snapshot with no captured time falls back
146
+ * only to the imported record's own (older) timestamps, never to "now".
147
+ * @param {unknown} raw
148
+ * @returns {ImportPatch | null}
149
+ */
150
+ function extractImportPatch(raw, importedVersion) {
151
+ const record = objectRecord(raw);
152
+ const domain = normalizeDomain(record.domain);
153
+ if (!domain)
154
+ return null;
155
+ const importFallback = caseTimestampOrNull(record.updatedAt, importedVersion)
156
+ || caseTimestampOrNull(record.createdAt, importedVersion)
157
+ || null;
158
+ const normalizedFallback = importFallback || '1970-01-01T00:00:00.000Z';
159
+ const timestampOptions = {
160
+ legacyTimestamps: importedVersion < CASE_SCHEMA_VERSION,
161
+ sourceVersion: importedVersion,
162
+ };
163
+ const rawEvidence = Array.isArray(record.evidenceHistory) ? record.evidenceHistory : [];
164
+ const evidencePins = normalizeCaseEvidencePins(record.evidencePins, normalizedFallback, {
165
+ allowCertificateObservation: importedVersion >= 11,
166
+ ...timestampOptions,
167
+ });
168
+ const pinIds = new Set(evidencePins.map((item) => item.id));
169
+ const actions = normalizeCaseActions(record.actions, normalizedFallback, { ...timestampOptions, validEvidencePinIds: pinIds });
170
+ const assertions = normalizeCaseAssertions(record.assertions, normalizedFallback, pinIds, timestampOptions);
171
+ const sightings = normalizeCaseSightings(record.sightings, normalizedFallback, pinIds, timestampOptions);
172
+ const observedEffects = normalizeCaseObservedEffectHistory(importedVersion >= 13 ? record.observedEffects : undefined, normalizedFallback, pinIds, new Set(sightings.map((item) => item.id)), timestampOptions);
173
+ const closures = normalizeCaseClosureHistory(importedVersion >= 13 ? record.closures : undefined, normalizedFallback, new Set(observedEffects.reviews.map((item) => item.id)), new Set(actions.map((item) => item.id)), timestampOptions, buildCaseClosureLinkContext(observedEffects, actions));
174
+ const branchReferences = caseInvestigationBranchReferences({ evidencePins, actions, assertions });
175
+ const brandProfileReferences = importedVersion >= 12
176
+ ? inspectCaseBrandProfileIds(record.brandProfileIds)
177
+ : { ids: [], omitted: 0 };
178
+ return {
179
+ domain,
180
+ rawId: typeof record.id === 'string' ? record.id : null,
181
+ status: importScalar(record.status, STATUS_VALUES),
182
+ disposition: importScalar(record.disposition, DISPOSITION_VALUES),
183
+ reviewReasonCode: Object.hasOwn(record, 'reviewReasonCode')
184
+ ? normalizeReviewReasonCode(record.reviewReasonCode)
185
+ : undefined,
186
+ brandProfileIds: brandProfileReferences.ids,
187
+ brandProfileReferencesOmitted: brandProfileReferences.omitted,
188
+ source: importScalar(record.source, SOURCE_VALUES),
189
+ evidenceHistory: normalizeEvidenceHistory(rawEvidence, {
190
+ source: 'import',
191
+ fallback: importFallback,
192
+ sourceVersion: importedVersion,
193
+ caseDomain: domain,
194
+ }),
195
+ evidencePins,
196
+ decisions: normalizeCaseDecisions(record.decisions, normalizedFallback, pinIds, timestampOptions),
197
+ actions,
198
+ assertions,
199
+ manualTrail: normalizeCaseManualTrail(record.manualTrail, normalizedFallback, timestampOptions),
200
+ sightings,
201
+ observedEffects,
202
+ closures,
203
+ branches: importedVersion >= 11
204
+ ? normalizeCaseInvestigationBranches(record.branches, normalizedFallback, branchReferences, timestampOptions)
205
+ : [],
206
+ tags: normalizeTags(record.tags),
207
+ // Imported notes fall back only to the imported record's own timestamps
208
+ // (never "now"), so a timestamp-less note gets a stable, deterministic time
209
+ // and id, and re-importing the same file cannot manufacture a duplicate or a
210
+ // spuriously-newer note.
211
+ notes: normalizeNotes(record.notes, importFallback, importedVersion),
212
+ createdAt: caseTimestampOrNull(record.createdAt, importedVersion),
213
+ updatedAt: caseTimestampOrNull(record.updatedAt, importedVersion),
214
+ };
215
+ }
216
+ /** @param {CaseNote[]} a @param {CaseNote[]} b @returns {CaseNote[]} */
217
+ function unionNotes(a, b) {
218
+ const byId = new Map();
219
+ for (const note of [...a, ...b]) {
220
+ if (!byId.has(note.id))
221
+ byId.set(note.id, note);
222
+ }
223
+ const notes = [...byId.values()].sort((left, right) => Date.parse(left.createdAt) - Date.parse(right.createdAt));
224
+ return notes.slice(Math.max(0, notes.length - MAX_NOTES_PER_CASE));
225
+ }
226
+ // Additive, deduplicated union of two evidence histories. Identical material
227
+ // collapses (earliest firstCapturedAt, latest capturedAt), distinct snapshots
228
+ // are retained subject to the per-case bound, and an older import can never
229
+ // move an existing observation backwards.
230
+ function mergeEvidenceHistories(local, imported, caseDomain) {
231
+ return normalizeEvidenceHistory([...local, ...imported], { source: 'import', fallback: null, caseDomain });
232
+ }
233
+ /** @param {ImportPatch} patch @param {string} now @returns {CaseRecord} */
234
+ function caseFromPatch(patch, now) {
235
+ return {
236
+ id: '', // assigned by mergeCases so it can guarantee uniqueness against locals
237
+ domain: patch.domain,
238
+ status: patch.status ?? DEFAULT_STATUS,
239
+ disposition: patch.disposition ?? DEFAULT_DISPOSITION,
240
+ reviewReasonCode: patch.reviewReasonCode ?? null,
241
+ brandProfileIds: patch.brandProfileIds,
242
+ tags: patch.tags,
243
+ notes: patch.notes,
244
+ source: patch.source ?? DEFAULT_SOURCE,
245
+ evidenceHistory: patch.evidenceHistory,
246
+ evidencePins: patch.evidencePins,
247
+ decisions: patch.decisions,
248
+ actions: patch.actions,
249
+ assertions: patch.assertions,
250
+ manualTrail: patch.manualTrail,
251
+ sightings: patch.sightings,
252
+ observedEffects: patch.observedEffects,
253
+ closures: patch.closures,
254
+ branches: patch.branches,
255
+ createdAt: patch.createdAt || patch.updatedAt || now,
256
+ updatedAt: patch.updatedAt || patch.createdAt || now,
257
+ };
258
+ }
259
+ /**
260
+ * Merges an imported patch into an existing local case. Notes, tags, and
261
+ * evidence history are merged additively (never destructive); a scalar field is
262
+ * only overwritten when the import provided a valid value AND is strictly newer
263
+ * than the local record. A patch with no/invalid updatedAt is never newer.
264
+ * @param {CaseRecord} local
265
+ * @param {ImportPatch} patch
266
+ * @returns {CaseRecord}
267
+ */
268
+ function applyImportPatch(local, patch) {
269
+ const importNewer = patch.updatedAt !== null && Date.parse(patch.updatedAt) > Date.parse(local.updatedAt);
270
+ const fallback = patch.updatedAt || local.updatedAt;
271
+ const evidencePins = mergeCaseEvidencePins(local.evidencePins, patch.evidencePins, fallback);
272
+ const pinIds = new Set(evidencePins.map((item) => item.id));
273
+ const actions = mergeCaseActions(local.actions, patch.actions, fallback, pinIds);
274
+ const assertions = mergeCaseAssertions(local.assertions, patch.assertions, fallback, pinIds);
275
+ const branchReferences = caseInvestigationBranchReferences({ evidencePins, actions, assertions });
276
+ const sightings = mergeCaseSightings(local.sightings, patch.sightings, fallback, pinIds);
277
+ const sightingIds = new Set(sightings.map((item) => item.id));
278
+ const observedEffects = mergeCaseObservedEffectHistories(local.observedEffects, patch.observedEffects, fallback, pinIds, sightingIds);
279
+ const closures = mergeCaseClosureHistories(local.closures, patch.closures, fallback, new Set(observedEffects.reviews.map((item) => item.id)), new Set(actions.map((item) => item.id)), buildCaseClosureLinkContext(observedEffects, actions));
280
+ const brandProfileReferences = unionCaseBrandProfileIds(local.brandProfileIds, patch.brandProfileIds);
281
+ return { record: {
282
+ ...local,
283
+ status: patch.status !== undefined && importNewer ? patch.status : local.status,
284
+ disposition: patch.disposition !== undefined && importNewer ? patch.disposition : local.disposition,
285
+ reviewReasonCode: patch.reviewReasonCode !== undefined && importNewer ? patch.reviewReasonCode : local.reviewReasonCode ?? null,
286
+ brandProfileIds: brandProfileReferences.ids,
287
+ source: patch.source !== undefined && importNewer ? patch.source : local.source,
288
+ evidenceHistory: mergeEvidenceHistories(local.evidenceHistory, patch.evidenceHistory, local.domain),
289
+ evidencePins,
290
+ decisions: mergeCaseDecisions(local.decisions, patch.decisions, fallback, pinIds),
291
+ actions,
292
+ assertions,
293
+ manualTrail: mergeCaseManualTrail(local.manualTrail, patch.manualTrail, fallback),
294
+ sightings,
295
+ observedEffects,
296
+ closures,
297
+ branches: mergeCaseInvestigationBranches(local.branches ?? [], patch.branches, fallback, branchReferences),
298
+ tags: normalizeTags([...local.tags, ...patch.tags]),
299
+ notes: unionNotes(local.notes, patch.notes),
300
+ createdAt: patch.createdAt && Date.parse(patch.createdAt) < Date.parse(local.createdAt) ? patch.createdAt : local.createdAt,
301
+ updatedAt: importNewer ? (patch.updatedAt ?? local.updatedAt) : local.updatedAt,
302
+ }, brandProfileReferencesOmitted: brandProfileReferences.omitted };
303
+ }
304
+ function pickFreeId(preferred, domain, used) {
305
+ const wanted = safeId(preferred);
306
+ let id = wanted && !used.has(wanted) ? wanted : deterministicId(domain);
307
+ const base = deterministicId(domain);
308
+ let suffix = 2;
309
+ while (used.has(id))
310
+ id = `${base}-${suffix++}`;
311
+ return id;
312
+ }
313
+ const CASE_EXPORT_ROOT_KEYS = Object.freeze(['version', 'exportedAt', 'cases']);
314
+ function assertOnlyEnvelopeKeys(value, allowed, label) {
315
+ const allowedKeys = new Set(allowed);
316
+ if (Object.keys(value).some((key) => !allowedKeys.has(key))) {
317
+ throw new Error(`The WHOISleuth ${label} contains undeclared envelope fields.`);
318
+ }
319
+ }
320
+ function exactStringList(value, expected) {
321
+ return Array.isArray(value)
322
+ && value.length === expected.length
323
+ && value.every((item, index) => item === expected[index]);
324
+ }
325
+ /**
326
+ * A CLI Case-pack remains browser-importable through its exact top-level Case
327
+ * collection. This projection closes the known pack envelopes before dropping
328
+ * pack-only review and integrity metadata; it does not make an integrity claim.
329
+ * The statically bound CLI verifier remains the exact structure-and-digest
330
+ * verifier for the durable Case-pack version.
331
+ */
332
+ function caseCollectionImportEnvelope(importedRaw) {
333
+ const root = objectRecord(importedRaw);
334
+ const hasPackField = Object.hasOwn(root, 'packet') || Object.hasOwn(root, 'integrity');
335
+ if (!hasPackField) {
336
+ assertOnlyEnvelopeKeys(root, CASE_EXPORT_ROOT_KEYS, 'case export');
337
+ return root;
338
+ }
339
+ assertOnlyEnvelopeKeys(root, CLI_CASE_PACK_ROOT_KEYS, 'CLI Case-pack');
340
+ if (CLI_CASE_PACK_ROOT_KEYS.some((key) => !Object.hasOwn(root, key))) {
341
+ throw new Error('The WHOISleuth CLI Case-pack is missing a required envelope field.');
342
+ }
343
+ const packet = objectRecord(root.packet);
344
+ const integrity = objectRecord(root.integrity);
345
+ const redactionManifest = objectRecord(packet.redactionManifest);
346
+ assertOnlyEnvelopeKeys(packet, CLI_CASE_PACK_PACKET_KEYS, 'CLI Case-pack packet');
347
+ assertOnlyEnvelopeKeys(integrity, CLI_CASE_PACK_INTEGRITY_KEYS, 'CLI Case-pack integrity');
348
+ const rootVersion = root.version;
349
+ const packVersion = packet.version;
350
+ if (Number.isSafeInteger(packVersion) && packVersion < CLI_CASE_PACK_VERSION) {
351
+ throw new Error(`WHOISleuth CLI Case-pack version ${String(packVersion)} is retired. Re-export it as version ${CLI_CASE_PACK_VERSION}; no data was changed.`);
352
+ }
353
+ if (Number.isSafeInteger(packVersion) && packVersion > CLI_CASE_PACK_VERSION) {
354
+ throw new Error(`WHOISleuth CLI Case-pack version ${String(packVersion)} is newer than the supported version ${CLI_CASE_PACK_VERSION}; no data was changed.`);
355
+ }
356
+ if (Number.isSafeInteger(rootVersion) && rootVersion < CASE_SCHEMA_VERSION
357
+ && !CASE_IMPORT_VERSIONS.includes(rootVersion)) {
358
+ throw new Error(`Case schema ${String(rootVersion)} in this CLI Case-pack is not part of the public compatibility boundary; no data was changed.`);
359
+ }
360
+ if (Number.isSafeInteger(rootVersion) && rootVersion > CASE_SCHEMA_VERSION) {
361
+ throw new Error(`Case schema ${String(rootVersion)} in this CLI Case-pack is newer than the supported schema ${CASE_SCHEMA_VERSION}; no data was changed.`);
362
+ }
363
+ assertOnlyEnvelopeKeys(redactionManifest, CLI_CASE_PACK_CURRENT_REDACTION_KEYS, 'CLI Case-pack redaction manifest');
364
+ const reports = packet.reports;
365
+ const cases = root.cases;
366
+ const audience = packet.audience;
367
+ if (packet.schema !== CLI_CASE_PACK_SCHEMA
368
+ || packVersion !== CLI_CASE_PACK_VERSION
369
+ || typeof rootVersion !== 'number'
370
+ || !CASE_IMPORT_VERSIONS.includes(rootVersion)
371
+ || packet.reviewed !== true
372
+ || (audience !== 'internal' && audience !== 'public' && audience !== 'trusted')
373
+ || !Array.isArray(cases)
374
+ || !Array.isArray(reports)
375
+ || reports.length !== cases.length
376
+ || !exactStringList(packet.limitations, CLI_CASE_PACK_LIMITATIONS)
377
+ || !Number.isSafeInteger(redactionManifest.sourceCaseCount)
378
+ || redactionManifest.sourceCaseCount !== cases.length
379
+ || !Number.isSafeInteger(redactionManifest.brandProfileReferencesOmitted)
380
+ || redactionManifest.brandProfileReferencesOmitted < 0
381
+ || integrity.algorithm !== 'SHA-256'
382
+ || integrity.canonicalization !== 'sorted-json-v2'
383
+ || typeof integrity.digestSha256 !== 'string'
384
+ || !/^sha256:[a-f0-9]{64}$/u.test(integrity.digestSha256)
385
+ || typeof root.exportedAt !== 'string'
386
+ || caseTimestampOrNull(root.exportedAt) !== root.exportedAt) {
387
+ throw new Error('The WHOISleuth CLI Case-pack envelope is invalid.');
388
+ }
389
+ for (const reportValue of reports) {
390
+ const report = objectRecord(reportValue);
391
+ assertOnlyEnvelopeKeys(report, rootVersion === CASE_SCHEMA_VERSION ? CLI_CASE_PACK_REPORT_KEYS : CLI_CASE_PACK_PUBLIC_REPORT_KEYS, 'CLI Case-pack report');
392
+ if (report.schema !== CASE_REPORT_SCHEMA
393
+ || !caseReportVersionMatchesCase(rootVersion, report.schemaVersion)
394
+ || report.generatedAt !== root.exportedAt) {
395
+ throw new Error('The WHOISleuth CLI Case-pack report epoch is invalid.');
396
+ }
397
+ }
398
+ const normalised = normalizeCaseStore(root).cases;
399
+ if (normalised.length !== cases.length
400
+ || (rootVersion === CASE_SCHEMA_VERSION
401
+ && canonicalArtifactJsonV2(cases) !== canonicalArtifactJsonV2(normalised))) {
402
+ throw new Error(`The WHOISleuth CLI Case-pack contains a non-canonical schema ${rootVersion} Case collection.`);
403
+ }
404
+ return { version: rootVersion, exportedAt: root.exportedAt, cases };
405
+ }
406
+ /**
407
+ * Merges an imported (already parsed) value into the local cases. Predictable
408
+ * and idempotent: unknown records are skipped, existing domains merge without
409
+ * losing newer local decisions, and new ones are added until the store bound is
410
+ * reached. An imported envelope that declares a schema version newer than we
411
+ * support is rejected up front (before any local data is touched) rather than
412
+ * reinterpreted.
413
+ * @param {CaseRecord[]} localCases
414
+ * @param {unknown} importedRaw
415
+ * @returns {{ cases: CaseRecord[], added: number, updated: number, skipped: number, brandProfileReferencesOmitted: number }}
416
+ */
417
+ export function mergeCases(localCases, importedRaw) {
418
+ assertBoundedJsonStructure(importedRaw, 'Case import', CASE_INPUT_JSON_LIMITS);
419
+ const importedEnvelope = caseCollectionImportEnvelope(importedRaw);
420
+ const importedVersion = parseStoreVersion(importedEnvelope);
421
+ if (importedVersion !== null && Number.isSafeInteger(importedVersion) && importedVersion < CASE_SCHEMA_VERSION
422
+ && !CASE_IMPORT_VERSIONS.includes(importedVersion)) {
423
+ throw new Error(`Case schema ${importedVersion} is not part of the public compatibility boundary. Only public schema 12 migrates to schema ${CASE_SCHEMA_VERSION}; no data was changed.`);
424
+ }
425
+ if (importedVersion !== null && Number.isSafeInteger(importedVersion) && importedVersion > CASE_SCHEMA_VERSION) {
426
+ throw new Error(`Case schema ${importedVersion} is newer than the supported schema ${CASE_SCHEMA_VERSION}; no data was changed.`);
427
+ }
428
+ if (!CASE_IMPORT_VERSIONS.includes(importedVersion)
429
+ || !Array.isArray(importedEnvelope.cases)) {
430
+ throw new Error(`Expected a well-formed WHOISleuth Case export using schema ${CASE_SCHEMA_VERSION}; no data was changed.`);
431
+ }
432
+ if (importedVersion === CASE_SCHEMA_VERSION)
433
+ assertCurrentEvidenceHostnameShape(importedEnvelope);
434
+ const local = normalizeCaseStore(localCases).cases;
435
+ const supportedImportedVersion = importedVersion ?? 0;
436
+ const byDomain = new Map(local.map((item) => [item.domain, item]));
437
+ const usedIds = new Set(local.map((item) => item.id));
438
+ let added = 0;
439
+ let updated = 0;
440
+ const imported = boundedCaseList(importedEnvelope);
441
+ let skipped = imported.omitted;
442
+ let brandProfileReferencesOmitted = 0;
443
+ const fallback = new Date(0).toISOString();
444
+ for (const item of imported.items) {
445
+ const patch = extractImportPatch(item, supportedImportedVersion);
446
+ if (!patch) {
447
+ skipped += 1;
448
+ continue;
449
+ }
450
+ const existing = byDomain.get(patch.domain);
451
+ if (existing) {
452
+ const merged = applyImportPatch(existing, patch);
453
+ byDomain.set(patch.domain, merged.record);
454
+ brandProfileReferencesOmitted += patch.brandProfileReferencesOmitted + merged.brandProfileReferencesOmitted;
455
+ updated += 1;
456
+ }
457
+ else if (byDomain.size < MAX_CASES) {
458
+ const record = caseFromPatch(patch, fallback);
459
+ record.id = pickFreeId(patch.rawId, patch.domain, usedIds);
460
+ usedIds.add(record.id);
461
+ byDomain.set(patch.domain, record);
462
+ brandProfileReferencesOmitted += patch.brandProfileReferencesOmitted;
463
+ added += 1;
464
+ }
465
+ else {
466
+ skipped += 1;
467
+ }
468
+ }
469
+ return {
470
+ cases: normalizeCaseStore([...byDomain.values()]).cases,
471
+ added,
472
+ updated,
473
+ skipped,
474
+ brandProfileReferencesOmitted,
475
+ };
476
+ }
@@ -1,5 +1,5 @@
1
1
  // Stable case-domain facade. Records, migrations, and storage concerns remain
2
2
  // separate internally while callers retain one public contract.
3
- export * from "./case-record-model.js";
4
- export * from "./case-migration-model.js";
5
- export * from "./case-storage-model.js";
3
+ export * from "./case-record-model.mjs";
4
+ export * from "./case-migration-model.mjs";
5
+ export * from "./case-storage-model.mjs";
@@ -1,22 +1,6 @@
1
- import { ANALYST_REVIEW_REASONS } from "../../../../lib/analyst-taxonomy.mjs";
2
- export const CASE_SCHEMA_VERSION = 10;
3
- export const CASE_IMPORT_VERSIONS = [3, 4, 5, 6, 7, 8, 9, CASE_SCHEMA_VERSION];
4
- export const MAX_CASES = 500;
5
- export const MAX_NOTES_PER_CASE = 50;
6
- export const MAX_NOTE_LENGTH = 2000;
7
- export const MAX_TAGS_PER_CASE = 20;
8
- export const MAX_TAG_LENGTH = 40;
9
- export const MAX_DOMAIN_LENGTH = 253;
10
- export const MAX_CASE_IMPORT_BYTES = 2 * 1024 * 1024;
11
- export const MAX_EVIDENCE_SNAPSHOTS_PER_CASE = 25;
12
- export const MAX_EVIDENCE_FACTORS = 20;
13
- export const MAX_EVIDENCE_NAMESERVERS = 12;
14
- export const MAX_EVIDENCE_MUTATIONS = 20;
15
- export const MAX_EVIDENCE_STRING_LENGTH = 200;
16
- export const MAX_EVIDENCE_TITLE_LENGTH = 200;
17
- export const MAX_EVIDENCE_DETAIL_LENGTH = 200;
18
- export const MAX_EVIDENCE_CHANGES = 40;
19
- export const MAX_CASE_STORE_BYTES = 4 * 1024 * 1024;
1
+ import { ANALYST_REVIEW_REASONS } from "../../lib/analyst-taxonomy.mjs";
2
+ import { CASE_IMPORT_VERSIONS, CASE_SCHEMA_VERSION, PUBLIC_CASE_SCHEMA_VERSION, MAX_CASE_BRAND_PROFILE_ID_CANDIDATES, MAX_CASE_BRAND_PROFILE_IDS, MAX_CASE_IMPORT_BYTES, MAX_CASE_STORE_BYTES, MAX_CASES, MAX_DOMAIN_LENGTH, MAX_EVIDENCE_CHANGES, MAX_EVIDENCE_DETAIL_LENGTH, MAX_EVIDENCE_FACTORS, MAX_EVIDENCE_MUTATIONS, MAX_EVIDENCE_NAMESERVERS, MAX_EVIDENCE_SNAPSHOTS_PER_CASE, MAX_EVIDENCE_STRING_LENGTH, MAX_EVIDENCE_TITLE_LENGTH, MAX_NOTE_LENGTH, MAX_NOTES_PER_CASE, MAX_TAG_LENGTH, MAX_TAGS_PER_CASE, } from "../contracts/case-portability.mjs";
3
+ export { CASE_IMPORT_VERSIONS, CASE_SCHEMA_VERSION, PUBLIC_CASE_SCHEMA_VERSION, MAX_CASE_BRAND_PROFILE_ID_CANDIDATES, MAX_CASE_BRAND_PROFILE_IDS, MAX_CASE_IMPORT_BYTES, MAX_CASE_STORE_BYTES, MAX_CASES, MAX_DOMAIN_LENGTH, MAX_EVIDENCE_CHANGES, MAX_EVIDENCE_DETAIL_LENGTH, MAX_EVIDENCE_FACTORS, MAX_EVIDENCE_MUTATIONS, MAX_EVIDENCE_NAMESERVERS, MAX_EVIDENCE_SNAPSHOTS_PER_CASE, MAX_EVIDENCE_STRING_LENGTH, MAX_EVIDENCE_TITLE_LENGTH, MAX_NOTE_LENGTH, MAX_NOTES_PER_CASE, MAX_TAG_LENGTH, MAX_TAGS_PER_CASE, };
20
4
  export const CASE_STATUSES = [
21
5
  { value: 'new', label: 'New' },
22
6
  { value: 'reviewing', label: 'Reviewing' },
@@ -1,6 +1,8 @@
1
1
  // Pure, framework-neutral analyst-case records, evidence histories, bounded
2
2
  // record normalization, and analyst updates.
3
- import { CASE_DISPOSITIONS, CASE_REVIEW_REASONS, CASE_SOURCES, CASE_STATUSES, DEFAULT_DISPOSITION, DEFAULT_SOURCE, DEFAULT_STATUS, EVIDENCE_SOURCES, MAX_DOMAIN_LENGTH, MAX_NOTES_PER_CASE, MAX_NOTE_LENGTH, MAX_TAGS_PER_CASE, MAX_TAG_LENGTH, } from "./case-record-contracts.js";
3
+ import { CASE_DISPOSITIONS, CASE_SCHEMA_VERSION, CASE_REVIEW_REASONS, CASE_SOURCES, CASE_STATUSES, DEFAULT_DISPOSITION, DEFAULT_SOURCE, DEFAULT_STATUS, EVIDENCE_SOURCES, MAX_DOMAIN_LENGTH, MAX_NOTES_PER_CASE, MAX_NOTE_LENGTH, MAX_TAGS_PER_CASE, MAX_TAG_LENGTH, } from "./case-record-contracts.mjs";
4
+ import { normalizeExplicitIsoTimestamp, normalizeLegacyIsoTimestamp } from "../evidence/observation.mjs";
5
+ import { canonicalRegistrableDomain } from "../../lib/registrable-domain.mjs";
4
6
  // Forward-version policy (two distinct guarantees):
5
7
  // - A locally-stored envelope that declares a version greater than this is
6
8
  // still read best-effort on load (known fields kept, unknown dropped), but
@@ -47,6 +49,11 @@ export const REGISTERED_LIKE = new Set([
47
49
  // URL/DOM/query-string-safe id shape. UUIDs satisfy this; anything else is
48
50
  // treated as untrusted and deterministically repaired.
49
51
  const SAFE_ID_RE = /^[A-Za-z0-9_-]{1,64}$/;
52
+ // Unicode U-labels can be substantially longer than their canonical A-label
53
+ // representation (for example, decomposed combining sequences). Keep the raw
54
+ // work bound finite, then enforce DNS' 253/63-byte limits on the URL parser's
55
+ // canonical ASCII hostname below.
56
+ export const MAX_EVIDENCE_DOMAIN_INPUT_LENGTH = 1024;
50
57
  export function objectRecord(value) {
51
58
  return value && typeof value === 'object' && !Array.isArray(value)
52
59
  ? value
@@ -89,47 +96,56 @@ export function hashString(value) {
89
96
  export function deterministicId(domain) {
90
97
  return `c-${hashString(domain)}`;
91
98
  }
99
+ export { normalizeDomain } from "../evidence/domain-name.mjs";
92
100
  /**
93
- * Strict, canonical domain normalization. Parses through the WHATWG URL host
94
- * (which strips scheme/path/port/userinfo and applies IDNA/punycode so Unicode
95
- * and its punycode form collapse to one value), lowercases, drops a single
96
- * terminal root dot, and validates LDH hostname labels. Rejects IPs, ASNs,
97
- * whitespace/control characters, underscores, empty/overlong/hyphen-edged
98
- * labels, and undotted names. Returns '' for anything unusable.
99
- * @param {unknown} value
100
- * @returns {string}
101
+ * Canonicalizes a hostname supplied as source evidence without applying the
102
+ * analyst-input conveniences in normalizeDomain. URI syntax, credentials,
103
+ * ports, paths, queries, fragments, separators, whitespace, and controls make
104
+ * the observation invalid rather than being silently discarded.
101
105
  */
102
- export function normalizeDomain(value) {
103
- const raw = String(value == null ? '' : value).trim();
104
- if (!raw || /[\s\x00-\x1f\x7f]/.test(raw))
106
+ export function normalizeEvidenceDomain(value) {
107
+ if (typeof value !== 'string' || !value || value !== value.trim())
108
+ return '';
109
+ if (value.length > MAX_EVIDENCE_DOMAIN_INPUT_LENGTH)
110
+ return '';
111
+ if (/[\s\x00-\x1f\x7f%@/:?#\\]/u.test(value))
105
112
  return '';
106
- let hostname;
113
+ const withoutRootDot = value.endsWith('.') ? value.slice(0, -1) : value;
114
+ if (!withoutRootDot || withoutRootDot.endsWith('.'))
115
+ return '';
116
+ let hostname = '';
107
117
  try {
108
- const hasScheme = /^[a-z][a-z0-9+.-]*:\/\//i.test(raw);
109
- hostname = new URL(hasScheme ? raw : `http://${raw}`).hostname;
118
+ hostname = new URL(`http://${withoutRootDot}`).hostname.toLowerCase();
110
119
  }
111
120
  catch {
112
121
  return '';
113
122
  }
114
- hostname = hostname.toLowerCase().replace(/\.$/, '');
115
- if (!hostname || hostname.length > MAX_DOMAIN_LENGTH)
116
- return '';
117
- // A leftover ':' or '[' means a port/IPv6 the host parser preserved.
118
- if (hostname.includes(':') || hostname.startsWith('['))
123
+ if (!hostname || hostname.length > MAX_DOMAIN_LENGTH || hostname !== hostname.replace(/\.$/u, ''))
119
124
  return '';
120
125
  const labels = hostname.split('.');
121
126
  if (labels.length < 2)
122
127
  return '';
123
- const labelPattern = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
124
- for (const label of labels) {
125
- if (!label || label.length > 63 || !labelPattern.test(label))
126
- return '';
127
- }
128
- // An all-numeric final label is an IPv4 address, never a hostname TLD.
129
- if (/^[0-9]+$/.test(labels.at(-1) ?? ''))
128
+ const labelPattern = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/u;
129
+ if (labels.some((label) => !label || label.length > 63 || !labelPattern.test(label)))
130
+ return '';
131
+ if (/^[0-9]+$/u.test(labels.at(-1) ?? ''))
130
132
  return '';
131
133
  return hostname;
132
134
  }
135
+ /**
136
+ * Retains an exact submitted hostname only when it is strict evidence input
137
+ * for the Case's canonical registrable identity. This boundary deliberately
138
+ * does not repair URI syntax or infer a hostname from other evidence.
139
+ */
140
+ export function normalizeEvidenceHostnameForCase(value, caseDomain) {
141
+ const hostname = normalizeEvidenceDomain(value);
142
+ const canonicalCase = normalizeEvidenceDomain(caseDomain);
143
+ if (!hostname || !canonicalCase)
144
+ return null;
145
+ if (canonicalRegistrableDomain(canonicalCase) !== canonicalCase)
146
+ return null;
147
+ return canonicalRegistrableDomain(hostname) === canonicalCase ? hostname : null;
148
+ }
133
149
  export function normalizeStatus(value) {
134
150
  return typeof value === 'string' && STATUS_VALUES.has(value) ? value : DEFAULT_STATUS;
135
151
  }
@@ -151,6 +167,17 @@ export function isoOrNull(value) {
151
167
  }
152
168
  return null;
153
169
  }
170
+ /** Canonical instant for versioned case data; current schemas require a zone. */
171
+ export function caseTimestampOrNull(value, sourceVersion) {
172
+ const explicit = normalizeExplicitIsoTimestamp(value);
173
+ if (explicit)
174
+ return explicit;
175
+ return sourceVersion !== undefined
176
+ && sourceVersion !== null
177
+ && sourceVersion < CASE_SCHEMA_VERSION
178
+ ? normalizeLegacyIsoTimestamp(value)
179
+ : null;
180
+ }
154
181
  export function isoOrNow(value, fallback) {
155
182
  return isoOrNull(value) || fallback;
156
183
  }
@@ -194,12 +221,12 @@ export function normalizeNoteBody(body) {
194
221
  * @param {string | null} fallback
195
222
  * @returns {CaseNote | null}
196
223
  */
197
- function normalizeNote(raw, fallback) {
224
+ function normalizeNote(raw, fallback, sourceVersion) {
198
225
  const record = objectRecord(raw);
199
226
  const body = normalizeNoteBody(record.body);
200
227
  if (!body)
201
228
  return null;
202
- const createdAt = isoOrNull(record.createdAt) || fallback;
229
+ const createdAt = caseTimestampOrNull(record.createdAt, sourceVersion) || fallback;
203
230
  if (!createdAt)
204
231
  return null;
205
232
  return {
@@ -215,13 +242,13 @@ function normalizeNote(raw, fallback) {
215
242
  * updatedAt for imports, never the current time)
216
243
  * @returns {CaseNote[]}
217
244
  */
218
- export function normalizeNotes(value, fallback) {
245
+ export function normalizeNotes(value, fallback, sourceVersion) {
219
246
  if (!Array.isArray(value))
220
247
  return [];
221
248
  const seen = new Set();
222
249
  const notes = [];
223
250
  for (const raw of value) {
224
- const note = normalizeNote(raw, fallback);
251
+ const note = normalizeNote(raw, fallback, sourceVersion);
225
252
  if (!note || seen.has(note.id))
226
253
  continue;
227
254
  seen.add(note.id);