@slicedearth/whoisleuth-cli 2.2.0 → 2.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (282) hide show
  1. package/NOTICE +6 -0
  2. package/README.md +20 -15
  3. package/bin/whoisleuth.mjs +8 -1
  4. package/cli/archive-content-digest.mjs +14 -0
  5. package/cli/archive-inspect.mjs +20 -53
  6. package/cli/arguments.mjs +454 -1748
  7. package/cli/artifact-validation/case-response.mjs +66 -35
  8. package/cli/artifact-validation/investigation-capsule.mjs +52 -31
  9. package/cli/artifact-validation/lookup-evidence.mjs +12 -6
  10. package/cli/artifact-validation/signed-review.mjs +112 -72
  11. package/cli/artifact-validation/structure-primitives.mjs +1 -105
  12. package/cli/artifact-verify.mjs +90 -24
  13. package/cli/assurance-command-runner.mjs +39 -4
  14. package/cli/bagit-folder.mjs +57 -0
  15. package/cli/bagit-review.mjs +23 -0
  16. package/cli/bounded-json.mjs +1 -1
  17. package/cli/bulk-checkpoint.mjs +34 -3
  18. package/cli/bulk-command-runner.mjs +2 -0
  19. package/cli/bulk-output.mjs +29 -3
  20. package/cli/case-command.mjs +195 -0
  21. package/cli/case-pack.mjs +66 -111
  22. package/cli/command-argument-grammar.mjs +215 -0
  23. package/cli/command-reference.mjs +342 -285
  24. package/cli/completion.mjs +303 -80
  25. package/cli/config-profile.mjs +7 -5
  26. package/cli/ct-event-intake.mjs +12 -12
  27. package/cli/discover.mjs +4 -3
  28. package/cli/discovery-observation-snapshot.mjs +59 -18
  29. package/cli/discovery-scan-command-runner.mjs +21 -0
  30. package/cli/discovery-scan.mjs +13 -4
  31. package/cli/discovery-workflow.mjs +1 -1
  32. package/cli/domain-control-monitor.mjs +50 -20
  33. package/cli/domain-control-observations.mjs +149 -102
  34. package/cli/errors.mjs +33 -1
  35. package/cli/evidence-command-runner.mjs +16 -5
  36. package/cli/evidence-signing.mjs +2 -2
  37. package/cli/external-observation-mapping.mjs +7 -8
  38. package/cli/formatters/terminal-lookup.mjs +18 -46
  39. package/cli/formatters/terminal-shared.mjs +5 -3
  40. package/cli/formatters/terminal-technology.mjs +114 -0
  41. package/cli/inline-command-families.mjs +2 -0
  42. package/cli/input.mjs +116 -0
  43. package/cli/interactive-launcher.mjs +2 -160
  44. package/cli/interchange-report.mjs +18 -8
  45. package/cli/investigation-artifacts.mjs +176 -0
  46. package/cli/investigation-checkpoint.mjs +10 -0
  47. package/cli/investigation-folder.mjs +114 -0
  48. package/cli/investigation-manifest.mjs +1 -112
  49. package/cli/investigation-package-review.mjs +103 -0
  50. package/cli/investigation-plan.mjs +32 -19
  51. package/cli/investigation-run.mjs +188 -33
  52. package/cli/local-document-checkpoint.mjs +170 -0
  53. package/cli/lookalike-calibration.mjs +7 -2
  54. package/cli/lookup-brief.mjs +4 -2
  55. package/cli/lookup-browser-view.mjs +1 -1
  56. package/cli/lookup-browser.mjs +5 -12
  57. package/cli/lookup-command-runner.mjs +17 -6
  58. package/cli/lookup-diff.mjs +37 -3
  59. package/cli/lookup-plan.mjs +11 -5
  60. package/cli/lookup-reconcile.mjs +10 -8
  61. package/cli/mail-header-review.mjs +467 -0
  62. package/cli/mail-review.mjs +66 -14
  63. package/cli/manual.mjs +1 -0
  64. package/cli/network-command-runner.mjs +2 -0
  65. package/cli/output-file.mjs +31 -5
  66. package/cli/registry-doctor.mjs +3 -1
  67. package/cli/registry-support.mjs +1 -1
  68. package/cli/retained-artifact-diff.mjs +2 -2
  69. package/cli/review-command-runner.mjs +70 -0
  70. package/cli/risk-calibration.mjs +19 -17
  71. package/cli/runner.mjs +62 -7
  72. package/cli/sharing-review.mjs +21 -34
  73. package/cli/signer-trust.mjs +104 -0
  74. package/cli/source-reliability.mjs +74 -83
  75. package/cli/terminal-input.mjs +168 -0
  76. package/cli/workflow-command-runner.mjs +68 -13
  77. package/docs/cli-reference.md +77 -24
  78. package/docs/cli.md +300 -25
  79. package/lib/abort.mjs +4 -1
  80. package/lib/availability.mjs +161 -112
  81. package/lib/bounded-file.mjs +3 -1
  82. package/lib/bounded-html-document.mjs +125 -0
  83. package/lib/bounded-json.mjs +51 -8
  84. package/lib/browser-library-profile.mjs +42 -51
  85. package/lib/browser-library-worker.mjs +112 -0
  86. package/lib/client-behavior-profile.mjs +3 -3
  87. package/lib/ct-search.mjs +11 -4
  88. package/lib/dns-convergence-review.mjs +1 -0
  89. package/lib/dns-delegation-health.mjs +24 -14
  90. package/lib/dns-inheritance-review.mjs +217 -0
  91. package/lib/dns-intelligence.mjs +73 -26
  92. package/lib/dns-mx.mjs +1 -1
  93. package/lib/dnssec-chain-validation.mjs +6 -0
  94. package/lib/dnssec-evidence-validation.mjs +11 -3
  95. package/lib/domain-change-packet.mjs +49 -16
  96. package/lib/domain-change-review.mjs +14 -8
  97. package/lib/domain-control-flight-recorder.mjs +221 -117
  98. package/lib/domain-control-manifest.mjs +129 -69
  99. package/lib/domain-posture-analysis.mjs +20 -7
  100. package/lib/domain-posture-parsers.mjs +201 -8
  101. package/lib/domain-posture.mjs +147 -29
  102. package/lib/evidence-export-privacy.mjs +561 -0
  103. package/lib/evidence-export.mjs +11 -543
  104. package/lib/evidence-report.mjs +5 -3
  105. package/lib/favicon.mjs +117 -41
  106. package/lib/generated/cisa-kev-catalog.mjs +12 -4
  107. package/lib/generated/registrar-standing-catalogue.mjs +7 -7
  108. package/lib/generated/retire-browser-catalog.mjs +2335 -940
  109. package/lib/hostname.mjs +2 -38
  110. package/lib/html-signals.mjs +111 -108
  111. package/lib/http-evidence-bounds.mjs +2 -1
  112. package/lib/http-intelligence.mjs +39 -26
  113. package/lib/local-geoip-evidence.mjs +19 -3
  114. package/lib/lookup-cache.mjs +34 -11
  115. package/lib/lookup-child-profile-contract.mjs +24 -15
  116. package/lib/lookup-diagnostics.mjs +24 -1
  117. package/lib/lookup-progress.mjs +30 -13
  118. package/lib/lookup-response-contract.mjs +1048 -0
  119. package/lib/lookup-response.mjs +8 -10
  120. package/lib/lookup-source-progress.mjs +40 -14
  121. package/lib/lookup.mjs +48 -27
  122. package/lib/nameserver-preflight-review.mjs +1 -0
  123. package/lib/observed-network-context.mjs +43 -27
  124. package/lib/outbound-request-bounds.mjs +4 -1
  125. package/lib/page-fingerprints.mjs +69 -133
  126. package/lib/page-role-profile.mjs +12 -8
  127. package/lib/perceptual-hash.mjs +43 -14
  128. package/lib/rdap-bootstrap.mjs +72 -22
  129. package/lib/rdap-capabilities.mjs +1 -1
  130. package/lib/rdap-client.mjs +15 -7
  131. package/lib/rdap-normalization.mjs +33 -18
  132. package/lib/rdap-search-workbench.mjs +1 -1
  133. package/lib/rdap-transport.mjs +47 -20
  134. package/lib/rdap-types.mjs +2 -1
  135. package/lib/rdap.mjs +18 -3
  136. package/lib/registrable-domain.mjs +21 -1
  137. package/lib/registrar-standing-contract.mjs +1 -0
  138. package/lib/registry-capabilities.mjs +5 -1
  139. package/lib/registry-capability-catalogue.mjs +2 -1
  140. package/lib/registry-dates.mjs +2 -190
  141. package/lib/registry-insights.mjs +85 -34
  142. package/lib/response-policy.mjs +86 -48
  143. package/lib/risk-calibration-summary.mjs +18 -6
  144. package/lib/rpki-evidence.mjs +8 -5
  145. package/lib/safe-fetch.mjs +54 -47
  146. package/lib/security-txt.mjs +42 -7
  147. package/lib/service-binding-dns.mjs +8 -0
  148. package/lib/smtp-transport-review.mjs +27 -15
  149. package/lib/sslbl-certificates.generated.mjs +136 -135
  150. package/lib/static-html-analysis.mjs +178 -151
  151. package/lib/structured-data-identity.mjs +1 -0
  152. package/lib/technology-markup.mjs +100 -0
  153. package/lib/threat-intelligence-manifests.mjs +11 -139
  154. package/lib/threat-intelligence-runtime.mjs +24 -52
  155. package/lib/threat-intelligence-targets.mjs +6 -34
  156. package/lib/threat-intelligence-types.mjs +39 -0
  157. package/lib/typosquat-generator.mjs +19 -15
  158. package/lib/urlhaus-intelligence.mjs +8 -3
  159. package/lib/urlscan-intelligence.mjs +1 -1
  160. package/lib/website-security-posture.mjs +30 -6
  161. package/lib/website-technology.mjs +153 -45
  162. package/lib/whois-authority.mjs +14 -2
  163. package/lib/whois-chain.mjs +9 -2
  164. package/lib/whois-common-formats.mjs +5 -5
  165. package/lib/whois-contracts.mjs +2 -1
  166. package/lib/whois-dialects.mjs +2 -2
  167. package/lib/whois-parser-support.mjs +8 -1
  168. package/lib/whois-transport.mjs +23 -7
  169. package/lib/zone-intent-review.mjs +16 -6
  170. package/package.json +2 -1
  171. package/packages/cases/case-attachment-model.mjs +83 -0
  172. package/packages/cases/case-evidence-model.mjs +156 -62
  173. package/packages/cases/case-export-input.mjs +53 -0
  174. package/packages/cases/case-migration-model.mjs +127 -44
  175. package/packages/cases/case-model.mjs +1 -0
  176. package/packages/cases/case-recheck-model.mjs +91 -0
  177. package/packages/cases/case-record-contracts.mjs +5 -20
  178. package/packages/cases/case-record-core.mjs +12 -22
  179. package/packages/cases/case-record-decisions.mjs +72 -0
  180. package/packages/cases/case-record-model.mjs +1 -0
  181. package/packages/cases/case-record-operations.mjs +95 -31
  182. package/packages/cases/case-record-projection.mjs +221 -0
  183. package/packages/cases/case-report.mjs +70 -54
  184. package/packages/cases/case-response-actions.mjs +654 -0
  185. package/packages/cases/case-response-model.mjs +64 -1115
  186. package/packages/cases/case-response-outcomes.mjs +317 -0
  187. package/packages/cases/case-response-packet-render.mjs +153 -0
  188. package/packages/cases/case-response-packet-types.mjs +1 -0
  189. package/packages/cases/case-response-packet-vocabulary.mjs +48 -0
  190. package/packages/cases/case-response-packet.mjs +74 -572
  191. package/packages/cases/case-response-records.mjs +94 -0
  192. package/packages/cases/case-response-review-inputs.mjs +371 -0
  193. package/packages/cases/case-response-values.mjs +85 -0
  194. package/packages/cases/case-review-package.mjs +45 -0
  195. package/packages/cases/case-selection.mjs +46 -0
  196. package/packages/cases/case-storage-model.mjs +31 -2
  197. package/packages/cases/response-route-freshness.mjs +16 -0
  198. package/packages/comparison/capture-context.mjs +61 -0
  199. package/packages/comparison/comparison-ledger-bulk.mjs +13 -11
  200. package/packages/comparison/page-similarity.mjs +5 -2
  201. package/packages/comparison/relationship-evidence.mjs +85 -8
  202. package/packages/comparison/relationship-provenance.mjs +93 -0
  203. package/packages/contracts/analyst-interchange.mjs +216 -0
  204. package/packages/contracts/analyst-review-state-contract.mjs +11 -0
  205. package/packages/contracts/analyst-review-state.mjs +5 -4
  206. package/packages/contracts/case-portability.mjs +366 -83
  207. package/packages/contracts/case-views-contract.mjs +12 -0
  208. package/packages/contracts/case-views.mjs +26 -0
  209. package/packages/contracts/cli-command-semantics.mjs +94 -0
  210. package/packages/contracts/cli-lookup.mjs +0 -1
  211. package/packages/contracts/domain-control-flight-recorder.mjs +80 -42
  212. package/packages/contracts/domain-control-manifest.mjs +127 -73
  213. package/packages/contracts/domain-control-monitor.mjs +48 -45
  214. package/packages/contracts/domain-control-review.mjs +179 -121
  215. package/packages/contracts/domain-name.mjs +21 -0
  216. package/packages/contracts/external-observation-interchange.mjs +2 -1
  217. package/packages/contracts/extracted-domain-lifecycle.mjs +11 -32
  218. package/packages/contracts/http-url.mjs +2 -0
  219. package/packages/contracts/investigation-package-limits.mjs +24 -0
  220. package/packages/contracts/investigation-portability.mjs +56 -27
  221. package/packages/contracts/investigation-run.mjs +16 -0
  222. package/packages/contracts/ip-address.mjs +34 -0
  223. package/packages/contracts/lookup-evidence.mjs +35 -0
  224. package/packages/contracts/offline-comparison.mjs +1 -2
  225. package/packages/contracts/page-fingerprints.mjs +15 -0
  226. package/packages/contracts/risk-calibration.mjs +15 -11
  227. package/packages/contracts/schema-compatibility.mjs +1 -1
  228. package/packages/contracts/schema-lifecycle.mjs +83 -153
  229. package/packages/contracts/selected-file-limits.mjs +6 -0
  230. package/packages/contracts/vulnerability-identifiers.mjs +5 -0
  231. package/packages/contracts/web-capture.mjs +10 -0
  232. package/packages/contracts/workspace-portability.mjs +183 -211
  233. package/packages/evidence/artifact-integrity.mjs +8 -2
  234. package/packages/evidence/artifact-structure.mjs +105 -0
  235. package/packages/evidence/decision-fact.mjs +4 -1
  236. package/packages/evidence/domain-control-runtime.mjs +201 -33
  237. package/packages/evidence/domain-posture-context.mjs +79 -0
  238. package/packages/evidence/image-regions.mjs +56 -0
  239. package/packages/evidence/latest-observations.mjs +28 -0
  240. package/packages/evidence/lookup-target.mjs +104 -0
  241. package/packages/evidence/observation.mjs +16 -1
  242. package/packages/evidence/passphrase-encryption.mjs +44 -0
  243. package/packages/evidence/record-identity.mjs +72 -0
  244. package/packages/evidence/registry-dates.mjs +190 -0
  245. package/packages/evidence/retained-file.mjs +69 -0
  246. package/packages/evidence/review-age.mjs +14 -0
  247. package/packages/interchange/bagit.mjs +342 -0
  248. package/packages/interchange/bounded-zip-extraction.mjs +278 -0
  249. package/packages/interchange/crc32.mjs +17 -0
  250. package/packages/interchange/external-findings-import.mjs +92 -51
  251. package/packages/interchange/web-capture-import.mjs +382 -0
  252. package/packages/investigation/bulk-mail-exposure.mjs +2 -1
  253. package/packages/investigation/investigation-bagit.mjs +14 -0
  254. package/packages/investigation/investigation-capsule.mjs +33 -5
  255. package/packages/investigation/investigation-manifest.mjs +200 -0
  256. package/packages/investigation/investigation-package-crypto.mjs +98 -0
  257. package/packages/investigation/investigation-package.mjs +200 -0
  258. package/packages/investigation/lookup-asset-graph-inputs.mjs +38 -0
  259. package/packages/investigation/lookup-asset-graph.mjs +193 -165
  260. package/packages/investigation/lookup-claim-readiness.mjs +18 -0
  261. package/packages/investigation/lookup-investigation-brief.mjs +3 -3
  262. package/packages/investigation/lookup-task-guidance.mjs +58 -0
  263. package/packages/monitoring/analyst-review-state.mjs +38 -90
  264. package/packages/workspace/brand-profile-model.mjs +276 -22
  265. package/packages/workspace/bulk-columns.mjs +17 -0
  266. package/packages/workspace/bulk-review-model.mjs +23 -11
  267. package/packages/workspace/bulk-session-model.mjs +100 -24
  268. package/packages/workspace/bulk-sort.mjs +1 -0
  269. package/packages/workspace/case-views.mjs +102 -0
  270. package/packages/workspace/detection-rule-model.mjs +70 -22
  271. package/packages/workspace/hostile-input.mjs +29 -8
  272. package/packages/workspace/investigation-template-model.mjs +19 -4
  273. package/packages/workspace/page-baseline.mjs +18 -15
  274. package/packages/workspace/relationship-observation-model.mjs +41 -23
  275. package/packages/workspace/watchlist-history.mjs +2 -2
  276. package/packages/workspace/watchlist-store.mjs +8 -2
  277. package/packages/workspace/website-snapshot-model.mjs +164 -26
  278. package/packages/workspace/workspace-archive-crypto.mjs +41 -61
  279. package/packages/workspace/workspace-archive.mjs +87 -21
  280. package/third-party-notices.txt +29 -2
  281. package/cli/evidence-command-arguments.mjs +0 -156
  282. package/packages/investigation/bulk-review-export.mjs +0 -63
@@ -0,0 +1,53 @@
1
+ import { boundedJsonLimitsForBytes, parseBoundedJsonObject } from "../../lib/bounded-json.mjs";
2
+ import { canonicalArtifactJsonV2 } from "../evidence/artifact-integrity.mjs";
3
+ import { normalizeExplicitIsoTimestamp } from "../evidence/observation.mjs";
4
+ import { CASE_SCHEMA_VERSION, CLI_CASE_PACK_INPUT_CASE_VERSIONS, MAX_CASE_STORE_BYTES, MAX_EDITABLE_CASE_INPUT_BYTES, PUBLISHED_V2_3_CASE_SCHEMA_VERSION } from "../contracts/case-portability.mjs";
5
+ import { normalizeCaseStore } from "./case-migration-model.mjs";
6
+ import { serializeCaseStore } from "./case-storage-model.mjs";
7
+ /** Historical wire shape remains independent of current in-memory defaults. */
8
+ export function canonicalCaseExportProjection(cases, caseVersion) {
9
+ if (caseVersion !== PUBLISHED_V2_3_CASE_SCHEMA_VERSION)
10
+ return [...cases];
11
+ return cases.map((value) => {
12
+ if ([...value.evidencePins, ...value.sightings].some((item) => item.observedAt === null)) {
13
+ throw new TypeError('The published Case 15 format requires a known observation time on each retained pin and sighting.');
14
+ }
15
+ const { title: _title, ...published } = value;
16
+ return {
17
+ ...published,
18
+ sightings: [...value.sightings].sort((left, right) => Date.parse(left.observedAt) - Date.parse(right.observedAt)
19
+ || (left.id < right.id ? -1 : left.id > right.id ? 1 : 0)),
20
+ };
21
+ });
22
+ }
23
+ /** Editing never silently repairs, drops or truncates an imported record. */
24
+ export function readEditableCaseExport(input) {
25
+ const root = parseBoundedJsonObject(input, {
26
+ label: 'Case file', maximumBytes: MAX_EDITABLE_CASE_INPUT_BYTES,
27
+ limits: boundedJsonLimitsForBytes(MAX_CASE_STORE_BYTES),
28
+ });
29
+ if (Object.keys(root).some(key => !['version', 'exportedAt', 'cases'].includes(key))) {
30
+ throw new TypeError('Case editing requires an ordinary Case export, not a response packet or an envelope with additional fields.');
31
+ }
32
+ if (!CLI_CASE_PACK_INPUT_CASE_VERSIONS.some(version => version === root.version) || !Array.isArray(root.cases)) {
33
+ throw new TypeError(`Case editing reads exact schemas ${CLI_CASE_PACK_INPUT_CASE_VERSIONS.join(', ')} and writes schema ${CASE_SCHEMA_VERSION}. Re-export other supported historical formats through the ordinary import workflow first.`);
34
+ }
35
+ if (Object.hasOwn(root, 'exportedAt') && !normalizeExplicitIsoTimestamp(root.exportedAt)) {
36
+ throw new TypeError('The Case export time is invalid.');
37
+ }
38
+ const cases = normalizeCaseStore(root).cases;
39
+ const projected = canonicalCaseExportProjection(cases, root.version);
40
+ const byId = new Map(projected.map(record => [record.id, record]));
41
+ const seen = new Set();
42
+ if (cases.length !== root.cases.length || root.cases.some(value => {
43
+ if (!value || typeof value !== 'object' || typeof value.id !== 'string' || seen.has(value.id))
44
+ return true;
45
+ seen.add(value.id);
46
+ return canonicalArtifactJsonV2(value) !== canonicalArtifactJsonV2(byId.get(value.id));
47
+ }))
48
+ throw new TypeError('The Case file would lose, repair or truncate retained data during normalisation. Nothing was changed.');
49
+ if (new TextEncoder().encode(serializeCaseStore(cases)).byteLength > MAX_CASE_STORE_BYTES) {
50
+ throw new TypeError('The Case file exceeds the canonical store byte budget. Select fewer Cases; no retained evidence was pruned.');
51
+ }
52
+ return cases;
53
+ }
@@ -1,13 +1,11 @@
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, PUBLIC_CASE_SCHEMA_VERSION, 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";
1
+ import { CASE_IMPORT_VERSIONS, CASE_SCHEMA_VERSION, DEFAULT_DISPOSITION, DEFAULT_SOURCE, DEFAULT_STATUS, MAX_CASES, MAX_NOTES_PER_CASE, deterministicId, caseTimestampOrNull, normalizeCase, normalizeDomain, normalizeCaseObjective, normalizeEvidenceHistory, normalizeNotes, normalizeReviewReasonCode, normalizeTags, objectRecord, safeId, isValidDisposition, isValidSource, isValidStatus, } from "./case-record-model.mjs";
2
+ import { CASE_REPORT_SCHEMA, PUBLIC_CASE_SCHEMA_VERSION, PUBLISHED_V2_3_CASE_SCHEMA_VERSION, INCIDENT_CASE_SCHEMA_VERSION, 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
3
  import { canonicalArtifactJsonV2 } from "../evidence/artifact-integrity.mjs";
4
+ import { readCaseAttachments, mergeCaseAttachments } from "./case-attachment-model.mjs";
4
5
  import { assertBoundedJsonStructure } from "../../lib/bounded-json.mjs";
5
6
  import { inspectCaseBrandProfileIds, unionCaseBrandProfileIds, } from "./case-brand-profile-references.mjs";
6
7
  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));
8
+ import { buildCaseClosureLinkContext, mergeCaseActions, mergeCaseClosureHistories, mergeCaseObservedEffectHistories, MAX_CASE_ASSERTIONS, MAX_CASE_DECISIONS, MAX_CASE_EVIDENCE_PINS, MAX_CASE_MANUAL_TRAIL_EVENTS, MAX_CASE_SIGHTINGS, normalizeCaseActions, normalizeCaseAssertions, normalizeCaseClosureHistory, normalizeCaseDecisions, normalizeCaseEvidencePins, normalizeCaseManualTrail, normalizeCaseObservedEffectHistory, normalizeCaseSightings, } from "./case-response-model.mjs";
11
9
  export const MAX_CASE_INPUT_RECORDS = 2_000;
12
10
  function compareCodeUnits(left, right) {
13
11
  return left < right ? -1 : left > right ? 1 : 0;
@@ -35,8 +33,8 @@ function assignUniqueIds(cases) {
35
33
  }
36
34
  /**
37
35
  * 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,
36
+ * internal bare array, drops malformed records, preserves distinct current
37
+ * Case identities, caps to MAX_CASES by recency,
40
38
  * and guarantees globally unique safe ids. Retired, future, accessor-bearing,
41
39
  * sparse, or otherwise non-JSON envelopes fail before normalisation.
42
40
  * @param {unknown} raw
@@ -51,46 +49,64 @@ export function normalizeCaseStore(raw) {
51
49
  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
50
  }
53
51
  if (!CASE_IMPORT_VERSIONS.includes(sourceVersion) && sourceVersion < CASE_SCHEMA_VERSION) {
54
- throw new TypeError(`Case schema ${sourceVersion} is not part of the supported compatibility boundary. Exact published schemas 12 and 13 migrate to schema ${CASE_SCHEMA_VERSION}; no data was changed.`);
52
+ throw new TypeError(`Case schema ${sourceVersion} is not part of the supported compatibility boundary. Exact published schemas ${CASE_IMPORT_VERSIONS.filter((version) => version < CASE_SCHEMA_VERSION).join(', ')} migrate to schema ${CASE_SCHEMA_VERSION}; no data was changed.`);
55
53
  }
56
54
  if (sourceVersion > CASE_SCHEMA_VERSION) {
57
55
  throw new TypeError(`Case schema ${sourceVersion} is newer than the supported schema ${CASE_SCHEMA_VERSION}; no data was changed.`);
58
56
  }
59
- if (sourceVersion === CASE_SCHEMA_VERSION) {
60
- assertCurrentCaseShape(raw);
57
+ if (sourceVersion >= PUBLISHED_V2_3_CASE_SCHEMA_VERSION) {
58
+ assertModernCaseShape(raw, sourceVersion);
61
59
  }
62
60
  }
63
- const byDomain = new Map();
61
+ const byIdentity = new Map();
64
62
  for (const item of boundedCaseList(raw).items) {
65
63
  const normalized = normalizeCase(item, undefined, fallback, sourceVersion ?? CASE_SCHEMA_VERSION);
66
64
  if (!normalized)
67
65
  continue;
68
- const existing = byDomain.get(normalized.domain);
66
+ // Published domain-centred stores retain their original recovery rule.
67
+ // Current incidents share a domain without sharing analyst decisions.
68
+ const key = sourceVersion !== null && sourceVersion < INCIDENT_CASE_SCHEMA_VERSION
69
+ ? normalized.domain : `${normalized.domain}\u0000${normalized.id}`;
70
+ const existing = byIdentity.get(key);
69
71
  if (!existing || Date.parse(normalized.updatedAt) >= Date.parse(existing.updatedAt)) {
70
- byDomain.set(normalized.domain, normalized);
72
+ byIdentity.set(key, normalized);
71
73
  }
72
74
  }
73
- const cases = [...byDomain.values()]
75
+ const cases = [...byIdentity.values()]
74
76
  .sort((a, b) => Date.parse(b.updatedAt) - Date.parse(a.updatedAt))
75
77
  .slice(0, MAX_CASES);
76
78
  assignUniqueIds(cases);
77
79
  return { version: CASE_SCHEMA_VERSION, cases };
78
80
  }
79
- function assertCurrentCaseShape(raw) {
81
+ function assertModernCaseShape(raw, sourceVersion) {
80
82
  for (const item of boundedCaseList(raw).items) {
81
83
  const itemRecord = objectRecord(item);
84
+ if (sourceVersion < INCIDENT_CASE_SCHEMA_VERSION && Object.hasOwn(itemRecord, 'attachments')) {
85
+ throw new TypeError('Retained file references require the current Case schema; no data was changed.');
86
+ }
82
87
  const evidenceHistory = itemRecord.evidenceHistory;
83
88
  for (const snapshot of Array.isArray(evidenceHistory) ? evidenceHistory : []) {
84
89
  const record = objectRecord(snapshot);
85
90
  if (Object.keys(record).length > 0 && !Object.hasOwn(record, 'inputHostname')) {
86
- 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.`);
91
+ throw new TypeError(`Case schema ${sourceVersion} evidence snapshots must declare their exact submitted hostname, including null; no data was changed.`);
87
92
  }
88
93
  }
89
94
  const actions = itemRecord.actions;
90
95
  for (const action of Array.isArray(actions) ? actions : []) {
91
96
  const record = objectRecord(action);
92
97
  if (Object.keys(record).length > 0 && !Object.hasOwn(record, 'routeObservedAt')) {
93
- throw new TypeError(`Case schema ${CASE_SCHEMA_VERSION} response actions must declare their route observation time, including null; unreleased local Case checkpoints are not interpreted as the current format and no data was changed.`);
98
+ throw new TypeError(`Case schema ${sourceVersion} response actions must declare their route observation time, including null; no data was changed.`);
99
+ }
100
+ if (Object.keys(record).length > 0 && !Object.hasOwn(record, 'routeReviewAfter')) {
101
+ throw new TypeError(`Case schema ${sourceVersion} response actions must declare their route review deadline, including null; no data was changed.`);
102
+ }
103
+ }
104
+ const decisions = itemRecord.decisions;
105
+ for (const decision of Array.isArray(decisions) ? decisions : []) {
106
+ const record = objectRecord(decision);
107
+ if (Object.keys(record).length > 0
108
+ && (!Object.hasOwn(record, 'confidence') || !Object.hasOwn(record, 'confidenceBasis'))) {
109
+ throw new TypeError(`Case schema ${sourceVersion} analyst decisions must declare confidence and its basis; no data was changed.`);
94
110
  }
95
111
  }
96
112
  }
@@ -140,9 +156,6 @@ function boundedCaseList(raw) {
140
156
  * @param {string} [nowIso]
141
157
  * @returns {CaseRecord}
142
158
  */
143
- function importScalar(value, valid) {
144
- return typeof value === 'string' && valid.has(value) ? value : undefined;
145
- }
146
159
  /**
147
160
  * Validates one imported record into a patch. Unlike normalizeCase, absent or
148
161
  * invalid scalar fields stay `undefined` (never defaulted) and a missing/invalid
@@ -163,7 +176,7 @@ function extractImportPatch(raw, importedVersion) {
163
176
  || null;
164
177
  const normalizedFallback = importFallback || '1970-01-01T00:00:00.000Z';
165
178
  const timestampOptions = {
166
- legacyTimestamps: importedVersion < CASE_SCHEMA_VERSION,
179
+ legacyTimestamps: importedVersion < PUBLISHED_V2_3_CASE_SCHEMA_VERSION,
167
180
  sourceVersion: importedVersion,
168
181
  };
169
182
  const rawEvidence = Array.isArray(record.evidenceHistory) ? record.evidenceHistory : [];
@@ -174,6 +187,8 @@ function extractImportPatch(raw, importedVersion) {
174
187
  const pinIds = new Set(evidencePins.map((item) => item.id));
175
188
  const actions = normalizeCaseActions(record.actions, normalizedFallback, { ...timestampOptions, validEvidencePinIds: pinIds });
176
189
  const assertions = normalizeCaseAssertions(record.assertions, normalizedFallback, pinIds, timestampOptions);
190
+ const decisions = normalizeCaseDecisions(record.decisions, normalizedFallback, pinIds, timestampOptions);
191
+ const manualTrail = normalizeCaseManualTrail(record.manualTrail, normalizedFallback, timestampOptions);
177
192
  const sightings = normalizeCaseSightings(record.sightings, normalizedFallback, pinIds, timestampOptions);
178
193
  const observedEffects = normalizeCaseObservedEffectHistory(importedVersion >= 13 ? record.observedEffects : undefined, normalizedFallback, pinIds, new Set(sightings.map((item) => item.id)), timestampOptions);
179
194
  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));
@@ -181,17 +196,27 @@ function extractImportPatch(raw, importedVersion) {
181
196
  const brandProfileReferences = importedVersion >= 12
182
197
  ? inspectCaseBrandProfileIds(record.brandProfileIds)
183
198
  : { ids: [], omitted: 0 };
199
+ const authoredHistoryOmitted = [
200
+ [record.evidencePins, evidencePins],
201
+ [record.decisions, decisions],
202
+ [record.assertions, assertions],
203
+ [record.manualTrail, manualTrail],
204
+ [record.sightings, sightings],
205
+ ].reduce((total, [candidates, retained]) => total
206
+ + Math.max(0, (Array.isArray(candidates) ? candidates.length : 0) - retained.length), 0);
184
207
  return {
185
208
  domain,
186
209
  rawId: typeof record.id === 'string' ? record.id : null,
187
- status: importScalar(record.status, STATUS_VALUES),
188
- disposition: importScalar(record.disposition, DISPOSITION_VALUES),
210
+ title: typeof record.title === 'string' && importedVersion >= INCIDENT_CASE_SCHEMA_VERSION ? normalizeCaseObjective(record.title) : undefined,
211
+ status: isValidStatus(record.status) ? record.status : undefined,
212
+ disposition: isValidDisposition(record.disposition) ? record.disposition : undefined,
189
213
  reviewReasonCode: Object.hasOwn(record, 'reviewReasonCode')
190
214
  ? normalizeReviewReasonCode(record.reviewReasonCode)
191
215
  : undefined,
192
216
  brandProfileIds: brandProfileReferences.ids,
193
217
  brandProfileReferencesOmitted: brandProfileReferences.omitted,
194
- source: importScalar(record.source, SOURCE_VALUES),
218
+ authoredHistoryOmitted,
219
+ source: isValidSource(record.source) ? record.source : undefined,
195
220
  evidenceHistory: normalizeEvidenceHistory(rawEvidence, {
196
221
  source: 'import',
197
222
  fallback: importFallback,
@@ -199,16 +224,17 @@ function extractImportPatch(raw, importedVersion) {
199
224
  caseDomain: domain,
200
225
  }),
201
226
  evidencePins,
202
- decisions: normalizeCaseDecisions(record.decisions, normalizedFallback, pinIds, timestampOptions),
227
+ decisions,
203
228
  actions,
204
229
  assertions,
205
- manualTrail: normalizeCaseManualTrail(record.manualTrail, normalizedFallback, timestampOptions),
230
+ manualTrail,
206
231
  sightings,
207
232
  observedEffects,
208
233
  closures,
209
234
  branches: importedVersion >= 11
210
235
  ? normalizeCaseInvestigationBranches(record.branches, normalizedFallback, branchReferences, timestampOptions)
211
236
  : [],
237
+ attachments: importedVersion >= INCIDENT_CASE_SCHEMA_VERSION ? readCaseAttachments(record.attachments) : undefined,
212
238
  tags: normalizeTags(record.tags),
213
239
  // Imported notes fall back only to the imported record's own timestamps
214
240
  // (never "now"), so a timestamp-less note gets a stable, deterministic time
@@ -229,6 +255,22 @@ function unionNotes(a, b) {
229
255
  const notes = [...byId.values()].sort((left, right) => Date.parse(left.createdAt) - Date.parse(right.createdAt));
230
256
  return notes.slice(Math.max(0, notes.length - MAX_NOTES_PER_CASE));
231
257
  }
258
+ function retainLocalAuthoredRecords(local, imported, maximum) {
259
+ const records = [...local];
260
+ const retainedIds = new Set(records.map((item) => item.id));
261
+ let omitted = 0;
262
+ for (const item of imported) {
263
+ if (retainedIds.has(item.id))
264
+ continue;
265
+ if (records.length >= maximum) {
266
+ omitted += 1;
267
+ continue;
268
+ }
269
+ records.push(item);
270
+ retainedIds.add(item.id);
271
+ }
272
+ return { records, omitted };
273
+ }
232
274
  // Additive, deduplicated union of two evidence histories. Identical material
233
275
  // collapses (earliest firstCapturedAt, latest capturedAt), distinct snapshots
234
276
  // are retained subject to the per-case bound, and an older import can never
@@ -241,6 +283,7 @@ function caseFromPatch(patch, now) {
241
283
  return {
242
284
  id: '', // assigned by mergeCases so it can guarantee uniqueness against locals
243
285
  domain: patch.domain,
286
+ title: patch.title ?? '',
244
287
  status: patch.status ?? DEFAULT_STATUS,
245
288
  disposition: patch.disposition ?? DEFAULT_DISPOSITION,
246
289
  reviewReasonCode: patch.reviewReasonCode ?? null,
@@ -258,6 +301,7 @@ function caseFromPatch(patch, now) {
258
301
  observedEffects: patch.observedEffects,
259
302
  closures: patch.closures,
260
303
  branches: patch.branches,
304
+ ...(patch.attachments === undefined ? {} : { attachments: patch.attachments }),
261
305
  createdAt: patch.createdAt || patch.updatedAt || now,
262
306
  updatedAt: patch.updatedAt || patch.createdAt || now,
263
307
  };
@@ -274,18 +318,33 @@ function caseFromPatch(patch, now) {
274
318
  function applyImportPatch(local, patch) {
275
319
  const importNewer = patch.updatedAt !== null && Date.parse(patch.updatedAt) > Date.parse(local.updatedAt);
276
320
  const fallback = patch.updatedAt || local.updatedAt;
277
- const evidencePins = mergeCaseEvidencePins(local.evidencePins, patch.evidencePins, fallback);
321
+ const pinSelection = retainLocalAuthoredRecords(local.evidencePins, patch.evidencePins, MAX_CASE_EVIDENCE_PINS);
322
+ const evidencePins = normalizeCaseEvidencePins(pinSelection.records, fallback);
278
323
  const pinIds = new Set(evidencePins.map((item) => item.id));
279
324
  const actions = mergeCaseActions(local.actions, patch.actions, fallback, pinIds);
280
- const assertions = mergeCaseAssertions(local.assertions, patch.assertions, fallback, pinIds);
325
+ const assertionSelection = retainLocalAuthoredRecords(local.assertions, patch.assertions, MAX_CASE_ASSERTIONS);
326
+ const assertions = normalizeCaseAssertions(assertionSelection.records, fallback, pinIds);
281
327
  const branchReferences = caseInvestigationBranchReferences({ evidencePins, actions, assertions });
282
- const sightings = mergeCaseSightings(local.sightings, patch.sightings, fallback, pinIds);
328
+ const sightingSelection = retainLocalAuthoredRecords(local.sightings, patch.sightings, MAX_CASE_SIGHTINGS);
329
+ const sightings = normalizeCaseSightings(sightingSelection.records, fallback, pinIds);
283
330
  const sightingIds = new Set(sightings.map((item) => item.id));
284
331
  const observedEffects = mergeCaseObservedEffectHistories(local.observedEffects, patch.observedEffects, fallback, pinIds, sightingIds);
285
332
  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));
286
333
  const brandProfileReferences = unionCaseBrandProfileIds(local.brandProfileIds, patch.brandProfileIds);
334
+ const decisionSelection = retainLocalAuthoredRecords(local.decisions, patch.decisions, MAX_CASE_DECISIONS);
335
+ const decisions = normalizeCaseDecisions(decisionSelection.records, fallback, pinIds);
336
+ const trailSelection = retainLocalAuthoredRecords(local.manualTrail, patch.manualTrail, MAX_CASE_MANUAL_TRAIL_EVENTS);
337
+ const manualTrail = normalizeCaseManualTrail(trailSelection.records, fallback);
338
+ const attachments = mergeCaseAttachments(local.attachments, patch.attachments);
339
+ const authoredHistoryOmitted = patch.authoredHistoryOmitted
340
+ + pinSelection.omitted
341
+ + decisionSelection.omitted
342
+ + assertionSelection.omitted
343
+ + trailSelection.omitted
344
+ + sightingSelection.omitted;
287
345
  return { record: {
288
346
  ...local,
347
+ title: patch.title !== undefined && importNewer ? patch.title : local.title ?? '',
289
348
  status: patch.status !== undefined && importNewer ? patch.status : local.status,
290
349
  disposition: patch.disposition !== undefined && importNewer ? patch.disposition : local.disposition,
291
350
  reviewReasonCode: patch.reviewReasonCode !== undefined && importNewer ? patch.reviewReasonCode : local.reviewReasonCode ?? null,
@@ -293,19 +352,20 @@ function applyImportPatch(local, patch) {
293
352
  source: patch.source !== undefined && importNewer ? patch.source : local.source,
294
353
  evidenceHistory: mergeEvidenceHistories(local.evidenceHistory, patch.evidenceHistory, local.domain),
295
354
  evidencePins,
296
- decisions: mergeCaseDecisions(local.decisions, patch.decisions, fallback, pinIds),
355
+ decisions,
297
356
  actions,
298
357
  assertions,
299
- manualTrail: mergeCaseManualTrail(local.manualTrail, patch.manualTrail, fallback),
358
+ manualTrail,
300
359
  sightings,
301
360
  observedEffects,
302
361
  closures,
303
362
  branches: mergeCaseInvestigationBranches(local.branches ?? [], patch.branches, fallback, branchReferences),
363
+ ...(attachments === undefined ? {} : { attachments }),
304
364
  tags: normalizeTags([...local.tags, ...patch.tags]),
305
365
  notes: unionNotes(local.notes, patch.notes),
306
366
  createdAt: patch.createdAt && Date.parse(patch.createdAt) < Date.parse(local.createdAt) ? patch.createdAt : local.createdAt,
307
367
  updatedAt: importNewer ? (patch.updatedAt ?? local.updatedAt) : local.updatedAt,
308
- }, brandProfileReferencesOmitted: brandProfileReferences.omitted };
368
+ }, brandProfileReferencesOmitted: brandProfileReferences.omitted, authoredHistoryOmitted };
309
369
  }
310
370
  function pickFreeId(preferred, domain, used) {
311
371
  const wanted = safeId(preferred);
@@ -411,14 +471,14 @@ function caseCollectionImportEnvelope(importedRaw) {
411
471
  }
412
472
  /**
413
473
  * Merges an imported (already parsed) value into the local cases. Predictable
414
- * and idempotent: unknown records are skipped, existing domains merge without
474
+ * and idempotent: unknown records are skipped, matching Case identities merge without
415
475
  * losing newer local decisions, and new ones are added until the store bound is
416
476
  * reached. An imported envelope that declares a schema version newer than we
417
477
  * support is rejected up front (before any local data is touched) rather than
418
478
  * reinterpreted.
419
479
  * @param {CaseRecord[]} localCases
420
480
  * @param {unknown} importedRaw
421
- * @returns {{ cases: CaseRecord[], added: number, updated: number, skipped: number, brandProfileReferencesOmitted: number }}
481
+ * @returns {{ cases: CaseRecord[], added: number, updated: number, skipped: number, brandProfileReferencesOmitted: number, authoredHistoryOmitted: number }}
422
482
  */
423
483
  export function mergeCases(localCases, importedRaw) {
424
484
  assertBoundedJsonStructure(importedRaw, 'Case import', CASE_INPUT_JSON_LIMITS);
@@ -426,7 +486,7 @@ export function mergeCases(localCases, importedRaw) {
426
486
  const importedVersion = parseStoreVersion(importedEnvelope);
427
487
  if (importedVersion !== null && Number.isSafeInteger(importedVersion) && importedVersion < CASE_SCHEMA_VERSION
428
488
  && !CASE_IMPORT_VERSIONS.includes(importedVersion)) {
429
- throw new Error(`Case schema ${importedVersion} is not part of the supported compatibility boundary. Exact published schemas 12 and 13 migrate to schema ${CASE_SCHEMA_VERSION}; no data was changed.`);
489
+ throw new Error(`Case schema ${importedVersion} is not part of the supported compatibility boundary. Exact published schemas ${CASE_IMPORT_VERSIONS.filter((version) => version < CASE_SCHEMA_VERSION).join(', ')} migrate to schema ${CASE_SCHEMA_VERSION}; no data was changed.`);
430
490
  }
431
491
  if (importedVersion !== null && Number.isSafeInteger(importedVersion) && importedVersion > CASE_SCHEMA_VERSION) {
432
492
  throw new Error(`Case schema ${importedVersion} is newer than the supported schema ${CASE_SCHEMA_VERSION}; no data was changed.`);
@@ -435,37 +495,59 @@ export function mergeCases(localCases, importedRaw) {
435
495
  || !Array.isArray(importedEnvelope.cases)) {
436
496
  throw new Error(`Expected a well-formed WHOISleuth Case export using schema ${CASE_SCHEMA_VERSION}; no data was changed.`);
437
497
  }
438
- if (importedVersion === CASE_SCHEMA_VERSION)
439
- assertCurrentCaseShape(importedEnvelope);
498
+ if (importedVersion !== null && importedVersion >= PUBLISHED_V2_3_CASE_SCHEMA_VERSION)
499
+ assertModernCaseShape(importedEnvelope, importedVersion);
440
500
  const local = normalizeCaseStore(localCases).cases;
441
501
  const supportedImportedVersion = importedVersion ?? 0;
442
- const byDomain = new Map(local.map((item) => [item.domain, item]));
502
+ const byId = new Map(local.map((item) => [item.id, item]));
443
503
  const usedIds = new Set(local.map((item) => item.id));
444
504
  let added = 0;
445
505
  let updated = 0;
446
506
  const imported = boundedCaseList(importedEnvelope);
447
507
  let skipped = imported.omitted;
448
508
  let brandProfileReferencesOmitted = 0;
509
+ let authoredHistoryOmitted = 0;
449
510
  const fallback = new Date(0).toISOString();
511
+ const importedIds = new Set();
450
512
  for (const item of imported.items) {
451
513
  const patch = extractImportPatch(item, supportedImportedVersion);
452
514
  if (!patch) {
453
515
  skipped += 1;
454
516
  continue;
455
517
  }
456
- const existing = byDomain.get(patch.domain);
518
+ const importedId = safeId(patch.rawId);
519
+ if (supportedImportedVersion >= INCIDENT_CASE_SCHEMA_VERSION && importedId) {
520
+ if (importedIds.has(importedId))
521
+ throw new Error('Incident Case imports cannot repeat a declared Case ID. No data was changed.');
522
+ importedIds.add(importedId);
523
+ }
524
+ let existing = importedId ? byId.get(importedId) : undefined;
525
+ if (existing && existing.domain !== patch.domain) {
526
+ if (supportedImportedVersion >= INCIDENT_CASE_SCHEMA_VERSION)
527
+ throw new Error('An imported Case ID belongs to a different domain in this workspace. No data was changed.');
528
+ existing = undefined;
529
+ }
530
+ if (!existing && (supportedImportedVersion < INCIDENT_CASE_SCHEMA_VERSION || !importedId)) {
531
+ const matchingDomain = [...byId.values()].filter(record => record.domain === patch.domain);
532
+ if (matchingDomain.length > 1) {
533
+ throw new Error(`The imported record for ${patch.domain} has no matching Case identity and several incidents exist. Import it into a separate workspace to review it without merging incidents; no data was changed.`);
534
+ }
535
+ existing = matchingDomain[0];
536
+ }
457
537
  if (existing) {
458
538
  const merged = applyImportPatch(existing, patch);
459
- byDomain.set(patch.domain, merged.record);
539
+ byId.set(existing.id, merged.record);
460
540
  brandProfileReferencesOmitted += patch.brandProfileReferencesOmitted + merged.brandProfileReferencesOmitted;
541
+ authoredHistoryOmitted += merged.authoredHistoryOmitted;
461
542
  updated += 1;
462
543
  }
463
- else if (byDomain.size < MAX_CASES) {
544
+ else if (byId.size < MAX_CASES) {
464
545
  const record = caseFromPatch(patch, fallback);
465
546
  record.id = pickFreeId(patch.rawId, patch.domain, usedIds);
466
547
  usedIds.add(record.id);
467
- byDomain.set(patch.domain, record);
548
+ byId.set(record.id, record);
468
549
  brandProfileReferencesOmitted += patch.brandProfileReferencesOmitted;
550
+ authoredHistoryOmitted += patch.authoredHistoryOmitted;
469
551
  added += 1;
470
552
  }
471
553
  else {
@@ -473,10 +555,11 @@ export function mergeCases(localCases, importedRaw) {
473
555
  }
474
556
  }
475
557
  return {
476
- cases: normalizeCaseStore([...byDomain.values()]).cases,
558
+ cases: normalizeCaseStore([...byId.values()]).cases,
477
559
  added,
478
560
  updated,
479
561
  skipped,
480
562
  brandProfileReferencesOmitted,
563
+ authoredHistoryOmitted,
481
564
  };
482
565
  }
@@ -3,3 +3,4 @@
3
3
  export * from "./case-record-model.mjs";
4
4
  export * from "./case-migration-model.mjs";
5
5
  export * from "./case-storage-model.mjs";
6
+ export * from "./case-selection.mjs";
@@ -0,0 +1,91 @@
1
+ import { isValidAsciiHostname } from "../../lib/hostname.mjs";
2
+ import { INCIDENT_CASE_SCHEMA_VERSION, MAX_RESPONSE_RATIONALE_LENGTH } from "../contracts/case-portability.mjs";
3
+ import { enumeration, exact, text } from "../evidence/artifact-structure.mjs";
4
+ export const CASE_RECHECK_CONDITIONS = Object.freeze({ unknown: 'Not established', comparable: 'Comparable for this question', different: 'Different conditions' });
5
+ function reference(value, label) {
6
+ const id = text(value, label, 64);
7
+ if (!/^[A-Za-z0-9_-]+$/u.test(id))
8
+ throw new TypeError(`${label} is invalid.`);
9
+ return id;
10
+ }
11
+ function contextFields(item) {
12
+ const targetHostname = text(item.targetHostname, 'Recheck target hostname', 253);
13
+ if (targetHostname !== targetHostname.toLowerCase() || !isValidAsciiHostname(targetHostname)) {
14
+ throw new TypeError('Use one normalised hostname for the recheck target, without a path or query.');
15
+ }
16
+ const conditions = text(item.conditions, 'Recheck comparison conditions', MAX_RESPONSE_RATIONALE_LENGTH);
17
+ if (!conditions.trim())
18
+ throw new TypeError('Describe the conditions needed to compare the observations.');
19
+ return Object.freeze({ targetHostname, conditions,
20
+ baselinePinId: item.baselinePinId === null ? null : reference(item.baselinePinId, 'Recheck baseline pin') });
21
+ }
22
+ /** Optional current-format context; historical records have no invented question. */
23
+ export function readCaseRecheckContext(value, sourceVersion) {
24
+ if (value === undefined)
25
+ return undefined;
26
+ if (sourceVersion != null && sourceVersion < INCIDENT_CASE_SCHEMA_VERSION)
27
+ throw new TypeError('This Case format cannot contain structured recheck questions.');
28
+ return contextFields(exact(value, ['targetHostname', 'baselinePinId', 'conditions'], 'Recheck question context'));
29
+ }
30
+ export function readCaseRecheckAnswerContext(value, sourceVersion) {
31
+ if (value === undefined)
32
+ return undefined;
33
+ if (sourceVersion != null && sourceVersion < INCIDENT_CASE_SCHEMA_VERSION)
34
+ throw new TypeError('This Case format cannot contain structured recheck answers.');
35
+ const item = exact(value, ['targetHostname', 'baselinePinId', 'conditions', 'questionId', 'question', 'conditionsMatch'], 'Recheck answer context');
36
+ const question = text(item.question, 'Recheck question', MAX_RESPONSE_RATIONALE_LENGTH);
37
+ if (!question.trim())
38
+ throw new TypeError('A recheck answer requires its original question.');
39
+ return Object.freeze({ ...contextFields(item), questionId: reference(item.questionId, 'Recheck question ID'),
40
+ question,
41
+ conditionsMatch: enumeration(item.conditionsMatch, Object.keys(CASE_RECHECK_CONDITIONS), 'Recheck condition comparison') });
42
+ }
43
+ export function caseRecheckQuestions(assertions) {
44
+ return assertions.filter(item => item.kind === 'next_step' && item.recheck && item.state === 'open');
45
+ }
46
+ export function caseRecheckAnswerContext(question, conditionsMatch) {
47
+ if (question.kind !== 'next_step' || !question.recheck)
48
+ throw new TypeError('Select a saved recheck question.');
49
+ return readCaseRecheckAnswerContext({ ...question.recheck, questionId: question.id, question: question.statement, conditionsMatch });
50
+ }
51
+ /** A saved answer is a snapshot, not a live reference to an editable plan. */
52
+ export function assertCurrentRecheckQuestion(answer, assertions) {
53
+ const question = caseRecheckQuestions(assertions).find(item => item.id === answer.questionId);
54
+ if (!question || JSON.stringify(caseRecheckAnswerContext(question, answer.conditionsMatch)) !== JSON.stringify(answer)) {
55
+ throw new Error('The recheck question changed or was resolved. Refresh and review its current conditions before saving this answer.');
56
+ }
57
+ }
58
+ export function caseRecheckComparisonWarnings(context, pins, current) {
59
+ const warnings = [];
60
+ if (context.conditionsMatch !== 'comparable')
61
+ warnings.push(context.conditionsMatch === 'different'
62
+ ? 'The comparison conditions differ.' : 'Comparable conditions have not been confirmed.');
63
+ const baseline = pins.find(pin => pin.id === context.baselinePinId);
64
+ if (context.baselinePinId && !baseline)
65
+ warnings.push('The baseline evidence is no longer available.');
66
+ if (current) {
67
+ if (current.observationHostname !== context.targetHostname)
68
+ warnings.push('The current evidence concerns a different or unknown hostname.');
69
+ if (current.completeness !== 'complete' || current.truncated || current.sourceState !== 'complete' && current.sourceState !== 'success' && current.sourceState !== 'reviewed' && current.sourceState !== 'not_found')
70
+ warnings.push('The current source does not establish a complete observation.');
71
+ if (baseline) {
72
+ if (baseline.id === current.id || !baseline.observedAt || !current.observedAt || Date.parse(current.observedAt) <= Date.parse(baseline.observedAt))
73
+ warnings.push('A later source observation is needed; reviewing the baseline again is not a recheck.');
74
+ if (baseline.observationHostname && baseline.observationHostname !== context.targetHostname)
75
+ warnings.push('The baseline concerns a different hostname.');
76
+ if (baseline.field && current.field !== baseline.field || baseline.source !== current.source)
77
+ warnings.push('The observations use different fields or sources.');
78
+ }
79
+ }
80
+ return warnings;
81
+ }
82
+ export function assertRecheckNonReproduction(state, context, completeness, pins, current, observedAt) {
83
+ if (state !== 'not_reproduced')
84
+ return;
85
+ const warnings = caseRecheckComparisonWarnings(context, pins, current);
86
+ const baseline = pins.find(pin => pin.id === context.baselinePinId);
87
+ if (!current && baseline && (!baseline.observedAt || !observedAt || !Number.isFinite(Date.parse(observedAt)) || Date.parse(observedAt) <= Date.parse(baseline.observedAt)))
88
+ warnings.push('A later independent observation is needed.');
89
+ if (completeness !== 'complete' || warnings.length)
90
+ throw new Error('Not reproduced requires a complete observation under comparable conditions. Record unavailable or describe the limited observation instead.');
91
+ }
@@ -1,30 +1,15 @@
1
1
  import { ANALYST_REVIEW_REASONS } from "../../lib/analyst-taxonomy.mjs";
2
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
+ import { CASE_DISPOSITIONS, CASE_STATUSES, DEFAULT_DISPOSITION, DEFAULT_STATUS, } from "./case-record-decisions.mjs";
3
4
  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, };
4
- export const CASE_STATUSES = [
5
- { value: 'new', label: 'New' },
6
- { value: 'reviewing', label: 'Reviewing' },
7
- { value: 'monitoring', label: 'Monitoring' },
8
- { value: 'escalated', label: 'Escalated' },
9
- { value: 'resolved', label: 'Resolved' },
10
- ];
11
- export const CASE_DISPOSITIONS = [
12
- { value: 'unreviewed', label: 'Unreviewed' },
13
- { value: 'suspicious', label: 'Suspicious' },
14
- { value: 'confirmed_abuse', label: 'Confirmed abuse' },
15
- { value: 'false_positive', label: 'False positive' },
16
- { value: 'expected', label: 'Expected' },
17
- { value: 'closed_no_action', label: 'Closed without action' },
18
- ];
5
+ export { CASE_DISPOSITIONS, CASE_STATUSES, DEFAULT_DISPOSITION, DEFAULT_STATUS, };
19
6
  export const CASE_REVIEW_REASONS = ANALYST_REVIEW_REASONS.map((item) => ({ ...item }));
20
- export const CASE_SOURCES = [
7
+ export const CASE_SOURCES = Object.freeze([
21
8
  { value: 'lookup', label: 'Lookup' },
22
9
  { value: 'bulk', label: 'Bulk' },
23
10
  { value: 'monitor', label: 'Monitor' },
24
11
  { value: 'manual', label: 'Manual' },
25
12
  { value: 'unknown', label: 'Unknown' },
26
- ];
27
- export const EVIDENCE_SOURCES = ['lookup', 'bulk', 'monitor', 'import', 'unknown'];
28
- export const DEFAULT_STATUS = 'new';
29
- export const DEFAULT_DISPOSITION = 'unreviewed';
13
+ ]);
14
+ export const EVIDENCE_SOURCES = Object.freeze(['lookup', 'bulk', 'monitor', 'import', 'unknown']);
30
15
  export const DEFAULT_SOURCE = 'unknown';