@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,94 @@
1
+ // Framework-neutral response record types and vocabulary.
2
+ export const CASE_EVIDENCE_RELATION_STANCES = ['supports', 'contradicts', 'unresolved'];
3
+ export const CASE_PIN_COMPLETENESS = ['complete', 'partial', 'inconclusive', 'unknown'];
4
+ export const CASE_TRANSITION_EXPECTATIONS = ['preserve', 'change', 'review'];
5
+ export const CASE_ACTION_TYPES = [
6
+ 'registrar_report',
7
+ 'registry_report',
8
+ 'network_hosting_report',
9
+ 'security_contact_report',
10
+ 'platform_report',
11
+ 'defensive_control',
12
+ 'internal_review',
13
+ ];
14
+ export const CASE_DECISION_CONFIDENCE_LEVELS = ['unknown', 'low', 'moderate', 'high'];
15
+ export const CASE_ACTION_STATES = [
16
+ 'drafting',
17
+ 'ready_for_review',
18
+ 'reviewed',
19
+ 'authorised',
20
+ 'submitted',
21
+ 'acknowledged',
22
+ 'terminal',
23
+ ];
24
+ export const CASE_PROVIDER_OUTCOMES = [
25
+ 'accepted_for_review',
26
+ 'more_information_requested',
27
+ 'referred_elsewhere',
28
+ 'rejected_outside_policy_scope',
29
+ 'no_response',
30
+ 'partially_remediated',
31
+ 'provider_reports_resolved',
32
+ 'withdrawn',
33
+ 'duplicate',
34
+ ];
35
+ export const CASE_ACTION_EVENT_SOURCE_CLASSES = [
36
+ 'analyst',
37
+ 'provider',
38
+ 'browser_local',
39
+ 'import',
40
+ 'migration',
41
+ ];
42
+ export const CASE_OBSERVED_EFFECT_STATES = [
43
+ 'not_checked',
44
+ 'still_observed',
45
+ 'changed',
46
+ 'not_reproduced',
47
+ 'unavailable',
48
+ ];
49
+ export const CASE_OBSERVED_EFFECT_SOURCE_CLASSES = ['deployment', 'analyst', 'import'];
50
+ export const CASE_CLOSURE_REASONS = [
51
+ 'independently_not_reproduced',
52
+ 'infrastructure_changed',
53
+ 'provider_reported_resolution_not_independently_checked',
54
+ 'risk_accepted',
55
+ 'false_positive',
56
+ 'duplicate',
57
+ 'unable_to_proceed',
58
+ 'monitoring_transferred',
59
+ ];
60
+ export const CASE_ASSERTION_KINDS = [
61
+ 'verified_fact',
62
+ 'hypothesis',
63
+ 'unknown',
64
+ 'contradiction',
65
+ 'next_step',
66
+ ];
67
+ export const CASE_ASSERTION_STATES = ['open', 'resolved'];
68
+ export const CASE_ASSERTION_EXTERNAL_FORMATS = ['stix', 'misp'];
69
+ export const CASE_ASSERTION_EXTERNAL_ENTITY_TYPES = [
70
+ 'asn',
71
+ 'certificate',
72
+ 'domain',
73
+ 'hostname',
74
+ 'ipv4',
75
+ 'ipv6',
76
+ 'url',
77
+ ];
78
+ export const CASE_MANUAL_TRAIL_KINDS = ['pivot', 'review', 'handoff'];
79
+ export const CASE_SIGHTING_STATES = [
80
+ 'observed_by_deployment',
81
+ 'reported_by_provider',
82
+ 'analyst_confirmed',
83
+ 'not_reproduced',
84
+ 'expired',
85
+ ];
86
+ export const CASE_SIGHTING_CATEGORIES = [
87
+ 'registration',
88
+ 'delegation',
89
+ 'certificate',
90
+ 'mail',
91
+ 'website',
92
+ 'infrastructure',
93
+ 'other',
94
+ ];
@@ -0,0 +1,371 @@
1
+ // Exact, bounded validation of current and published response-review inputs.
2
+ import { CASE_RESPONSE_REVIEW_INPUTS_SCHEMA, MAX_ABUSE_CATEGORY_LENGTH, MAX_ABUSIVE_URLS, MAX_AFFECTED_PARTY_LENGTH, MAX_CASE_ACTIONS, MAX_CASE_ACTION_EVENTS_PER_ACTION, MAX_CASE_ACTION_EVENTS_PER_CASE, MAX_EXACT_URL_LENGTH, MAX_RESPONSE_ACTION_HISTORY, MAX_RESPONSE_ARTEFACT_REFERENCES, MAX_RESPONSE_CONTACTS, MAX_RESPONSE_CONTRADICTIONS, MAX_RESPONSE_HARM_LENGTH, MAX_RESPONSE_LIMITATIONS, MAX_RESPONSE_LIMITATION_LENGTH, MAX_RESPONSE_REFERENCE_LENGTH, MAX_RESPONSE_SELECTED_EVIDENCE, MAX_RESPONSE_VALUE_LENGTH, PUBLISHED_V2_2_CASE_RESPONSE_REVIEW_INPUTS_VERSION, PUBLISHED_V2_3_CASE_RESPONSE_REVIEW_INPUTS_VERSION, SUPPORTED_CASE_RESPONSE_REVIEW_INPUTS_VERSIONS, } from "../contracts/case-portability.mjs";
3
+ import { CASE_ACTION_EVENT_SOURCE_CLASSES, CASE_ACTION_STATES, CASE_ACTION_TYPES, CASE_ASSERTION_STATES, CASE_CLOSURE_REASONS, CASE_OBSERVED_EFFECT_SOURCE_CLASSES, CASE_OBSERVED_EFFECT_STATES, CASE_PIN_COMPLETENESS, CASE_PROVIDER_OUTCOMES, } from "./case-response-records.mjs";
4
+ import { RESPONSE_CONTACT_KINDS, RESPONSE_PACKET_PROFILE_IDS, RESPONSE_READINESS_ROW_IDS, RESPONSE_READINESS_STATES, } from "./case-response-packet-vocabulary.mjs";
5
+ import { isValidAsciiHostname } from "../../lib/hostname.mjs";
6
+ const CONTACT_KINDS = new Set(RESPONSE_CONTACT_KINDS);
7
+ const PRE_PLATFORM_CONTACT_KINDS = new Set(RESPONSE_CONTACT_KINDS.filter((kind) => kind !== 'application_platform'));
8
+ const CONTROL_RE = /[\u0000-\u001f\u007f]/u;
9
+ const RESPONSE_PROFILE_IDS = new Set(RESPONSE_PACKET_PROFILE_IDS);
10
+ const PRE_PLATFORM_PROFILE_IDS = new Set(RESPONSE_PACKET_PROFILE_IDS.filter((id) => id !== 'application_platform'));
11
+ const PRE_PLATFORM_ACTION_TYPES = CASE_ACTION_TYPES.filter((type) => type !== 'platform_report');
12
+ const CASE_RESPONSE_REVIEW_INPUT_KEYS = Object.freeze([
13
+ 'contract',
14
+ 'version',
15
+ 'profile',
16
+ 'case',
17
+ 'incident',
18
+ 'contacts',
19
+ 'recipientRoute',
20
+ 'actionBinding',
21
+ 'selectedEvidence',
22
+ 'contradictions',
23
+ 'readiness',
24
+ 'artefactReferences',
25
+ 'escalationHistory',
26
+ 'escalationHistoryOmitted',
27
+ 'escalationHistoryLimitations',
28
+ 'responseLifecycle',
29
+ ]);
30
+ const PUBLISHED_V2_CASE_RESPONSE_REVIEW_INPUT_KEYS = Object.freeze(CASE_RESPONSE_REVIEW_INPUT_KEYS.filter((key) => key !== 'recipientRoute' && key !== 'actionBinding'));
31
+ function exactReviewRecord(value, keys, label, optionalKeys = []) {
32
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
33
+ throw new TypeError(`${label} must be an exact object.`);
34
+ }
35
+ const ownKeys = Reflect.ownKeys(value);
36
+ if (keys.some((key) => !Object.hasOwn(value, key))
37
+ || ownKeys.some((key) => typeof key !== 'string' || !keys.includes(key) && !optionalKeys.includes(key))) {
38
+ throw new TypeError(`${label} contains an undeclared field.`);
39
+ }
40
+ const descriptors = Object.getOwnPropertyDescriptors(value);
41
+ if (ownKeys.some((key) => typeof key !== 'string' || !descriptors[key] || !Object.hasOwn(descriptors[key], 'value'))) {
42
+ throw new TypeError(`${label} must not contain accessors.`);
43
+ }
44
+ return value;
45
+ }
46
+ function boundedReviewArray(value, maximum, label) {
47
+ if (!Array.isArray(value) || value.length > maximum) {
48
+ throw new TypeError(`${label} exceeds its declared array bound.`);
49
+ }
50
+ const ownKeys = Reflect.ownKeys(value).filter((key) => key !== 'length');
51
+ if (ownKeys.length !== value.length
52
+ || ownKeys.some((key, index) => key !== String(index))) {
53
+ throw new TypeError(`${label} must be a dense ordinary array.`);
54
+ }
55
+ const descriptors = Object.getOwnPropertyDescriptors(value);
56
+ if (ownKeys.some((key) => typeof key !== 'string' || !Object.hasOwn(descriptors[key], 'value'))) {
57
+ throw new TypeError(`${label} must not contain accessors.`);
58
+ }
59
+ return value;
60
+ }
61
+ function reviewText(value, maximum, label, nullable = false) {
62
+ if (nullable && value === null)
63
+ return null;
64
+ if (typeof value !== 'string' || value.length > maximum || CONTROL_RE.test(value)) {
65
+ throw new TypeError(`${label} is not a bounded text value.`);
66
+ }
67
+ return value;
68
+ }
69
+ function reviewStrings(value, maximum, itemMaximum, label) {
70
+ const items = boundedReviewArray(value, maximum, label);
71
+ for (const item of items)
72
+ reviewText(item, itemMaximum, `${label} item`);
73
+ return items;
74
+ }
75
+ function reviewCount(value, maximum, label) {
76
+ if (!Number.isSafeInteger(value) || value < 0 || value > maximum) {
77
+ throw new TypeError(`${label} is not a bounded count.`);
78
+ }
79
+ return value;
80
+ }
81
+ function reviewEnum(value, values, label) {
82
+ if (typeof value !== 'string' || !values.includes(value)) {
83
+ throw new TypeError(`${label} contains an unsupported value.`);
84
+ }
85
+ return value;
86
+ }
87
+ function reviewNullableEnum(value, values, label) {
88
+ return value === null ? null : reviewEnum(value, values, label);
89
+ }
90
+ function validateReviewLifecycle(value) {
91
+ const lifecycle = exactReviewRecord(value, [
92
+ 'providerOutcomeState', 'latestProviderOutcome', 'observedChangeState',
93
+ 'latestObservedEffect', 'latestObservedChangeAt', 'closure', 'limitations',
94
+ ], 'Case-response review lifecycle');
95
+ reviewEnum(lifecycle.providerOutcomeState, ['available', 'missing', 'ambiguous'], 'Case-response provider-outcome state');
96
+ reviewEnum(lifecycle.observedChangeState, ['available', 'missing', 'ambiguous'], 'Case-response observed-change state');
97
+ reviewText(lifecycle.latestObservedChangeAt, 64, 'Case-response latest observed change', true);
98
+ reviewStrings(lifecycle.limitations, MAX_RESPONSE_LIMITATIONS, MAX_RESPONSE_LIMITATION_LENGTH, 'Case-response lifecycle limitations');
99
+ if (lifecycle.latestProviderOutcome !== null) {
100
+ const outcome = exactReviewRecord(lifecycle.latestProviderOutcome, [
101
+ 'actionId', 'eventId', 'outcome', 'occurredAt', 'reference',
102
+ ], 'Case-response latest provider outcome');
103
+ reviewText(outcome.actionId, 64, 'Case-response provider action id');
104
+ reviewText(outcome.eventId, 64, 'Case-response provider event id');
105
+ reviewEnum(outcome.outcome, CASE_PROVIDER_OUTCOMES, 'Case-response provider outcome');
106
+ reviewText(outcome.occurredAt, 64, 'Case-response provider outcome time');
107
+ reviewText(outcome.reference, MAX_RESPONSE_REFERENCE_LENGTH, 'Case-response provider reference', true);
108
+ }
109
+ if (lifecycle.latestObservedEffect !== null) {
110
+ const effect = exactReviewRecord(lifecycle.latestObservedEffect, [
111
+ 'reviewId', 'state', 'observedAt', 'sourceClass', 'source',
112
+ ], 'Case-response latest observed effect');
113
+ reviewText(effect.reviewId, 64, 'Case-response observed-effect review id');
114
+ reviewEnum(effect.state, CASE_OBSERVED_EFFECT_STATES, 'Case-response observed-effect state');
115
+ reviewText(effect.observedAt, 64, 'Case-response observed-effect time');
116
+ reviewEnum(effect.sourceClass, CASE_OBSERVED_EFFECT_SOURCE_CLASSES, 'Case-response observed-effect source class');
117
+ reviewText(effect.source, MAX_RESPONSE_VALUE_LENGTH, 'Case-response observed-effect source');
118
+ }
119
+ if (lifecycle.closure !== null) {
120
+ const closure = exactReviewRecord(lifecycle.closure, [
121
+ 'id', 'reason', 'createdAt', 'limitations',
122
+ ], 'Case-response latest closure');
123
+ reviewText(closure.id, 64, 'Case-response closure id');
124
+ reviewEnum(closure.reason, CASE_CLOSURE_REASONS, 'Case-response closure reason');
125
+ reviewText(closure.createdAt, 64, 'Case-response closure time');
126
+ reviewStrings(closure.limitations, MAX_RESPONSE_LIMITATIONS, MAX_RESPONSE_LIMITATION_LENGTH, 'Case-response closure limitations');
127
+ }
128
+ }
129
+ function recursivelyFreezeReviewValue(value) {
130
+ if (!value || typeof value !== 'object' || Object.isFrozen(value))
131
+ return value;
132
+ for (const child of Object.values(value)) {
133
+ recursivelyFreezeReviewValue(child);
134
+ }
135
+ return Object.freeze(value);
136
+ }
137
+ export function validateCaseResponseReviewInputs(value) {
138
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
139
+ throw new TypeError('Case-response review inputs must be an exact object.');
140
+ }
141
+ const descriptors = Object.getOwnPropertyDescriptors(value);
142
+ if (!descriptors.contract || !Object.hasOwn(descriptors.contract, 'value')
143
+ || !descriptors.version || !Object.hasOwn(descriptors.version, 'value')) {
144
+ throw new TypeError('Case-response review inputs must not contain accessors.');
145
+ }
146
+ const version = descriptors.version.value;
147
+ if (descriptors.contract.value !== CASE_RESPONSE_REVIEW_INPUTS_SCHEMA
148
+ || !SUPPORTED_CASE_RESPONSE_REVIEW_INPUTS_VERSIONS.some((supported) => supported === version)) {
149
+ throw new TypeError('Case-response review inputs contain an unsupported version, shape, or bound.');
150
+ }
151
+ const hasPlatformRoutes = version >= PUBLISHED_V2_3_CASE_RESPONSE_REVIEW_INPUTS_VERSION;
152
+ const hasActionBinding = version >= PUBLISHED_V2_2_CASE_RESPONSE_REVIEW_INPUTS_VERSION;
153
+ const source = exactReviewRecord(value, hasActionBinding ? CASE_RESPONSE_REVIEW_INPUT_KEYS : PUBLISHED_V2_CASE_RESPONSE_REVIEW_INPUT_KEYS, 'Case-response review inputs');
154
+ const profile = exactReviewRecord(source.profile, [
155
+ 'id', 'label', 'audience', 'subject', 'checklist', 'includedEvidence',
156
+ 'excludedEvidence', 'redactions',
157
+ ], 'Case-response review profile');
158
+ const supportedProfileIds = hasPlatformRoutes ? RESPONSE_PROFILE_IDS : PRE_PLATFORM_PROFILE_IDS;
159
+ if (typeof profile.id !== 'string' || !supportedProfileIds.has(profile.id)) {
160
+ throw new TypeError('Case-response review profile is unsupported.');
161
+ }
162
+ for (const key of ['label', 'audience', 'subject']) {
163
+ reviewText(profile[key], 500, `Case-response profile ${key}`);
164
+ }
165
+ for (const key of ['checklist', 'includedEvidence', 'excludedEvidence', 'redactions']) {
166
+ reviewStrings(profile[key], 32, 500, `Case-response profile ${key}`);
167
+ }
168
+ const caseRecord = exactReviewRecord(source.case, [
169
+ 'id', 'domain', 'status', 'disposition', 'updatedAt',
170
+ ], 'Case-response review Case');
171
+ reviewText(caseRecord.id, 64, 'Case-response Case id');
172
+ reviewText(caseRecord.domain, 253, 'Case-response Case domain');
173
+ reviewText(caseRecord.status, 64, 'Case-response Case status');
174
+ reviewText(caseRecord.disposition, 64, 'Case-response Case disposition');
175
+ reviewText(caseRecord.updatedAt, 64, 'Case-response Case update time');
176
+ const incident = exactReviewRecord(source.incident, [
177
+ 'category', 'affectedParty', 'abusiveUrls', 'observedHarm', 'observedAt',
178
+ ], 'Case-response review incident');
179
+ reviewText(incident.category, MAX_ABUSE_CATEGORY_LENGTH, 'Case-response category');
180
+ reviewText(incident.affectedParty, MAX_AFFECTED_PARTY_LENGTH, 'Case-response affected party');
181
+ reviewStrings(incident.abusiveUrls, MAX_ABUSIVE_URLS, MAX_EXACT_URL_LENGTH, 'Case-response abusive URLs');
182
+ reviewText(incident.observedHarm, MAX_RESPONSE_HARM_LENGTH, 'Case-response observed harm');
183
+ reviewText(incident.observedAt, 64, 'Case-response observation time', true);
184
+ for (const candidate of boundedReviewArray(source.contacts, MAX_RESPONSE_CONTACTS, 'Case-response contacts')) {
185
+ const contact = exactReviewRecord(candidate, [
186
+ 'kind', 'contact', 'source', 'observedAt', ...(hasPlatformRoutes ? ['reviewAfter'] : []), 'freshness', 'limitations',
187
+ ], 'Case-response contact');
188
+ const supportedContactKinds = hasPlatformRoutes ? CONTACT_KINDS : PRE_PLATFORM_CONTACT_KINDS;
189
+ if (typeof contact.kind !== 'string' || !supportedContactKinds.has(contact.kind)
190
+ || typeof contact.freshness !== 'string'
191
+ || !['current', 'stale', 'unknown'].includes(contact.freshness)) {
192
+ throw new TypeError('Case-response contact contains an unsupported enum.');
193
+ }
194
+ reviewText(contact.contact, 320, 'Case-response contact value');
195
+ reviewText(contact.source, 120, 'Case-response contact source');
196
+ reviewText(contact.observedAt, 64, 'Case-response contact observation time', true);
197
+ if (hasPlatformRoutes)
198
+ reviewText(contact.reviewAfter, 64, 'Case-response contact review deadline', true);
199
+ reviewStrings(contact.limitations, MAX_RESPONSE_LIMITATIONS, MAX_RESPONSE_LIMITATION_LENGTH, 'Case-response contact limitations');
200
+ }
201
+ if (hasActionBinding && source.recipientRoute !== null) {
202
+ const route = exactReviewRecord(source.recipientRoute, [
203
+ 'actionId', 'kind', 'contact', 'source', 'observedAt', ...(hasPlatformRoutes ? ['reviewAfter'] : []), 'freshness', 'limitations',
204
+ ], 'Case-response recipient route');
205
+ reviewText(route.actionId, 64, 'Case-response recipient action id');
206
+ reviewEnum(route.kind, [...(hasPlatformRoutes ? RESPONSE_CONTACT_KINDS : [...PRE_PLATFORM_CONTACT_KINDS]), 'manual'], 'Case-response recipient kind');
207
+ reviewText(route.contact, 320, 'Case-response recipient value');
208
+ reviewText(route.source, 120, 'Case-response recipient source');
209
+ reviewText(route.observedAt, 64, 'Case-response recipient observation time', true);
210
+ if (hasPlatformRoutes)
211
+ reviewText(route.reviewAfter, 64, 'Case-response recipient review deadline', true);
212
+ reviewEnum(route.freshness, ['current', 'stale', 'unknown'], 'Case-response recipient freshness');
213
+ reviewStrings(route.limitations, MAX_RESPONSE_LIMITATIONS, MAX_RESPONSE_LIMITATION_LENGTH, 'Case-response recipient limitations');
214
+ }
215
+ let currentLineageActionIds = null;
216
+ if (hasActionBinding) {
217
+ const actionBinding = exactReviewRecord(source.actionBinding, [
218
+ 'state', 'selectedActionId', 'lineageActionIds', 'limitations',
219
+ ], 'Case-response action binding');
220
+ const bindingState = reviewEnum(actionBinding.state, ['selected', 'not_selected'], 'Case-response action binding state');
221
+ const selectedActionId = reviewText(actionBinding.selectedActionId, 64, 'Case-response selected action id', true);
222
+ const lineageActionIds = reviewStrings(actionBinding.lineageActionIds, MAX_RESPONSE_ACTION_HISTORY, 64, 'Case-response action lineage');
223
+ currentLineageActionIds = lineageActionIds;
224
+ if ((bindingState === 'selected') !== Boolean(selectedActionId)
225
+ || (selectedActionId !== null && lineageActionIds[0] !== selectedActionId)
226
+ || new Set(lineageActionIds).size !== lineageActionIds.length) {
227
+ throw new TypeError('Case-response action binding is inconsistent.');
228
+ }
229
+ reviewStrings(actionBinding.limitations, MAX_RESPONSE_LIMITATIONS, MAX_RESPONSE_LIMITATION_LENGTH, 'Case-response action-binding limitations');
230
+ if (source.recipientRoute !== null
231
+ && source.recipientRoute.actionId !== selectedActionId) {
232
+ throw new TypeError('Case-response recipient route is not bound to the selected action.');
233
+ }
234
+ }
235
+ for (const candidate of boundedReviewArray(source.selectedEvidence, MAX_RESPONSE_SELECTED_EVIDENCE, 'Case-response selected evidence')) {
236
+ const evidence = exactReviewRecord(candidate, [
237
+ 'id', 'label', 'source', 'observedAt', 'completeness', 'limitations',
238
+ ], 'Case-response selected evidence item', version > PUBLISHED_V2_3_CASE_RESPONSE_REVIEW_INPUTS_VERSION ? ['observationHostname', 'webObservationMode'] : []);
239
+ reviewText(evidence.id, 64, 'Case-response evidence id');
240
+ reviewText(evidence.label, 80, 'Case-response evidence label');
241
+ reviewText(evidence.source, 120, 'Case-response evidence source');
242
+ if (evidence.observationHostname !== undefined && (typeof evidence.observationHostname !== 'string'
243
+ || !isValidAsciiHostname(evidence.observationHostname) || evidence.observationHostname !== evidence.observationHostname.toLowerCase())) {
244
+ throw new TypeError('Case-response evidence hostname is invalid.');
245
+ }
246
+ if (evidence.webObservationMode !== undefined && evidence.webObservationMode !== 'selected_url') {
247
+ throw new TypeError('Case-response web observation mode is invalid.');
248
+ }
249
+ reviewText(evidence.observedAt, 64, 'Case-response evidence observation time', version > PUBLISHED_V2_3_CASE_RESPONSE_REVIEW_INPUTS_VERSION);
250
+ reviewEnum(evidence.completeness, CASE_PIN_COMPLETENESS, 'Case-response evidence completeness');
251
+ reviewStrings(evidence.limitations, MAX_RESPONSE_LIMITATIONS, MAX_RESPONSE_LIMITATION_LENGTH, 'Case-response evidence limitations');
252
+ }
253
+ for (const candidate of boundedReviewArray(source.contradictions, MAX_RESPONSE_CONTRADICTIONS, 'Case-response contradictions')) {
254
+ const contradiction = exactReviewRecord(candidate, [
255
+ 'id', 'statement', 'state', 'limitations',
256
+ ], 'Case-response contradiction');
257
+ reviewText(contradiction.id, 64, 'Case-response contradiction id');
258
+ reviewText(contradiction.statement, 2_000, 'Case-response contradiction statement');
259
+ reviewEnum(contradiction.state, CASE_ASSERTION_STATES, 'Case-response contradiction state');
260
+ reviewStrings(contradiction.limitations, MAX_RESPONSE_LIMITATIONS, MAX_RESPONSE_LIMITATION_LENGTH, 'Case-response contradiction limitations');
261
+ }
262
+ const readiness = exactReviewRecord(source.readiness, [
263
+ 'profileId', 'rows', 'counts', 'limitations',
264
+ ], 'Case-response readiness');
265
+ if (typeof readiness.profileId !== 'string' || !supportedProfileIds.has(readiness.profileId)) {
266
+ throw new TypeError('Case-response readiness profile is unsupported.');
267
+ }
268
+ const readinessRows = boundedReviewArray(readiness.rows, RESPONSE_READINESS_ROW_IDS.length, 'Case-response readiness rows');
269
+ const readinessIds = new Set();
270
+ const readinessCounts = Object.fromEntries(RESPONSE_READINESS_STATES.map((state) => [state, 0]));
271
+ for (const candidate of readinessRows) {
272
+ const row = exactReviewRecord(candidate, [
273
+ 'id', 'label', 'state', 'detail', 'requiredForAuthorisation', 'limitations',
274
+ ], 'Case-response readiness row');
275
+ if (typeof row.id !== 'string'
276
+ || !RESPONSE_READINESS_ROW_IDS.includes(row.id)
277
+ || readinessIds.has(row.id)
278
+ || typeof row.state !== 'string'
279
+ || !RESPONSE_READINESS_STATES.includes(row.state)
280
+ || typeof row.requiredForAuthorisation !== 'boolean') {
281
+ throw new TypeError('Case-response readiness row is unsupported or duplicated.');
282
+ }
283
+ readinessIds.add(row.id);
284
+ readinessCounts[row.state] += 1;
285
+ reviewText(row.label, 120, 'Case-response readiness label');
286
+ reviewText(row.detail, 500, 'Case-response readiness detail');
287
+ reviewStrings(row.limitations, MAX_RESPONSE_LIMITATIONS, MAX_RESPONSE_LIMITATION_LENGTH, 'Case-response readiness limitations');
288
+ }
289
+ const counts = exactReviewRecord(readiness.counts, RESPONSE_READINESS_STATES, 'Case-response readiness counts');
290
+ for (const state of RESPONSE_READINESS_STATES) {
291
+ if (reviewCount(counts[state], RESPONSE_READINESS_ROW_IDS.length, `Case-response readiness ${state} count`) !== readinessCounts[state]) {
292
+ throw new TypeError('Case-response readiness counts do not match the exact rows.');
293
+ }
294
+ }
295
+ reviewStrings(readiness.limitations, MAX_RESPONSE_LIMITATIONS, MAX_RESPONSE_LIMITATION_LENGTH, 'Case-response readiness limitations');
296
+ for (const candidate of boundedReviewArray(source.artefactReferences, MAX_RESPONSE_ARTEFACT_REFERENCES, 'Case-response artefact references')) {
297
+ const reference = exactReviewRecord(candidate, [
298
+ 'id', 'label', 'mediaType', 'capturedAt', 'source', 'digestSha256',
299
+ 'byteLength', 'limitations',
300
+ ], 'Case-response artefact reference');
301
+ reviewText(reference.id, 64, 'Case-response artefact id');
302
+ reviewText(reference.label, 120, 'Case-response artefact label');
303
+ reviewText(reference.mediaType, 120, 'Case-response artefact media type');
304
+ reviewText(reference.capturedAt, 64, 'Case-response artefact capture time');
305
+ reviewText(reference.source, 120, 'Case-response artefact source');
306
+ if (typeof reference.digestSha256 !== 'string' || !/^[a-f0-9]{64}$/u.test(reference.digestSha256)) {
307
+ throw new TypeError('Case-response artefact digest is invalid.');
308
+ }
309
+ if (reference.byteLength !== null)
310
+ reviewCount(reference.byteLength, 100 * 1024 * 1024, 'Case-response artefact byte length');
311
+ reviewStrings(reference.limitations, MAX_RESPONSE_LIMITATIONS, MAX_RESPONSE_LIMITATION_LENGTH, 'Case-response artefact limitations');
312
+ }
313
+ const escalationActionIds = [];
314
+ for (const candidate of boundedReviewArray(source.escalationHistory, MAX_RESPONSE_ACTION_HISTORY, 'Case-response escalation history')) {
315
+ const action = exactReviewRecord(candidate, [
316
+ 'actionId', 'type', 'recipient', 'contactSource', 'state', 'reference',
317
+ ...(hasActionBinding ? ['routeObservedAt'] : []),
318
+ ...(hasPlatformRoutes ? ['routeReviewAfter'] : []),
319
+ 'providerOutcome', 'outcomeDetail', 'originActionId', 'historyOmitted',
320
+ 'historyLimitations', 'transitions', 'createdAt', 'updatedAt',
321
+ ], 'Case-response escalation action');
322
+ for (const key of ['actionId', 'recipient', 'contactSource', 'createdAt', 'updatedAt']) {
323
+ reviewText(action[key], MAX_RESPONSE_VALUE_LENGTH, `Case-response action ${key}`);
324
+ }
325
+ if (escalationActionIds.includes(action.actionId)) {
326
+ throw new TypeError('Case-response escalation action identities must be unique.');
327
+ }
328
+ escalationActionIds.push(action.actionId);
329
+ if (hasActionBinding)
330
+ reviewText(action.routeObservedAt, 64, 'Case-response action route observation time', true);
331
+ if (hasPlatformRoutes)
332
+ reviewText(action.routeReviewAfter, 64, 'Case-response action route review deadline', true);
333
+ reviewEnum(action.type, hasPlatformRoutes ? CASE_ACTION_TYPES : PRE_PLATFORM_ACTION_TYPES, 'Case-response action type');
334
+ reviewEnum(action.state, CASE_ACTION_STATES, 'Case-response action state');
335
+ for (const key of ['reference', 'outcomeDetail', 'originActionId']) {
336
+ reviewText(action[key], MAX_RESPONSE_VALUE_LENGTH, `Case-response action ${key}`, true);
337
+ }
338
+ reviewNullableEnum(action.providerOutcome, CASE_PROVIDER_OUTCOMES, 'Case-response action provider outcome');
339
+ reviewCount(action.historyOmitted, MAX_CASE_ACTION_EVENTS_PER_CASE, 'Case-response omitted transition count');
340
+ reviewStrings(action.historyLimitations, MAX_RESPONSE_LIMITATIONS, MAX_RESPONSE_LIMITATION_LENGTH, 'Case-response action history limitations');
341
+ for (const candidateTransition of boundedReviewArray(action.transitions, MAX_CASE_ACTION_EVENTS_PER_ACTION, 'Case-response transitions')) {
342
+ const transition = exactReviewRecord(candidateTransition, [
343
+ 'id', 'previousState', 'nextState', 'occurredAt', 'sourceClass', 'provenance',
344
+ 'reference', 'evidencePinId', 'limitations', 'providerOutcome', 'outcomeDetail',
345
+ 'originActionId', 'applied',
346
+ ], 'Case-response transition');
347
+ for (const key of ['id', 'occurredAt', 'provenance']) {
348
+ reviewText(transition[key], MAX_RESPONSE_VALUE_LENGTH, `Case-response transition ${key}`);
349
+ }
350
+ reviewNullableEnum(transition.previousState, CASE_ACTION_STATES, 'Case-response previous action state');
351
+ reviewEnum(transition.nextState, CASE_ACTION_STATES, 'Case-response next action state');
352
+ reviewEnum(transition.sourceClass, CASE_ACTION_EVENT_SOURCE_CLASSES, 'Case-response transition source class');
353
+ for (const key of ['reference', 'evidencePinId', 'outcomeDetail', 'originActionId']) {
354
+ reviewText(transition[key], MAX_RESPONSE_VALUE_LENGTH, `Case-response transition ${key}`, true);
355
+ }
356
+ reviewNullableEnum(transition.providerOutcome, CASE_PROVIDER_OUTCOMES, 'Case-response transition provider outcome');
357
+ if (typeof transition.applied !== 'boolean')
358
+ throw new TypeError('Case-response transition applied state is invalid.');
359
+ reviewStrings(transition.limitations, MAX_RESPONSE_LIMITATIONS, MAX_RESPONSE_LIMITATION_LENGTH, 'Case-response transition limitations');
360
+ }
361
+ }
362
+ if (currentLineageActionIds
363
+ && (currentLineageActionIds.length !== escalationActionIds.length
364
+ || escalationActionIds.some((id, index) => currentLineageActionIds?.[index] !== id))) {
365
+ throw new TypeError('Case-response escalation history must match the selected action lineage in order.');
366
+ }
367
+ reviewCount(source.escalationHistoryOmitted, MAX_CASE_ACTIONS, 'Case-response omitted action count');
368
+ reviewStrings(source.escalationHistoryLimitations, MAX_RESPONSE_LIMITATIONS, MAX_RESPONSE_LIMITATION_LENGTH, 'Case-response escalation limitations');
369
+ validateReviewLifecycle(source.responseLifecycle);
370
+ return recursivelyFreezeReviewValue(structuredClone(source));
371
+ }
@@ -0,0 +1,85 @@
1
+ // Bounded value normalisation shared by Case response responsibilities.
2
+ import { MAX_DECISION_PIN_REFERENCES, MAX_RESPONSE_LIMITATIONS, MAX_RESPONSE_LIMITATION_LENGTH, } from "../contracts/case-portability.mjs";
3
+ import { normalizeExplicitIsoTimestamp, normalizeLegacyIsoTimestamp, } from "../evidence/observation.mjs";
4
+ import { CASE_PIN_COMPLETENESS, } from "./case-response-records.mjs";
5
+ export const SAFE_ID_RE = /^[A-Za-z0-9_-]{1,64}$/u;
6
+ const CONTROL_REPLACE_RE = /[\u0000-\u001f\u007f]+/gu;
7
+ export const COMPLETENESS = new Set(CASE_PIN_COMPLETENESS);
8
+ export function record(value) {
9
+ return value && typeof value === 'object' && !Array.isArray(value)
10
+ ? value
11
+ : {};
12
+ }
13
+ export function text(value, maximum) {
14
+ if (typeof value !== 'string')
15
+ return '';
16
+ return value.replace(CONTROL_REPLACE_RE, ' ').trim().slice(0, maximum);
17
+ }
18
+ function timestamp(value, options = {}) {
19
+ return normalizeExplicitIsoTimestamp(value)
20
+ ?? (options.legacyTimestamps ? normalizeLegacyIsoTimestamp(value) : null);
21
+ }
22
+ export function iso(value, fallback, options = {}) {
23
+ return timestamp(value, options) ?? fallback;
24
+ }
25
+ export function optionalIso(value, options = {}) {
26
+ return timestamp(value, options);
27
+ }
28
+ function hash(value) {
29
+ let result = 2166136261 >>> 0;
30
+ for (let index = 0; index < value.length; index += 1) {
31
+ result ^= value.charCodeAt(index);
32
+ result = Math.imul(result, 16777619);
33
+ }
34
+ return (result >>> 0).toString(36);
35
+ }
36
+ export function deterministicId(prefix, value) {
37
+ return `${prefix}-${hash(JSON.stringify(value))}`;
38
+ }
39
+ export function freshId(prefix) {
40
+ if (typeof globalThis.crypto?.randomUUID === 'function')
41
+ return globalThis.crypto.randomUUID();
42
+ return `${prefix}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
43
+ }
44
+ export function safeId(value, prefix, raw) {
45
+ return typeof value === 'string' && SAFE_ID_RE.test(value)
46
+ ? value
47
+ : deterministicId(prefix, raw);
48
+ }
49
+ export function limitations(value) {
50
+ if (!Array.isArray(value))
51
+ return [];
52
+ const unique = new Set();
53
+ for (const item of value.slice(0, MAX_RESPONSE_LIMITATIONS * 2)) {
54
+ const normalized = text(item, MAX_RESPONSE_LIMITATION_LENGTH);
55
+ if (normalized)
56
+ unique.add(normalized);
57
+ if (unique.size >= MAX_RESPONSE_LIMITATIONS)
58
+ break;
59
+ }
60
+ return [...unique];
61
+ }
62
+ export function compareCodeUnits(left, right) {
63
+ return left < right ? -1 : left > right ? 1 : 0;
64
+ }
65
+ export function lifecycleLimitations(value) {
66
+ return limitations(value).sort(compareCodeUnits);
67
+ }
68
+ export function uniqueIds(value, validIds) {
69
+ if (!Array.isArray(value))
70
+ return [];
71
+ const unique = new Set();
72
+ for (const item of value.slice(0, MAX_DECISION_PIN_REFERENCES * 2)) {
73
+ if (typeof item !== 'string' || !SAFE_ID_RE.test(item) || (validIds && !validIds.has(item)))
74
+ continue;
75
+ unique.add(item);
76
+ if (unique.size >= MAX_DECISION_PIN_REFERENCES)
77
+ break;
78
+ }
79
+ return [...unique];
80
+ }
81
+ export function boundedCounter(value) {
82
+ return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0
83
+ ? Math.min(value, 1_000_000)
84
+ : 0;
85
+ }
@@ -0,0 +1,45 @@
1
+ import { CASE_SCHEMA_VERSION, MAX_EDITABLE_CASE_OUTPUT_BYTES } from "../contracts/case-portability.mjs";
2
+ import { MAX_SELECTED_FILE_TOTAL_BYTES } from "../contracts/selected-file-limits.mjs";
3
+ import { canonicalArtifactJsonV2 } from "../evidence/artifact-integrity.mjs";
4
+ import { readCaseAttachments } from "./case-attachment-model.mjs";
5
+ import { readEditableCaseExport } from "./case-export-input.mjs";
6
+ import { buildCaseExport } from "./case-storage-model.mjs";
7
+ /** Uses the ordinary Case export; no parallel handoff schema or draft fields. */
8
+ export function caseReviewDocument(record, now) {
9
+ const text = `${JSON.stringify(buildCaseExport([record], now))}\n`;
10
+ if (new TextEncoder().encode(text).byteLength > MAX_EDITABLE_CASE_OUTPUT_BYTES)
11
+ throw new Error('The complete Case exceeds its review-file bound. No shortened copy was created.');
12
+ const checked = readCaseReviewDocument(text);
13
+ if (canonicalArtifactJsonV2(checked) !== canonicalArtifactJsonV2(record))
14
+ throw new Error('The Case cannot be exported without changing retained content. Nothing was prepared.');
15
+ return text;
16
+ }
17
+ export function readCaseReviewDocument(text) {
18
+ const cases = readEditableCaseExport(text);
19
+ const root = JSON.parse(text);
20
+ if (root.version !== CASE_SCHEMA_VERSION || cases.length !== 1)
21
+ throw new Error('Select an ordinary current export containing exactly one Case.');
22
+ return cases[0];
23
+ }
24
+ export function caseReviewFileSelection(record, ids) {
25
+ const references = readCaseAttachments(record.attachments) ?? [];
26
+ if (!Array.isArray(ids) || ids.length > references.length || new Set(ids).size !== ids.length)
27
+ throw new Error('The Case file selection is invalid.');
28
+ const wanted = new Set(ids), selected = references.filter(item => wanted.has(item.id));
29
+ if (selected.length !== ids.length)
30
+ throw new Error('A selected file reference is no longer in this Case.');
31
+ const unique = new Map();
32
+ for (const item of selected)
33
+ unique.set(item.digestSha256, item);
34
+ const files = [...unique.values()];
35
+ const bytes = files.reduce((sum, item) => sum + item.byteLength, 0);
36
+ if (bytes > MAX_SELECTED_FILE_TOTAL_BYTES)
37
+ throw new Error('The selected original bytes exceed the bounded handoff payload. Select a smaller group explicitly; nothing was omitted automatically.');
38
+ return { references, selected, files, bytes, omitted: references.filter(item => !unique.has(item.digestSha256)) };
39
+ }
40
+ /** Match bytes, not filenames or source claims. Duplicated byte identities are not source identities. */
41
+ export function matchCaseReviewFiles(record, files) {
42
+ return (readCaseAttachments(record.attachments) ?? []).map(attachment => ({ attachment,
43
+ entries: files.filter(file => file.digestSha256 === attachment.digestSha256 && file.byteLength === attachment.byteLength).map(file => file.id),
44
+ }));
45
+ }
@@ -0,0 +1,46 @@
1
+ import { normalizeDomain } from "../evidence/domain-name.mjs";
2
+ export class CaseSelectionError extends Error {
3
+ constructor(message) { super(message); this.name = 'CaseSelectionError'; }
4
+ }
5
+ export function casesForDomain(records, value) {
6
+ const domain = normalizeDomain(value);
7
+ return domain ? records.filter(record => record.domain === domain) : [];
8
+ }
9
+ export function casesForDomains(records, domains) {
10
+ const selected = new Set(domains.map(normalizeDomain).filter(Boolean));
11
+ return records.filter(record => selected.has(record.domain));
12
+ }
13
+ /** A domain pivot cannot choose between independent analyst investigations. */
14
+ export function selectExistingCase(records, domain, selection = {}) {
15
+ if (selection.newIncident && selection.caseId)
16
+ throw new CaseSelectionError('Choose an existing Case or create a new incident, not both.');
17
+ if (selection.newIncident)
18
+ return null;
19
+ const matches = casesForDomain(records, domain);
20
+ if (selection.caseId) {
21
+ const selected = matches.find(record => record.id === selection.caseId);
22
+ if (!selected)
23
+ throw new CaseSelectionError('The selected Case is no longer available for this domain. No data was changed.');
24
+ return selected;
25
+ }
26
+ if (matches.length > 1)
27
+ throw new CaseSelectionError(`Several Cases use ${domain}. Select the intended incident before saving evidence or decisions.`);
28
+ return matches[0] ?? null;
29
+ }
30
+ /** Only explicit or unambiguous selections may carry Case decisions into a domain view. */
31
+ export function selectedCasesByDomain(records, selections = new Map()) {
32
+ const grouped = new Map();
33
+ for (const record of records) {
34
+ const group = grouped.get(record.domain) ?? [];
35
+ group.push(record);
36
+ grouped.set(record.domain, group);
37
+ }
38
+ const selected = new Map();
39
+ for (const [domain, group] of grouped) {
40
+ const id = selections.get(domain);
41
+ const record = id ? group.find(item => item.id === id) : group.length === 1 ? group[0] : undefined;
42
+ if (record)
43
+ selected.set(domain, record);
44
+ }
45
+ return selected;
46
+ }