@slicedearth/whoisleuth-cli 2.3.0 → 2.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (418) hide show
  1. package/NOTICE +8 -2
  2. package/README.md +19 -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/argument-values.mjs +55 -0
  7. package/cli/arguments.mjs +24 -408
  8. package/cli/artifact-validation/case-response.mjs +65 -44
  9. package/cli/artifact-validation/investigation-capsule.mjs +52 -31
  10. package/cli/artifact-validation/lookup-evidence.mjs +15 -9
  11. package/cli/artifact-validation/signed-review.mjs +136 -68
  12. package/cli/artifact-validation/structure-primitives.mjs +1 -105
  13. package/cli/artifact-verify.mjs +92 -24
  14. package/cli/assurance-arguments.mjs +56 -0
  15. package/cli/assurance-command-definitions.mjs +179 -0
  16. package/cli/assurance-command-runner.mjs +40 -5
  17. package/cli/bagit-folder.mjs +57 -0
  18. package/cli/bagit-review.mjs +23 -0
  19. package/cli/bounded-json.mjs +1 -1
  20. package/cli/bulk-checkpoint.mjs +1 -1
  21. package/cli/bulk-command-runner.mjs +4 -2
  22. package/cli/bulk-output.mjs +29 -3
  23. package/cli/bulk.mjs +2 -3
  24. package/cli/case-command.mjs +212 -0
  25. package/cli/case-pack.mjs +50 -32
  26. package/cli/ci-report.mjs +6 -3
  27. package/cli/collection-arguments.mjs +123 -0
  28. package/cli/collection-command-definitions.mjs +330 -0
  29. package/cli/collection-preflight.mjs +8 -1
  30. package/cli/command-argument-grammar.mjs +8 -206
  31. package/cli/command-definition.mjs +329 -0
  32. package/cli/command-reference.mjs +94 -1357
  33. package/cli/command-seeds.mjs +18 -0
  34. package/cli/compare.mjs +1 -1
  35. package/cli/completion.mjs +298 -79
  36. package/cli/config-profile.mjs +8 -6
  37. package/cli/ct-event-intake.mjs +16 -16
  38. package/cli/discover.mjs +5 -4
  39. package/cli/discovery-observation-snapshot.mjs +46 -18
  40. package/cli/discovery-scan-command-runner.mjs +5 -3
  41. package/cli/discovery-scan.mjs +7 -4
  42. package/cli/discovery-snapshot.mjs +2 -2
  43. package/cli/discovery-workflow.mjs +5 -5
  44. package/cli/domain-control-monitor.mjs +50 -20
  45. package/cli/domain-control-observations.mjs +151 -103
  46. package/cli/errors.mjs +37 -6
  47. package/cli/evidence-arguments.mjs +46 -0
  48. package/cli/evidence-command-definitions.mjs +69 -0
  49. package/cli/evidence-command-runner.mjs +18 -8
  50. package/cli/evidence-signing.mjs +5 -4
  51. package/cli/external-observation-mapping.mjs +9 -10
  52. package/cli/formatters/terminal-command-formats.mjs +6 -0
  53. package/cli/formatters/terminal-lookup.mjs +24 -46
  54. package/cli/formatters/terminal-shared.mjs +5 -3
  55. package/cli/formatters/terminal-technology.mjs +114 -0
  56. package/cli/history-arguments.mjs +39 -0
  57. package/cli/history-command-definitions.mjs +88 -0
  58. package/cli/indicator-set-command.mjs +33 -0
  59. package/cli/inline-command-families.mjs +6 -46
  60. package/cli/inline-command-runner.mjs +4 -31
  61. package/cli/input.mjs +2 -2
  62. package/cli/intake-command.mjs +107 -0
  63. package/cli/interactive-launcher.mjs +2 -160
  64. package/cli/interchange-report.mjs +8 -10
  65. package/cli/investigation-artifacts.mjs +176 -0
  66. package/cli/investigation-checkpoint.mjs +10 -0
  67. package/cli/investigation-folder.mjs +114 -0
  68. package/cli/investigation-manifest.mjs +1 -112
  69. package/cli/investigation-package-review.mjs +103 -0
  70. package/cli/investigation-plan.mjs +38 -147
  71. package/cli/investigation-recipes.mjs +177 -0
  72. package/cli/investigation-run.mjs +256 -35
  73. package/cli/local-document-checkpoint.mjs +170 -0
  74. package/cli/local-mmdb-review.mjs +197 -17
  75. package/cli/lookalike-calibration.mjs +1 -1
  76. package/cli/lookup-brief.mjs +2 -2
  77. package/cli/lookup-browser.mjs +5 -12
  78. package/cli/lookup-command-runner.mjs +33 -39
  79. package/cli/lookup-diff.mjs +47 -12
  80. package/cli/lookup-plan.mjs +16 -14
  81. package/cli/lookup-reconcile.mjs +15 -12
  82. package/cli/lookup-timeline.mjs +7 -6
  83. package/cli/mail-header-review.mjs +24 -247
  84. package/cli/mail-review.mjs +75 -21
  85. package/cli/manual.mjs +1 -0
  86. package/cli/network-arguments.mjs +51 -0
  87. package/cli/network-command-definitions.mjs +130 -0
  88. package/cli/network-command-runner.mjs +45 -31
  89. package/cli/offline-evidence-review.mjs +39 -6
  90. package/cli/output-file.mjs +31 -5
  91. package/cli/posture.mjs +1 -1
  92. package/cli/registry-doctor.mjs +3 -1
  93. package/cli/registry-fixture-scaffold.mjs +1 -1
  94. package/cli/registry-support.mjs +1 -1
  95. package/cli/retained-artifact-diff.mjs +10 -9
  96. package/cli/review-arguments.mjs +131 -0
  97. package/cli/review-command-definitions.mjs +273 -0
  98. package/cli/review-command-runner.mjs +104 -149
  99. package/cli/risk-calibration.mjs +8 -8
  100. package/cli/runner.mjs +74 -37
  101. package/cli/saved-lookup.mjs +2 -2
  102. package/cli/selected-input-worker.mjs +63 -0
  103. package/cli/sharing-review.mjs +8 -80
  104. package/cli/signer-trust.mjs +104 -0
  105. package/cli/source-reliability.mjs +75 -84
  106. package/cli/support-arguments.mjs +49 -0
  107. package/cli/support-command-definitions.mjs +209 -0
  108. package/cli/support-command-runner.mjs +2 -2
  109. package/cli/terminal-input.mjs +168 -0
  110. package/cli/workflow-arguments.mjs +86 -0
  111. package/cli/workflow-command-definitions.mjs +78 -0
  112. package/cli/workflow-command-runner.mjs +75 -13
  113. package/docs/cli-reference.md +103 -30
  114. package/docs/cli.md +510 -25
  115. package/docs/contextual-reviews.md +155 -0
  116. package/lib/abort.mjs +4 -1
  117. package/lib/analyst-taxonomy.mjs +2 -40
  118. package/lib/application-version.mjs +1 -1
  119. package/lib/availability.mjs +27 -754
  120. package/lib/bounded-contract-normalizers.mjs +4 -0
  121. package/lib/bounded-file.mjs +3 -1
  122. package/lib/bounded-html-document.mjs +125 -0
  123. package/lib/bounded-json.mjs +2 -290
  124. package/lib/browser-library-profile.mjs +99 -130
  125. package/lib/browser-library-worker.mjs +112 -0
  126. package/lib/candidate-provenance-bounds.mjs +2 -0
  127. package/lib/classify.mjs +1 -1
  128. package/lib/client-behavior-profile.mjs +3 -3
  129. package/lib/ct-query.mjs +2 -27
  130. package/lib/ct-response-bounds.mjs +2 -9
  131. package/lib/ct-search.mjs +89 -46
  132. package/lib/dmarc-discovery.mjs +62 -0
  133. package/lib/dns-convergence-review.mjs +21 -8
  134. package/lib/dns-delegation-health.mjs +36 -22
  135. package/lib/dns-inheritance-review.mjs +207 -0
  136. package/lib/dns-intelligence.mjs +44 -29
  137. package/lib/dns-mx.mjs +1 -1
  138. package/lib/dns-query-coverage.mjs +40 -0
  139. package/lib/dnssec-evidence-validation.mjs +11 -3
  140. package/lib/domain-assurance.mjs +1 -1
  141. package/lib/domain-change-packet.mjs +18 -14
  142. package/lib/domain-change-review.mjs +40 -16
  143. package/lib/domain-control-flight-recorder.mjs +301 -124
  144. package/lib/domain-control-manifest.mjs +126 -58
  145. package/lib/domain-posture-analysis.mjs +41 -11
  146. package/lib/domain-posture-parsers.mjs +88 -6
  147. package/lib/domain-posture.mjs +120 -32
  148. package/lib/domain-registration.mjs +392 -0
  149. package/lib/evidence-export-privacy.mjs +568 -0
  150. package/lib/evidence-export.mjs +12 -593
  151. package/lib/evidence-report.mjs +7 -2
  152. package/lib/external-intelligence-risk.mjs +2 -124
  153. package/lib/favicon.mjs +117 -41
  154. package/lib/generated/cisa-kev-catalog.mjs +50 -4
  155. package/lib/generated/registrar-standing-catalogue.mjs +18 -10
  156. package/lib/generated/retire-browser-catalog.mjs +2742 -1034
  157. package/lib/generated/{unicode-confusables-17.mjs → unicode-confusables.mjs} +33 -33
  158. package/lib/html-signals.mjs +87 -85
  159. package/lib/http-evidence-bounds.mjs +2 -7
  160. package/lib/http-intelligence.mjs +52 -27
  161. package/lib/idn-confusable-policy.mjs +6 -4
  162. package/lib/idn-confusables.mjs +1 -1
  163. package/lib/interchange-fidelity-registry.mjs +10 -0
  164. package/lib/lookup-cache.mjs +34 -11
  165. package/lib/lookup-child-profile-contract.mjs +24 -15
  166. package/lib/lookup-contract-primitives.mjs +1 -1
  167. package/lib/lookup-diagnostics.mjs +2 -1
  168. package/lib/lookup-network-evidence-bounds.mjs +1 -10
  169. package/lib/lookup-progress.mjs +79 -13
  170. package/lib/lookup-response-contract.mjs +1077 -0
  171. package/lib/lookup-response.mjs +11 -24
  172. package/lib/lookup-source-progress.mjs +24 -35
  173. package/lib/lookup.mjs +49 -28
  174. package/lib/observed-network-context.mjs +43 -27
  175. package/lib/opportunity-scoring.mjs +2 -107
  176. package/lib/outbound-identity.mjs +1 -1
  177. package/lib/outbound-request-bounds.mjs +4 -1
  178. package/lib/page-fingerprints.mjs +87 -134
  179. package/lib/page-role-profile.mjs +12 -8
  180. package/lib/perceptual-hash-comparison.mjs +2 -34
  181. package/lib/perceptual-hash.mjs +79 -24
  182. package/lib/portable-generator.mjs +2 -29
  183. package/lib/project-metadata.mjs +2 -8
  184. package/lib/rdap-bootstrap.mjs +72 -22
  185. package/lib/rdap-client.mjs +26 -14
  186. package/lib/rdap-normalization.mjs +33 -18
  187. package/lib/rdap-search-workbench.mjs +1 -1
  188. package/lib/rdap-transport.mjs +47 -20
  189. package/lib/rdap-types.mjs +2 -1
  190. package/lib/rdap.mjs +18 -3
  191. package/lib/registrable-domain.mjs +2 -36
  192. package/lib/registrar-standing-contract.mjs +2 -1
  193. package/lib/registrar-standing.mjs +9 -13
  194. package/lib/registry-capabilities.mjs +7 -3
  195. package/lib/registry-capability-catalogue.mjs +29 -20
  196. package/lib/registry-dates.mjs +2 -190
  197. package/lib/registry-insights.mjs +56 -18
  198. package/lib/response-policy.mjs +86 -48
  199. package/lib/risk-calibration-summary.mjs +2 -338
  200. package/lib/risk-scoring.mjs +2 -345
  201. package/lib/rpki-evidence.mjs +8 -5
  202. package/lib/safe-fetch.mjs +54 -47
  203. package/lib/scoring-evidence-quality.mjs +2 -107
  204. package/lib/security-txt.mjs +10 -7
  205. package/lib/semantic-version.mjs +2 -54
  206. package/lib/service-binding-dns.mjs +35 -0
  207. package/lib/smtp-transport-review.mjs +75 -34
  208. package/lib/sslbl-certificates.generated.mjs +141 -138
  209. package/lib/static-html-analysis.mjs +117 -146
  210. package/lib/structured-data-identity.mjs +2 -1
  211. package/lib/technology-markup.mjs +100 -0
  212. package/lib/threat-intelligence-manifests.mjs +11 -139
  213. package/lib/threat-intelligence-runtime.mjs +24 -52
  214. package/lib/threat-intelligence-targets.mjs +6 -34
  215. package/lib/threat-intelligence-types.mjs +2 -36
  216. package/lib/typosquat-generator.mjs +29 -16
  217. package/lib/urlhaus-intelligence.mjs +8 -3
  218. package/lib/urlscan-intelligence.mjs +1 -1
  219. package/lib/website-enrichment.mjs +388 -0
  220. package/lib/website-security-posture.mjs +30 -6
  221. package/lib/website-technology.mjs +111 -71
  222. package/lib/whois-authority.mjs +55 -5
  223. package/lib/whois-chain.mjs +9 -2
  224. package/lib/whois-common-formats.mjs +5 -5
  225. package/lib/whois-contracts.mjs +2 -1
  226. package/lib/whois-dialects.mjs +2 -2
  227. package/lib/whois-normalization.mjs +3 -1
  228. package/lib/whois-parser-support.mjs +8 -1
  229. package/lib/whois-parser.mjs +5 -3
  230. package/lib/whois-transport.mjs +23 -7
  231. package/package.json +14 -14
  232. package/packages/analysis/analyst-taxonomy.mjs +40 -0
  233. package/packages/analysis/bounded-json.mjs +330 -0
  234. package/packages/analysis/candidate-provenance-bounds.mjs +4 -0
  235. package/packages/analysis/cli-argument-grammar.mjs +216 -0
  236. package/packages/analysis/ct-query.mjs +27 -0
  237. package/packages/analysis/ct-response-bounds.mjs +9 -0
  238. package/packages/analysis/external-intelligence-risk.mjs +124 -0
  239. package/packages/analysis/http-evidence-bounds.mjs +8 -0
  240. package/packages/analysis/lookup-network-evidence-bounds.mjs +10 -0
  241. package/packages/analysis/opportunity-scoring.mjs +107 -0
  242. package/packages/analysis/perceptual-hash-comparison.mjs +34 -0
  243. package/packages/analysis/portable-generator.mjs +29 -0
  244. package/packages/analysis/project-metadata.mjs +8 -0
  245. package/packages/analysis/registrable-domain.mjs +56 -0
  246. package/packages/analysis/risk-calibration-summary.mjs +352 -0
  247. package/packages/analysis/risk-scoring.mjs +345 -0
  248. package/packages/analysis/scoring-evidence-quality.mjs +107 -0
  249. package/packages/analysis/semantic-version.mjs +54 -0
  250. package/packages/analysis/threat-intelligence-types.mjs +75 -0
  251. package/packages/cases/case-attachment-model.mjs +83 -0
  252. package/packages/cases/case-evidence-links.mjs +135 -0
  253. package/packages/cases/case-evidence-model.mjs +229 -69
  254. package/packages/cases/case-evidence-quality.mjs +5 -0
  255. package/packages/cases/case-export-input.mjs +55 -0
  256. package/packages/cases/case-incident-context.mjs +52 -0
  257. package/packages/cases/case-markdown.mjs +11 -0
  258. package/packages/cases/case-migration-model.mjs +98 -48
  259. package/packages/cases/case-model.mjs +1 -0
  260. package/packages/cases/case-recheck-model.mjs +149 -0
  261. package/packages/cases/case-record-contracts.mjs +1 -1
  262. package/packages/cases/case-record-core.mjs +4 -3
  263. package/packages/cases/case-record-operations.mjs +123 -133
  264. package/packages/cases/case-record-projection.mjs +30 -20
  265. package/packages/cases/case-record-version-input.mjs +19 -0
  266. package/packages/cases/case-report.mjs +114 -67
  267. package/packages/cases/case-requested-evidence.mjs +146 -0
  268. package/packages/cases/case-response-actions.mjs +756 -0
  269. package/packages/cases/case-response-model.mjs +49 -1129
  270. package/packages/cases/case-response-outcomes.mjs +338 -0
  271. package/packages/cases/case-response-packet-render.mjs +157 -0
  272. package/packages/cases/case-response-packet-types.mjs +1 -0
  273. package/packages/cases/case-response-packet-vocabulary.mjs +48 -0
  274. package/packages/cases/case-response-packet.mjs +58 -593
  275. package/packages/cases/case-response-records.mjs +93 -0
  276. package/packages/cases/case-response-review-inputs.mjs +382 -0
  277. package/packages/cases/case-response-values.mjs +85 -0
  278. package/packages/cases/case-review-package.mjs +45 -0
  279. package/packages/cases/case-selection.mjs +46 -0
  280. package/packages/cases/case-storage-model.mjs +26 -1
  281. package/packages/cases/case-workflow-metadata.mjs +225 -0
  282. package/packages/cases/case-workflow-migration.mjs +43 -0
  283. package/packages/cases/http-summary.mjs +6 -1
  284. package/packages/cases/response-route-freshness.mjs +16 -0
  285. package/packages/comparison/capture-context.mjs +61 -0
  286. package/packages/comparison/comparison-ledger-bulk.mjs +4 -1
  287. package/packages/comparison/favicon-similarity.mjs +1 -1
  288. package/packages/comparison/page-similarity.mjs +3 -2
  289. package/packages/comparison/relationship-evidence.mjs +103 -14
  290. package/packages/comparison/relationship-provenance.mjs +99 -0
  291. package/packages/contracts/analyst-interchange.mjs +234 -0
  292. package/packages/contracts/analyst-review-state-contract.mjs +11 -0
  293. package/packages/contracts/analyst-review-state.mjs +5 -4
  294. package/packages/contracts/case-portability.mjs +370 -114
  295. package/packages/contracts/case-views-contract.mjs +12 -0
  296. package/packages/contracts/case-views.mjs +26 -0
  297. package/packages/contracts/cli-command-semantics.mjs +3 -0
  298. package/packages/contracts/cli-grammar.mjs +6 -0
  299. package/packages/contracts/cli-lookup-brief.mjs +2 -0
  300. package/packages/contracts/cli-lookup.mjs +0 -1
  301. package/packages/contracts/context-review.mjs +24 -0
  302. package/packages/contracts/document-intake.mjs +11 -0
  303. package/packages/contracts/domain-control-flight-recorder.mjs +98 -44
  304. package/packages/contracts/domain-control-manifest.mjs +128 -74
  305. package/packages/contracts/domain-control-monitor.mjs +59 -45
  306. package/packages/contracts/domain-control-review.mjs +200 -121
  307. package/packages/contracts/domain-name.mjs +21 -0
  308. package/packages/contracts/external-observation-interchange.mjs +2 -1
  309. package/packages/contracts/extracted-domain-lifecycle.mjs +11 -32
  310. package/packages/contracts/har-review.mjs +2 -0
  311. package/packages/contracts/http-url.mjs +2 -0
  312. package/packages/contracts/identity-events.mjs +4 -0
  313. package/packages/contracts/investigation-package-limits.mjs +24 -0
  314. package/packages/contracts/investigation-portability.mjs +56 -27
  315. package/packages/contracts/investigation-run.mjs +18 -0
  316. package/packages/contracts/ip-address.mjs +44 -0
  317. package/packages/contracts/lookup-evidence.mjs +35 -0
  318. package/packages/contracts/mail-authentication.mjs +5 -0
  319. package/packages/contracts/message-intake.mjs +31 -0
  320. package/packages/contracts/offline-comparison.mjs +4 -2
  321. package/packages/contracts/page-fingerprints.mjs +15 -0
  322. package/packages/contracts/passive-mail.mjs +10 -0
  323. package/packages/contracts/risk-calibration.mjs +0 -1
  324. package/packages/contracts/schema-compatibility.mjs +1 -1
  325. package/packages/contracts/schema-lifecycle.mjs +83 -153
  326. package/packages/contracts/selected-file-limits.mjs +6 -0
  327. package/packages/contracts/vulnerability-identifiers.mjs +5 -0
  328. package/packages/contracts/web-capture.mjs +10 -0
  329. package/packages/contracts/workspace-portability.mjs +219 -238
  330. package/packages/evidence/artifact-integrity.mjs +8 -2
  331. package/packages/evidence/artifact-structure.mjs +105 -0
  332. package/packages/evidence/collection-quality.mjs +67 -0
  333. package/packages/evidence/decision-fact.mjs +4 -1
  334. package/packages/evidence/dns-query-outcome.mjs +72 -0
  335. package/packages/evidence/domain-control-runtime.mjs +155 -27
  336. package/packages/evidence/domain-posture-context.mjs +79 -0
  337. package/packages/evidence/email-recipient.mjs +18 -0
  338. package/packages/evidence/image-regions.mjs +56 -0
  339. package/packages/evidence/latest-observations.mjs +28 -0
  340. package/packages/evidence/lookup-target.mjs +108 -0
  341. package/packages/evidence/observation.mjs +18 -1
  342. package/packages/evidence/passphrase-encryption.mjs +44 -0
  343. package/packages/evidence/png-pixels.mjs +117 -0
  344. package/packages/evidence/public-address-policy.mjs +83 -0
  345. package/packages/evidence/record-identity.mjs +72 -0
  346. package/packages/evidence/registry-dates.mjs +190 -0
  347. package/packages/evidence/retained-file.mjs +69 -0
  348. package/packages/evidence/review-age.mjs +14 -0
  349. package/packages/interchange/bagit.mjs +342 -0
  350. package/packages/interchange/bounded-zip-extraction.mjs +278 -0
  351. package/packages/interchange/crc32.mjs +17 -0
  352. package/packages/interchange/defensive-indicator-export.mjs +264 -0
  353. package/packages/interchange/external-findings-import.mjs +93 -61
  354. package/packages/interchange/managed-indicator-export.mjs +35 -0
  355. package/packages/interchange/managed-indicator-set.mjs +210 -0
  356. package/packages/interchange/misp-indicator-export.mjs +93 -0
  357. package/packages/interchange/sharing-policy.mjs +56 -0
  358. package/packages/interchange/stix-indicator-export.mjs +129 -0
  359. package/packages/interchange/web-capture-import.mjs +389 -0
  360. package/packages/investigation/acquisition-decision-packet.mjs +1 -1
  361. package/packages/investigation/bulk-mail-exposure.mjs +9 -14
  362. package/packages/investigation/capture-coverage.mjs +57 -0
  363. package/packages/investigation/connector-provenance-review.mjs +100 -0
  364. package/packages/investigation/context-review.mjs +35 -0
  365. package/packages/investigation/document-intake.mjs +81 -0
  366. package/packages/investigation/docx-intake.mjs +133 -0
  367. package/packages/investigation/domain-history-review.mjs +100 -0
  368. package/packages/investigation/domain-transition-review.mjs +47 -0
  369. package/packages/investigation/evidence-coverage-ledger.mjs +9 -1
  370. package/packages/investigation/har-intake.mjs +68 -0
  371. package/packages/investigation/identity-event-intake.mjs +131 -0
  372. package/packages/investigation/identity-incident-review.mjs +39 -0
  373. package/packages/investigation/incident-sequence-review.mjs +58 -0
  374. package/packages/investigation/intake-report.mjs +19 -0
  375. package/packages/investigation/investigation-bagit.mjs +14 -0
  376. package/packages/investigation/investigation-capsule.mjs +33 -5
  377. package/packages/investigation/investigation-manifest.mjs +200 -0
  378. package/packages/investigation/investigation-package-crypto.mjs +98 -0
  379. package/packages/investigation/investigation-package.mjs +200 -0
  380. package/packages/investigation/link-intake.mjs +179 -0
  381. package/packages/investigation/lookup-asset-graph-inputs.mjs +38 -0
  382. package/packages/investigation/lookup-asset-graph.mjs +161 -141
  383. package/packages/investigation/lookup-claim-readiness.mjs +18 -0
  384. package/packages/investigation/lookup-investigation-brief.mjs +3 -3
  385. package/packages/investigation/lookup-task-guidance.mjs +58 -0
  386. package/packages/investigation/mail-authentication-review.mjs +222 -0
  387. package/packages/investigation/mail-header-identity.mjs +209 -0
  388. package/packages/investigation/message-intake.mjs +195 -0
  389. package/packages/investigation/page-behaviour.mjs +113 -0
  390. package/packages/investigation/pdf-intake.mjs +155 -0
  391. package/packages/investigation/platform-continuity-review.mjs +60 -0
  392. package/packages/investigation/qr-intake.mjs +56 -0
  393. package/packages/investigation/requested-action-hints.mjs +11 -0
  394. package/packages/investigation/selected-input-review.mjs +17 -0
  395. package/packages/investigation/storefront-review.mjs +70 -0
  396. package/packages/monitoring/analyst-review-state.mjs +38 -90
  397. package/packages/workspace/brand-profile-model.mjs +160 -22
  398. package/packages/workspace/bulk-columns.mjs +17 -0
  399. package/packages/workspace/bulk-review-model.mjs +39 -31
  400. package/packages/workspace/bulk-session-model.mjs +112 -27
  401. package/packages/workspace/bulk-sort.mjs +1 -0
  402. package/packages/workspace/campaign-model.mjs +1 -1
  403. package/packages/workspace/case-views.mjs +102 -0
  404. package/packages/workspace/detection-rule-model.mjs +1 -1
  405. package/packages/workspace/hostile-input.mjs +29 -8
  406. package/packages/workspace/investigation-guide.mjs +2 -2
  407. package/packages/workspace/investigation-template-model.mjs +45 -6
  408. package/packages/workspace/page-baseline.mjs +20 -17
  409. package/packages/workspace/relationship-observation-model.mjs +54 -35
  410. package/packages/workspace/shortlist-model.mjs +14 -5
  411. package/packages/workspace/watchlist-history.mjs +30 -14
  412. package/packages/workspace/watchlist-store.mjs +24 -6
  413. package/packages/workspace/website-snapshot-model.mjs +98 -33
  414. package/packages/workspace/workspace-archive-crypto.mjs +41 -61
  415. package/packages/workspace/workspace-archive.mjs +90 -25
  416. package/third-party-notices.txt +577 -17
  417. package/lib/hostname.mjs +0 -38
  418. package/packages/investigation/bulk-review-export.mjs +0 -63
@@ -0,0 +1,756 @@
1
+ // Append-only response actions: transitions, reconciliation and bounded history.
2
+ import { readCaseEvidenceRequest, readCasePacketAmendment, assertEvidenceRequestEvent, assertEvidenceRequestHistory, assertEvidenceRequestTransition, assertPacketAmendment } from "./case-requested-evidence.mjs";
3
+ import { MAX_INTAKE_LINKS, MAX_MESSAGE_PARTS } from "../contracts/message-intake.mjs";
4
+ import { emailRecipient } from "../evidence/email-recipient.mjs";
5
+ import { responseRouteFreshness } from "./response-route-freshness.mjs";
6
+ import { CASE_SCHEMA_VERSION, MAX_CASE_ACTIONS, MAX_CASE_ACTION_BYTES, MAX_CASE_ACTION_EVENTS_PER_ACTION, MAX_CASE_ACTION_EVENTS_PER_CASE, MAX_CASE_ACTION_HISTORY_BYTES_PER_CASE, MAX_RESPONSE_LABEL_LENGTH, MAX_RESPONSE_RATIONALE_LENGTH, MAX_RESPONSE_RECIPIENT_LENGTH, MAX_RESPONSE_REFERENCE_LENGTH, } from "../contracts/case-portability.mjs";
7
+ import { normalizeExplicitIsoTimestamp, } from "../evidence/observation.mjs";
8
+ import { CASE_ACTION_EVENT_SOURCE_CLASSES, CASE_ACTION_STATES, CASE_ACTION_TYPES, CASE_PROVIDER_OUTCOMES, } from "./case-response-records.mjs";
9
+ import { SAFE_ID_RE, boundedCounter, compareCodeUnits, deterministicId, freshId, iso, lifecycleLimitations, limitations, optionalIso, record, safeId, text, } from "./case-response-values.mjs";
10
+ const ACTION_TYPES = new Set(CASE_ACTION_TYPES);
11
+ /** Advisory page-memory comparison; neither authenticated identity nor a mutation. */
12
+ export function compareCaseIncomingNotice(action, report, input) {
13
+ const bounded = (value, maximum) => {
14
+ if (value === undefined)
15
+ return '';
16
+ if (value.length > maximum || /[\u0000-\u001f\u007f]/u.test(value))
17
+ throw new TypeError('Notice comparison fields must be bounded single-line values.');
18
+ return value.trim();
19
+ };
20
+ const reference = bounded(input.reference, MAX_RESPONSE_REFERENCE_LENGTH);
21
+ const organisation = bounded(input.claimedOrganisation, MAX_RESPONSE_LABEL_LENGTH);
22
+ const recipient = emailRecipient(action?.recipient);
23
+ let recipientHostname = recipient ? recipient.slice(recipient.lastIndexOf('@') + 1).toLowerCase() : null;
24
+ if (!recipientHostname && action) {
25
+ try {
26
+ const url = new URL(action.recipient);
27
+ if (['https:', 'http:'].includes(url.protocol) && !url.username && !url.password)
28
+ recipientHostname = url.hostname;
29
+ }
30
+ catch { /* A free-text recipient does not establish a route hostname. */ }
31
+ }
32
+ const comparisons = [];
33
+ let senderDomains = 0, omittedSenderDomains = 0;
34
+ for (const identity of report.identities) {
35
+ if (identity.role !== 'from' && identity.role !== 'reply_to')
36
+ continue;
37
+ if (senderDomains >= MAX_MESSAGE_PARTS * 5) {
38
+ omittedSenderDomains += 1;
39
+ continue;
40
+ }
41
+ senderDomains += 1;
42
+ comparisons.push({
43
+ label: `Part ${identity.part} ${identity.role.replaceAll('_', ' ')} domain: ${identity.domain}`,
44
+ state: recipientHostname ? identity.domain.toLowerCase() === recipientHostname ? 'match' : 'mismatch' : 'unknown',
45
+ explanation: 'Compared only with the selected action recipient hostname. Domain agreement does not authenticate the sender; different domains may require independent clarification.',
46
+ });
47
+ }
48
+ if (!senderDomains)
49
+ comparisons.push({ label: 'Sender domains', state: 'unknown', explanation: 'No From or Reply-To domain was retained. Exact sender addresses are not retained.' });
50
+ if (omittedSenderDomains)
51
+ comparisons.push({ label: 'Additional sender context', state: 'unknown', explanation: `${omittedSenderDomains} additional From or Reply-To claims exceed the local comparison cap. No overall sender conclusion is available.` });
52
+ if (!report.links.length)
53
+ comparisons.push({ label: 'Supplied destinations', state: 'unknown', explanation: 'No supported destination was retained. Missing or partial extraction is not proof that no destination exists.' });
54
+ for (const link of report.links.slice(0, MAX_INTAKE_LINKS))
55
+ comparisons.push({
56
+ label: `Destination hostname: ${link.hostname}`,
57
+ state: recipientHostname ? link.hostname.toLowerCase() === recipientHostname ? 'match' : 'mismatch' : 'unknown',
58
+ explanation: 'Hostname context only, not an exact route match or authority check. Supplied destinations have not been opened.',
59
+ });
60
+ const deliveryReferences = action?.history.filter(event => event.applied && event.previousState === 'authorised'
61
+ && event.nextState === 'submitted' && event.sourceClass === 'analyst' && event.reference).map(event => event.reference) ?? [];
62
+ comparisons.push({ label: 'Delivery reference', state: !reference || !deliveryReferences.length ? 'unknown'
63
+ : deliveryReferences.includes(reference) ? 'match' : action.historyOmitted > 0 ? 'unknown' : 'mismatch',
64
+ explanation: 'Compared with retained, applied submission events only. Acknowledgements and later updates have separate references; missing submission history remains unknown. Matching text does not authenticate a notice.' });
65
+ comparisons.push({ label: 'Latest action reference', state: reference && action?.reference ? reference === action.reference ? 'match' : 'mismatch' : 'unknown',
66
+ explanation: 'Compared with the latest retained reference from any applied action event. This may be an acknowledgement or later update, not the delivery reference.' });
67
+ comparisons.push({ label: 'Claimed organisation', state: 'unknown', explanation: organisation ? 'The entered organisation is an analyst-supplied claim. Retained contact-source text is not a structured authenticated organisation identity.' : 'No organisation claim was supplied; no authenticated organisation identity is inferred.' });
68
+ return {
69
+ comparisons,
70
+ routeFreshness: action ? responseRouteFreshness(action.routeObservedAt, action.routeReviewAfter, input.now) : 'unknown',
71
+ confirmation: input.confirmedOutOfBand ? 'analyst_reported_out_of_band' : 'not_reported',
72
+ };
73
+ }
74
+ const ACTION_STATES = new Set(CASE_ACTION_STATES);
75
+ const PROVIDER_OUTCOMES = new Set(CASE_PROVIDER_OUTCOMES);
76
+ const ACTION_EVENT_SOURCE_CLASSES = new Set(CASE_ACTION_EVENT_SOURCE_CLASSES);
77
+ function currentActionNormalizationOptions(validEvidencePinIds) {
78
+ return validEvidencePinIds
79
+ ? { sourceVersion: CASE_SCHEMA_VERSION, validEvidencePinIds }
80
+ : { sourceVersion: CASE_SCHEMA_VERSION };
81
+ }
82
+ const LEGACY_ACTION_STATE_MAP = Object.freeze({
83
+ planned: 'drafting',
84
+ ready_for_review: 'ready_for_review',
85
+ submitted: 'submitted',
86
+ acknowledged: 'acknowledged',
87
+ resolved: 'terminal',
88
+ closed: 'terminal',
89
+ });
90
+ const ACTION_TRANSITIONS = Object.freeze({
91
+ drafting: new Set(['ready_for_review', 'terminal']),
92
+ ready_for_review: new Set(['drafting', 'reviewed', 'terminal']),
93
+ reviewed: new Set(['drafting', 'authorised', 'terminal']),
94
+ authorised: new Set(['drafting', 'submitted', 'terminal']),
95
+ submitted: new Set(['submitted', 'acknowledged', 'terminal']),
96
+ acknowledged: new Set(['acknowledged', 'terminal']),
97
+ terminal: new Set(),
98
+ });
99
+ // Terminal also includes deliberate withdrawal before review. This is stage
100
+ // completion, not proof of authorisation or submission.
101
+ const RESPONSE_DECISION_COMPLETE = Object.freeze({
102
+ drafting: false,
103
+ ready_for_review: false,
104
+ reviewed: true,
105
+ authorised: true,
106
+ submitted: true,
107
+ acknowledged: true,
108
+ terminal: true,
109
+ });
110
+ export function caseActionCompletesResponseDecision(state) {
111
+ return RESPONSE_DECISION_COMPLETE[state];
112
+ }
113
+ function bytes(value) {
114
+ try {
115
+ return new TextEncoder().encode(JSON.stringify(value)).byteLength;
116
+ }
117
+ catch {
118
+ return Number.POSITIVE_INFINITY;
119
+ }
120
+ }
121
+ export function isLegalCaseActionTransition(previousState, nextState, sourceClass = 'analyst') {
122
+ if (previousState === null) {
123
+ return sourceClass === 'migration'
124
+ || nextState === 'drafting' && (sourceClass === 'analyst' || sourceClass === 'browser_local');
125
+ }
126
+ if (!ACTION_TRANSITIONS[previousState].has(nextState))
127
+ return false;
128
+ if (sourceClass === 'provider' || sourceClass === 'import') {
129
+ return (previousState === 'submitted' || previousState === 'acknowledged')
130
+ && (nextState === 'submitted' || nextState === 'acknowledged' || nextState === 'terminal');
131
+ }
132
+ if (sourceClass === 'browser_local') {
133
+ return nextState === 'drafting' && ['ready_for_review', 'reviewed', 'authorised'].includes(previousState);
134
+ }
135
+ return sourceClass === 'analyst';
136
+ }
137
+ function normalizeActionEvent(raw, actionId, options = {}) {
138
+ const item = record(raw);
139
+ const previousState = item.previousState === null
140
+ ? null
141
+ : typeof item.previousState === 'string' && ACTION_STATES.has(item.previousState)
142
+ ? item.previousState
143
+ : undefined;
144
+ const nextState = typeof item.nextState === 'string' && ACTION_STATES.has(item.nextState)
145
+ ? item.nextState
146
+ : null;
147
+ const sourceClass = typeof item.sourceClass === 'string' && ACTION_EVENT_SOURCE_CLASSES.has(item.sourceClass)
148
+ ? item.sourceClass
149
+ : null;
150
+ const occurredAt = optionalIso(item.occurredAt, options);
151
+ if (previousState === undefined || !nextState || !sourceClass || !occurredAt
152
+ || !isLegalCaseActionTransition(previousState, nextState, sourceClass)) {
153
+ return null;
154
+ }
155
+ const migrationSnapshot = sourceClass === 'migration' && previousState === null;
156
+ if (['ready_for_review', 'reviewed', 'authorised', 'submitted'].includes(nextState)
157
+ && sourceClass !== 'analyst' && !migrationSnapshot)
158
+ return null;
159
+ const provenance = text(item.provenance, MAX_RESPONSE_LABEL_LENGTH) || `${sourceClass}_record`;
160
+ if (item.evidenceRequest !== undefined && options.sourceVersion != null && options.sourceVersion < 17)
161
+ throw new Error('Requested evidence requires Case schema 17.');
162
+ const evidenceRequest = readCaseEvidenceRequest(item.evidenceRequest);
163
+ assertEvidenceRequestEvent(evidenceRequest, { previousState, nextState, sourceClass, providerOutcome: item.providerOutcome });
164
+ const providerOutcome = typeof item.providerOutcome === 'string' && PROVIDER_OUTCOMES.has(item.providerOutcome)
165
+ ? item.providerOutcome
166
+ : null;
167
+ if (providerOutcome && !['submitted', 'acknowledged', 'terminal'].includes(nextState) && sourceClass !== 'migration') {
168
+ return null;
169
+ }
170
+ if (providerOutcome && previousState === 'authorised' && nextState === 'submitted' && !migrationSnapshot) {
171
+ return null;
172
+ }
173
+ if (providerOutcome === 'no_response' && sourceClass === 'provider')
174
+ return null;
175
+ if (nextState === 'terminal' && previousState !== null
176
+ && ['drafting', 'ready_for_review', 'reviewed', 'authorised'].includes(previousState)
177
+ && (sourceClass !== 'analyst' || providerOutcome !== 'withdrawn')) {
178
+ return null;
179
+ }
180
+ if (providerOutcome === 'withdrawn'
181
+ && (nextState !== 'terminal' || (sourceClass !== 'analyst' && !migrationSnapshot)))
182
+ return null;
183
+ const candidateEvidencePinId = typeof item.evidencePinId === 'string' && SAFE_ID_RE.test(item.evidencePinId)
184
+ ? item.evidencePinId
185
+ : null;
186
+ const evidencePinId = candidateEvidencePinId
187
+ && (!options.validEvidencePinIds || options.validEvidencePinIds.has(candidateEvidencePinId))
188
+ ? candidateEvidencePinId
189
+ : null;
190
+ const eventMaterial = {
191
+ previousState,
192
+ ...(evidenceRequest ? { evidenceRequest } : {}),
193
+ nextState,
194
+ occurredAt,
195
+ sourceClass,
196
+ provenance,
197
+ reference: text(item.reference, MAX_RESPONSE_REFERENCE_LENGTH) || null,
198
+ evidencePinId,
199
+ limitations: limitations([
200
+ ...(item.evidencePinId != null && !evidencePinId ? ['A malformed or dangling evidence-pin reference was omitted from this transition.'] : []),
201
+ ...limitations(item.limitations),
202
+ ]),
203
+ providerOutcome,
204
+ outcomeDetail: text(item.outcomeDetail, MAX_RESPONSE_RATIONALE_LENGTH) || null,
205
+ originActionId: typeof item.originActionId === 'string' && SAFE_ID_RE.test(item.originActionId) && item.originActionId !== actionId
206
+ ? item.originActionId
207
+ : null,
208
+ };
209
+ return {
210
+ id: safeId(item.id, 'action-event', { actionId, ...eventMaterial }),
211
+ ...eventMaterial,
212
+ applied: false,
213
+ };
214
+ }
215
+ function legacyActionState(value) {
216
+ if (typeof value === 'string' && ACTION_STATES.has(value))
217
+ return value;
218
+ if (typeof value === 'string' && LEGACY_ACTION_STATE_MAP[value])
219
+ return LEGACY_ACTION_STATE_MAP[value];
220
+ return 'drafting';
221
+ }
222
+ function legacyActionEvent(item, actionId, createdAt, fallback, options) {
223
+ const nextState = legacyActionState(item.state);
224
+ const occurredAt = iso(item.updatedAt, createdAt || fallback, options);
225
+ const sourceVersion = options.sourceVersion;
226
+ const provenance = sourceVersion !== null && sourceVersion !== undefined && sourceVersion <= 12
227
+ ? 'case_v12_legacy_snapshot'
228
+ : 'legacy_action_snapshot';
229
+ const legacyState = typeof item.state === 'string' ? item.state : 'planned';
230
+ const migrationLimitation = sourceVersion !== null && sourceVersion !== undefined && sourceVersion <= 12
231
+ ? `Migrated from the Case v12 current state "${text(legacyState, 40)}"; pre-v13 transition history is unavailable.`
232
+ : 'Recovered a legacy current-state action; earlier transition history is unavailable.';
233
+ const eventMaterial = {
234
+ previousState: null,
235
+ nextState,
236
+ occurredAt,
237
+ sourceClass: 'migration',
238
+ provenance,
239
+ reference: text(item.reference, MAX_RESPONSE_REFERENCE_LENGTH) || null,
240
+ evidencePinId: null,
241
+ limitations: limitations([migrationLimitation, ...limitations(item.contactLimitations)]),
242
+ providerOutcome: !(sourceVersion !== null && sourceVersion !== undefined && sourceVersion <= 12)
243
+ && typeof item.providerOutcome === 'string' && PROVIDER_OUTCOMES.has(item.providerOutcome)
244
+ ? item.providerOutcome
245
+ : null,
246
+ outcomeDetail: text(item.outcome, MAX_RESPONSE_RATIONALE_LENGTH) || null,
247
+ originActionId: typeof item.originActionId === 'string' && SAFE_ID_RE.test(item.originActionId) && item.originActionId !== actionId
248
+ ? item.originActionId
249
+ : null,
250
+ };
251
+ return {
252
+ id: deterministicId('action-event', { actionId, ...eventMaterial }),
253
+ ...eventMaterial,
254
+ applied: true,
255
+ };
256
+ }
257
+ function currentActionRecoveryEvent(actionId, createdAt) {
258
+ const eventMaterial = {
259
+ previousState: null,
260
+ nextState: 'drafting',
261
+ occurredAt: createdAt,
262
+ sourceClass: 'browser_local',
263
+ provenance: 'case_v13_history_recovery',
264
+ reference: null,
265
+ evidencePinId: null,
266
+ limitations: ['The v13 action had no valid transition history. Mutable state, reference, and outcome projections were ignored.'],
267
+ providerOutcome: null,
268
+ outcomeDetail: null,
269
+ originActionId: null,
270
+ };
271
+ return {
272
+ id: deterministicId('action-event', { actionId, ...eventMaterial }),
273
+ ...eventMaterial,
274
+ applied: true,
275
+ };
276
+ }
277
+ function actionEventContent(event) {
278
+ const { applied: _applied, ...material } = event;
279
+ return JSON.stringify(material);
280
+ }
281
+ function projectActionHistory(source, omitted) {
282
+ let state = null;
283
+ let conflicts = 0;
284
+ const history = [];
285
+ for (let start = 0; start < source.length;) {
286
+ let end = start + 1;
287
+ while (end < source.length && source[end].occurredAt === source[start].occurredAt)
288
+ end += 1;
289
+ const cohort = source.slice(start, end);
290
+ if (state === null && omitted > 0 && start === 0 && cohort[0].previousState !== null)
291
+ state = cohort[0].previousState;
292
+ while (cohort.length) {
293
+ // State prerequisites order equal-time transitions. Competing successors
294
+ // retain the existing deterministic conflict order; times are unchanged.
295
+ const eligible = cohort.findIndex((event) => event.previousState === state);
296
+ const [event] = cohort.splice(eligible < 0 ? 0 : eligible, 1);
297
+ const applied = event.previousState === state
298
+ && isLegalCaseActionTransition(event.previousState, event.nextState, event.sourceClass);
299
+ if (applied)
300
+ state = event.nextState;
301
+ else
302
+ conflicts += 1;
303
+ history.push({ ...event, applied });
304
+ }
305
+ start = end;
306
+ }
307
+ return { history, state: state ?? 'drafting', conflicts };
308
+ }
309
+ function normalizeActionHistory(raw, item, actionId, createdAt, fallback, options) {
310
+ const source = Array.isArray(raw) ? raw : [];
311
+ let omitted = boundedCounter(item.historyOmitted);
312
+ let invalid = 0;
313
+ let duplicateConflict = 0;
314
+ const byId = new Map();
315
+ for (const candidate of source.slice(0, MAX_CASE_ACTION_EVENTS_PER_ACTION * 4)) {
316
+ const event = normalizeActionEvent(candidate, actionId, options);
317
+ if (!event) {
318
+ invalid += 1;
319
+ continue;
320
+ }
321
+ const existing = byId.get(event.id);
322
+ if (!existing)
323
+ byId.set(event.id, event);
324
+ else if (actionEventContent(existing) !== actionEventContent(event)) {
325
+ duplicateConflict += 1;
326
+ if (actionEventContent(event) < actionEventContent(existing))
327
+ byId.set(event.id, event);
328
+ }
329
+ }
330
+ invalid += Math.max(0, source.length - MAX_CASE_ACTION_EVENTS_PER_ACTION * 4);
331
+ if (!byId.size) {
332
+ const recovered = options.sourceVersion != null && options.sourceVersion <= 12
333
+ ? legacyActionEvent(item, actionId, createdAt, fallback, options)
334
+ : currentActionRecoveryEvent(actionId, createdAt);
335
+ byId.set(recovered.id, recovered);
336
+ }
337
+ let history = [...byId.values()].sort((left, right) => Date.parse(left.occurredAt) - Date.parse(right.occurredAt) || compareCodeUnits(left.id, right.id));
338
+ if (history.length > MAX_CASE_ACTION_EVENTS_PER_ACTION) {
339
+ omitted += history.length - MAX_CASE_ACTION_EVENTS_PER_ACTION;
340
+ history = history.slice(-MAX_CASE_ACTION_EVENTS_PER_ACTION);
341
+ }
342
+ while (history.length > 1 && bytes(history) > MAX_CASE_ACTION_BYTES) {
343
+ history.shift();
344
+ omitted += 1;
345
+ }
346
+ const totalOmitted = omitted + invalid + duplicateConflict;
347
+ const projected = projectActionHistory(history, totalOmitted);
348
+ const retainedLimitations = limitations(item.historyLimitations).filter((item) => !/^\d+ earlier action transition events? omitted by bounded retention\.$/u.test(item)
349
+ && !/^\d+ retained concurrent transition(?: is|s are) not applied to the current-state projection\.$/u.test(item));
350
+ const historyLimitations = lifecycleLimitations([
351
+ ...(totalOmitted ? [`${totalOmitted} earlier action transition event${totalOmitted === 1 ? '' : 's'} omitted by bounded retention.`] : []),
352
+ ...(invalid ? [`${invalid} malformed or illegal action transition event${invalid === 1 ? '' : 's'} omitted during normalisation.`] : []),
353
+ ...(duplicateConflict ? [`${duplicateConflict} conflicting duplicate event identit${duplicateConflict === 1 ? 'y was' : 'ies were'} reconciled deterministically.`] : []),
354
+ ...(projected.conflicts ? [`${projected.conflicts} retained concurrent transition${projected.conflicts === 1 ? ' is' : 's are'} not applied to the current-state projection.`] : []),
355
+ ...retainedLimitations,
356
+ ]);
357
+ return {
358
+ history: projected.history,
359
+ state: projected.state,
360
+ omitted: totalOmitted,
361
+ historyLimitations,
362
+ };
363
+ }
364
+ function normalizeAction(raw, fallback, options = {}) {
365
+ const item = record(raw);
366
+ const recipient = text(item.recipient, MAX_RESPONSE_RECIPIENT_LENGTH);
367
+ if (!recipient)
368
+ return null;
369
+ const createdAt = iso(item.createdAt, fallback, options);
370
+ const actionId = safeId(item.id, 'action', { recipient, createdAt });
371
+ const history = normalizeActionHistory(item.history, item, actionId, createdAt, fallback, options);
372
+ assertEvidenceRequestHistory(history.history, history.omitted > 0);
373
+ if (item.amendment !== undefined && options.sourceVersion != null && options.sourceVersion < 17)
374
+ throw new Error('Packet amendments require Case schema 17.');
375
+ const amendment = readCasePacketAmendment(item.amendment);
376
+ const applied = history.history.filter((event) => event.applied);
377
+ const latestReference = [...applied].reverse().find((event) => event.reference)?.reference ?? null;
378
+ const latestProviderOutcome = [...applied].reverse().find((event) => event.providerOutcome) ?? null;
379
+ const latestOutcomeDetail = [...applied].reverse().find((event) => event.outcomeDetail) ?? null;
380
+ const latestEventAt = applied.at(-1)?.occurredAt ?? createdAt;
381
+ const metadataUpdatedAt = iso(item.metadataUpdatedAt ?? item.updatedAt, createdAt, options);
382
+ const updatedAt = Date.parse(latestEventAt) > Date.parse(metadataUpdatedAt) ? latestEventAt : metadataUpdatedAt;
383
+ const contactSource = text(item.contactSource, MAX_RESPONSE_LABEL_LENGTH) || 'analyst_supplied';
384
+ const legacyPlatformReview = options.sourceVersion != null && options.sourceVersion < 15
385
+ && item.type === 'security_contact_report'
386
+ ? /^Official .+, reviewed (\d{4}-\d{2}-\d{2})$/u.exec(contactSource)
387
+ : null;
388
+ return {
389
+ id: actionId,
390
+ ...(amendment ? { amendment } : {}),
391
+ type: legacyPlatformReview
392
+ ? 'platform_report'
393
+ : typeof item.type === 'string' && ACTION_TYPES.has(item.type)
394
+ ? item.type
395
+ : 'internal_review',
396
+ recipient,
397
+ contactSource,
398
+ routeObservedAt: legacyPlatformReview
399
+ ? normalizeExplicitIsoTimestamp(`${legacyPlatformReview[1]}T00:00:00.000Z`)
400
+ : options.sourceVersion != null && options.sourceVersion < 14
401
+ ? null
402
+ : optionalIso(item.routeObservedAt, options),
403
+ routeReviewAfter: options.sourceVersion != null && options.sourceVersion < 15
404
+ ? null
405
+ : optionalIso(item.routeReviewAfter, options),
406
+ contactLimitations: limitations(item.contactLimitations),
407
+ dueAt: optionalIso(item.dueAt, options),
408
+ state: history.state,
409
+ reference: latestReference,
410
+ followUpAt: optionalIso(item.followUpAt, options),
411
+ providerOutcome: latestProviderOutcome?.providerOutcome ?? null,
412
+ outcome: latestProviderOutcome?.outcomeDetail ?? latestOutcomeDetail?.outcomeDetail ?? null,
413
+ originActionId: typeof item.originActionId === 'string' && SAFE_ID_RE.test(item.originActionId) && item.originActionId !== actionId
414
+ ? item.originActionId
415
+ : null,
416
+ history: history.history,
417
+ historyOmitted: history.omitted,
418
+ historyLimitations: history.historyLimitations,
419
+ createdAt,
420
+ metadataUpdatedAt,
421
+ updatedAt,
422
+ };
423
+ }
424
+ function mergeNormalizedActions(left, right, fallback) {
425
+ const metadataWinner = Date.parse(left.metadataUpdatedAt) > Date.parse(right.metadataUpdatedAt)
426
+ ? left
427
+ : Date.parse(right.metadataUpdatedAt) > Date.parse(left.metadataUpdatedAt)
428
+ ? right
429
+ : JSON.stringify(left) <= JSON.stringify(right) ? left : right;
430
+ return normalizeAction({
431
+ ...metadataWinner,
432
+ id: left.id,
433
+ createdAt: Date.parse(left.createdAt) <= Date.parse(right.createdAt) ? left.createdAt : right.createdAt,
434
+ history: [...left.history, ...right.history],
435
+ historyOmitted: Math.max(left.historyOmitted, right.historyOmitted),
436
+ historyLimitations: [...left.historyLimitations, ...right.historyLimitations],
437
+ }, fallback, { sourceVersion: CASE_SCHEMA_VERSION });
438
+ }
439
+ function boundActionCollection(actions, fallback) {
440
+ const keep = new Set();
441
+ for (const action of actions) {
442
+ const latest = action.history.at(-1);
443
+ if (latest)
444
+ keep.add(`${action.id}\u0000${latest.id}`);
445
+ }
446
+ const all = actions.flatMap((action) => action.history.map((event) => ({ action, event })))
447
+ .sort((left, right) => Date.parse(right.event.occurredAt) - Date.parse(left.event.occurredAt)
448
+ || compareCodeUnits(right.event.id, left.event.id));
449
+ for (const item of all) {
450
+ if (keep.size >= MAX_CASE_ACTION_EVENTS_PER_CASE)
451
+ break;
452
+ keep.add(`${item.action.id}\u0000${item.event.id}`);
453
+ }
454
+ let bounded = actions.map((action) => {
455
+ const history = action.history.filter((event) => keep.has(`${action.id}\u0000${event.id}`));
456
+ const omitted = action.historyOmitted + action.history.length - history.length;
457
+ return normalizeAction({ ...action, history, historyOmitted: omitted }, fallback, { sourceVersion: CASE_SCHEMA_VERSION });
458
+ });
459
+ while (bytes(bounded) > MAX_CASE_ACTION_HISTORY_BYTES_PER_CASE) {
460
+ const candidate = bounded
461
+ .flatMap((action) => action.history.slice(0, -1).map((event) => ({ action, event })))
462
+ .sort((left, right) => Date.parse(left.event.occurredAt) - Date.parse(right.event.occurredAt)
463
+ || compareCodeUnits(left.event.id, right.event.id))[0];
464
+ if (!candidate)
465
+ break;
466
+ bounded = bounded.map((action) => action.id !== candidate.action.id
467
+ ? action
468
+ : normalizeAction({
469
+ ...action,
470
+ history: action.history.filter((event) => event.id !== candidate.event.id),
471
+ historyOmitted: action.historyOmitted + 1,
472
+ }, fallback, { sourceVersion: CASE_SCHEMA_VERSION }));
473
+ }
474
+ if (bytes(bounded) > MAX_CASE_ACTION_HISTORY_BYTES_PER_CASE && bounded.length > 1) {
475
+ const byteOmissionPattern = /^(\d+) earlier response actions? omitted by the per-Case response-history byte bound\.$/u;
476
+ const previouslyOmitted = bounded.reduce((maximum, action) => {
477
+ const retainedCount = action.historyLimitations.reduce((count, item) => {
478
+ const match = byteOmissionPattern.exec(item);
479
+ return match ? Math.max(count, Number(match[1])) : count;
480
+ }, 0);
481
+ return Math.max(maximum, retainedCount);
482
+ }, 0);
483
+ bounded = bounded.map((action) => ({
484
+ ...action,
485
+ historyLimitations: action.historyLimitations.filter((item) => !byteOmissionPattern.test(item)),
486
+ }));
487
+ let omittedActions = 0;
488
+ while (bounded.length > 1) {
489
+ bounded = bounded.slice(1);
490
+ omittedActions += 1;
491
+ const first = bounded[0];
492
+ const totalOmitted = previouslyOmitted + omittedActions;
493
+ bounded = [{
494
+ ...first,
495
+ historyLimitations: lifecycleLimitations([
496
+ `${totalOmitted} earlier response action${totalOmitted === 1 ? '' : 's'} omitted by the per-Case response-history byte bound.`,
497
+ ...first.historyLimitations,
498
+ ]),
499
+ }, ...bounded.slice(1)];
500
+ if (bytes(bounded) <= MAX_CASE_ACTION_HISTORY_BYTES_PER_CASE)
501
+ return bounded;
502
+ }
503
+ }
504
+ return bounded;
505
+ }
506
+ export function normalizeCaseActions(raw, fallback, options = {}) {
507
+ if (!Array.isArray(raw))
508
+ return [];
509
+ const byId = new Map();
510
+ const inspected = raw.slice(0, MAX_CASE_ACTIONS * 2);
511
+ let invalid = 0;
512
+ for (const item of inspected) {
513
+ const normalized = normalizeAction(item, fallback, options);
514
+ if (!normalized) {
515
+ invalid += 1;
516
+ continue;
517
+ }
518
+ const existing = byId.get(normalized.id);
519
+ byId.set(normalized.id, existing ? mergeNormalizedActions(existing, normalized, fallback) : normalized);
520
+ }
521
+ const normalizedActions = [...byId.values()]
522
+ .sort((left, right) => Date.parse(left.createdAt) - Date.parse(right.createdAt) || compareCodeUnits(left.id, right.id));
523
+ const uninspected = Math.max(0, raw.length - inspected.length);
524
+ const omitted = Math.max(0, normalizedActions.length - MAX_CASE_ACTIONS);
525
+ const actions = normalizedActions.slice(-MAX_CASE_ACTIONS);
526
+ if (actions.length && (invalid || uninspected || omitted)) {
527
+ const first = actions[0];
528
+ actions[0] = {
529
+ ...first,
530
+ historyLimitations: lifecycleLimitations([
531
+ ...(invalid ? [`${invalid} malformed response-action candidate${invalid === 1 ? '' : 's'} omitted during normalisation.`] : []),
532
+ ...(uninspected ? [`${uninspected} response-action candidate${uninspected === 1 ? '' : 's'} beyond the bounded inspection window were not traversed.`] : []),
533
+ ...(omitted ? [`${omitted} earlier response action${omitted === 1 ? '' : 's'} omitted by the per-Case action bound.`] : []),
534
+ ...first.historyLimitations,
535
+ ]),
536
+ };
537
+ }
538
+ const actionIds = new Set(actions.map((action) => action.id));
539
+ const linked = actions.map((action) => {
540
+ const originActionId = action.originActionId && actionIds.has(action.originActionId) && action.originActionId !== action.id
541
+ ? action.originActionId
542
+ : null;
543
+ const invalidOrigins = action.history.filter((event) => event.originActionId
544
+ && (!actionIds.has(event.originActionId) || event.originActionId === action.id)).length;
545
+ return {
546
+ ...action,
547
+ originActionId,
548
+ history: action.history.map((event) => ({
549
+ ...event,
550
+ originActionId: event.originActionId && actionIds.has(event.originActionId) && event.originActionId !== action.id
551
+ ? event.originActionId
552
+ : null,
553
+ })),
554
+ historyLimitations: lifecycleLimitations([
555
+ ...action.historyLimitations,
556
+ ...(invalidOrigins || (action.originActionId && !originActionId)
557
+ ? ['One or more dangling or self-referential origin-action links were omitted.']
558
+ : []),
559
+ ]),
560
+ };
561
+ });
562
+ return boundActionCollection(linked, fallback);
563
+ }
564
+ export function appendCaseAction(current, raw, now) {
565
+ if (current.length >= MAX_CASE_ACTIONS) {
566
+ throw new Error(`A Case can retain at most ${MAX_CASE_ACTIONS} response actions. No additional action was retained.`);
567
+ }
568
+ const item = record(raw);
569
+ const id = freshId('action');
570
+ const originActionId = typeof item.originActionId === 'string' && current.some((action) => action.id === item.originActionId)
571
+ ? item.originActionId
572
+ : null;
573
+ if (item.originActionId != null && !originActionId)
574
+ throw new Error('A follow-on action requires an existing originating action.');
575
+ const amendment = readCasePacketAmendment(item.amendment);
576
+ if (amendment)
577
+ assertPacketAmendment(current, originActionId, amendment);
578
+ const history = [{
579
+ id: freshId('action-event'),
580
+ previousState: null,
581
+ nextState: 'drafting',
582
+ occurredAt: now,
583
+ sourceClass: 'analyst',
584
+ provenance: 'browser_local_action_creation',
585
+ reference: null,
586
+ evidencePinId: null,
587
+ limitations: [],
588
+ providerOutcome: null,
589
+ outcomeDetail: null,
590
+ originActionId,
591
+ }];
592
+ const created = normalizeAction({
593
+ ...item,
594
+ id,
595
+ originActionId,
596
+ history,
597
+ historyOmitted: 0,
598
+ createdAt: now,
599
+ metadataUpdatedAt: now,
600
+ }, now, { sourceVersion: CASE_SCHEMA_VERSION });
601
+ if (!created)
602
+ throw new Error('An action requires a recipient or internal owner.');
603
+ return normalizeCaseActions([...current, created], now, { sourceVersion: CASE_SCHEMA_VERSION });
604
+ }
605
+ export function appendCaseActionTransition(current, actionId, raw, now, validPinIds) {
606
+ const action = current.find((item) => item.id === actionId);
607
+ if (!action)
608
+ throw new Error('That case action no longer exists.');
609
+ const item = record(raw);
610
+ const nextState = typeof item.nextState === 'string' && ACTION_STATES.has(item.nextState)
611
+ ? item.nextState
612
+ : null;
613
+ const sourceClass = typeof item.sourceClass === 'string' && ACTION_EVENT_SOURCE_CLASSES.has(item.sourceClass)
614
+ ? item.sourceClass
615
+ : 'analyst';
616
+ if (!nextState || !isLegalCaseActionTransition(action.state, nextState, sourceClass)) {
617
+ throw new Error(`The transition from ${action.state.replaceAll('_', ' ')} to ${String(item.nextState || 'that state').replaceAll('_', ' ')} is not permitted.`);
618
+ }
619
+ if (['ready_for_review', 'reviewed', 'authorised', 'submitted'].includes(nextState) && sourceClass !== 'analyst') {
620
+ throw new Error('Readiness, review, authorisation, and submission require an explicit analyst transition.');
621
+ }
622
+ const originActionId = typeof item.originActionId === 'string'
623
+ && item.originActionId !== action.id
624
+ && current.some((candidate) => candidate.id === item.originActionId)
625
+ ? item.originActionId
626
+ : action.originActionId;
627
+ if (item.originActionId != null && originActionId !== item.originActionId) {
628
+ throw new Error('A referral or follow-on transition requires a distinct existing originating action.');
629
+ }
630
+ if (item.evidencePinId != null && (typeof item.evidencePinId !== 'string'
631
+ || !SAFE_ID_RE.test(item.evidencePinId) || (validPinIds && !validPinIds.has(item.evidencePinId)))) {
632
+ throw new Error('An action transition evidence pin must reference a retained Case evidence pin.');
633
+ }
634
+ const evidenceRequest = readCaseEvidenceRequest(item.evidenceRequest);
635
+ if (evidenceRequest)
636
+ assertEvidenceRequestTransition(action, evidenceRequest, sourceClass, item.providerOutcome, validPinIds);
637
+ if (action.amendment && ['ready_for_review', 'reviewed', 'authorised', 'submitted'].includes(nextState)) {
638
+ assertPacketAmendment(current, action.originActionId, action.amendment);
639
+ }
640
+ const occurredAt = optionalIso(item.occurredAt) ?? now;
641
+ const event = normalizeActionEvent({
642
+ ...item,
643
+ id: freshId('action-event'),
644
+ previousState: action.state,
645
+ nextState,
646
+ occurredAt,
647
+ sourceClass,
648
+ provenance: text(item.provenance, MAX_RESPONSE_LABEL_LENGTH) || 'browser_local_explicit_transition',
649
+ originActionId,
650
+ }, action.id, currentActionNormalizationOptions(validPinIds));
651
+ if (!event)
652
+ throw new Error('The action transition contains an invalid time, provider outcome, or provenance field.');
653
+ const updated = normalizeAction({
654
+ ...action,
655
+ history: [...action.history, event],
656
+ historyOmitted: action.historyOmitted,
657
+ }, now, currentActionNormalizationOptions(validPinIds));
658
+ return normalizeCaseActions(current.map((candidate) => candidate.id === actionId ? updated : candidate), now, currentActionNormalizationOptions(validPinIds));
659
+ }
660
+ const ACTION_REVIEW_MATERIAL_FIELDS = [
661
+ 'type', 'recipient', 'contactSource', 'routeObservedAt', 'routeReviewAfter', 'contactLimitations', 'originActionId', 'amendment',
662
+ ];
663
+ export function updateCaseAction(current, raw, now, validPinIds) {
664
+ const patch = record(raw);
665
+ const id = typeof patch.id === 'string' && SAFE_ID_RE.test(patch.id) ? patch.id : '';
666
+ const existing = current.find((item) => item.id === id);
667
+ if (!existing)
668
+ throw new Error('That case action no longer exists.');
669
+ if (Object.hasOwn(patch, 'state') || Object.hasOwn(patch, 'outcome') || Object.hasOwn(patch, 'providerOutcome') || Object.hasOwn(patch, 'reference')) {
670
+ throw new Error('State, reference, and provider outcomes must be recorded as an append-only action transition.');
671
+ }
672
+ const metadata = {
673
+ ...existing,
674
+ ...Object.fromEntries(ACTION_REVIEW_MATERIAL_FIELDS
675
+ .filter((field) => Object.hasOwn(patch, field))
676
+ .map((field) => [field, patch[field]])),
677
+ dueAt: Object.hasOwn(patch, 'dueAt') ? patch.dueAt : existing.dueAt,
678
+ followUpAt: Object.hasOwn(patch, 'followUpAt') ? patch.followUpAt : existing.followUpAt,
679
+ metadataUpdatedAt: now,
680
+ };
681
+ if (Object.hasOwn(patch, 'originActionId') && patch.originActionId !== null
682
+ && (typeof patch.originActionId !== 'string' || patch.originActionId === id || !current.some((item) => item.id === patch.originActionId))) {
683
+ throw new Error('A follow-on action requires a distinct existing originating action.');
684
+ }
685
+ let updated = normalizeAction(metadata, now, currentActionNormalizationOptions(validPinIds));
686
+ if (!updated)
687
+ throw new Error('An action requires a recipient or internal owner.');
688
+ const materialChanged = ACTION_REVIEW_MATERIAL_FIELDS
689
+ .some((key) => Object.hasOwn(patch, key) && JSON.stringify(record(existing)[key]) !== JSON.stringify(record(updated)[key]));
690
+ if (materialChanged && updated.amendment)
691
+ assertPacketAmendment(current, updated.originActionId, updated.amendment);
692
+ if (materialChanged && ['submitted', 'acknowledged', 'terminal'].includes(existing.state)) {
693
+ throw new Error('Submitted or terminal action identity and recipient metadata cannot be rewritten; create a linked follow-on action instead.');
694
+ }
695
+ if (materialChanged && ['ready_for_review', 'reviewed', 'authorised'].includes(existing.state)) {
696
+ const invalidated = normalizeAction({
697
+ ...updated,
698
+ history: [...updated.history, {
699
+ id: freshId('action-event'),
700
+ previousState: existing.state,
701
+ nextState: 'drafting',
702
+ occurredAt: now,
703
+ sourceClass: 'browser_local',
704
+ provenance: 'material_action_change',
705
+ reference: null,
706
+ evidencePinId: null,
707
+ limitations: ['Material action inputs changed after review; prior readiness, review, or authorisation no longer applies.'],
708
+ providerOutcome: null,
709
+ outcomeDetail: null,
710
+ originActionId: updated.originActionId,
711
+ }],
712
+ }, now, currentActionNormalizationOptions(validPinIds));
713
+ if (!invalidated)
714
+ throw new Error('The material action change could not be retained safely.');
715
+ updated = invalidated;
716
+ }
717
+ const interim = current.map((item) => item.id === id ? updated : item);
718
+ return patch.transition !== undefined
719
+ ? appendCaseActionTransition(interim, id, patch.transition, now, validPinIds)
720
+ : normalizeCaseActions(interim, now, currentActionNormalizationOptions(validPinIds));
721
+ }
722
+ export function buildCaseActionOutcomeSummary(actions, nowRaw = new Date().toISOString()) {
723
+ const now = optionalIso(nowRaw) ?? new Date().toISOString();
724
+ const nowTime = Date.parse(now);
725
+ const active = actions.filter((item) => item.state !== 'terminal');
726
+ const latestOutcomes = actions
727
+ .flatMap((action) => action.history.filter((event) => event.applied && event.providerOutcome).map((event) => ({ action, event })))
728
+ .sort((left, right) => Date.parse(right.event.occurredAt) - Date.parse(left.event.occurredAt) || compareCodeUnits(right.event.id, left.event.id))
729
+ .slice(0, 5)
730
+ .map(({ action, event }) => ({
731
+ actionId: action.id,
732
+ recipient: action.recipient,
733
+ state: action.state,
734
+ providerOutcome: event.providerOutcome,
735
+ outcomeDetail: event.outcomeDetail,
736
+ occurredAt: event.occurredAt,
737
+ }));
738
+ return {
739
+ total: actions.length,
740
+ active: active.length,
741
+ drafting: actions.filter((item) => item.state === 'drafting').length,
742
+ readyForReview: actions.filter((item) => item.state === 'ready_for_review').length,
743
+ reviewed: actions.filter((item) => item.state === 'reviewed').length,
744
+ authorised: actions.filter((item) => item.state === 'authorised').length,
745
+ submitted: actions.filter((item) => item.state === 'submitted').length,
746
+ acknowledged: actions.filter((item) => item.state === 'acknowledged').length,
747
+ terminal: actions.filter((item) => item.state === 'terminal').length,
748
+ overdue: active.filter((item) => item.dueAt && Date.parse(item.dueAt) < nowTime).length,
749
+ followUpDue: active.filter((item) => item.followUpAt && Date.parse(item.followUpAt) <= nowTime).length,
750
+ withProviderOutcome: actions.filter((item) => item.providerOutcome !== null).length,
751
+ latestOutcomes,
752
+ };
753
+ }
754
+ export function mergeCaseActions(local, imported, fallback, validPinIds) {
755
+ return normalizeCaseActions([...local, ...imported], fallback, currentActionNormalizationOptions(validPinIds));
756
+ }