@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,195 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { boundedJsonLimitsForBytes, parseBoundedJsonObject } from "../lib/bounded-json.mjs";
3
+ import { canonicalArtifactJsonV2 } from "../packages/evidence/artifact-integrity.mjs";
4
+ import { normalizeExplicitIsoTimestamp } from "../packages/evidence/observation.mjs";
5
+ import { MAX_CASE_CHECKPOINT_FACTS, MAX_CASE_OBJECTIVE_LENGTH, MAX_CASE_STORE_BYTES, MAX_EDITABLE_CASE_INPUT_BYTES, MAX_NOTE_LENGTH, MAX_RESPONSE_RATIONALE_LENGTH, MAX_RESPONSE_VALUE_LENGTH } from "../packages/contracts/case-portability.mjs";
6
+ import { readEditableCaseExport } from "../packages/cases/case-export-input.mjs";
7
+ import { createCaseIncident, openOrCreateCase, recordCaseConclusion, recordCaseRecheckOutcome, updateCase } from "../packages/cases/case-record-operations.mjs";
8
+ import { buildCaseExport, serializeCaseStore } from "../packages/cases/case-storage-model.mjs";
9
+ import { appendCaseEvidencePin } from "../packages/cases/case-response-model.mjs";
10
+ import { readCaseRecheckAnswerContext } from "../packages/cases/case-recheck-model.mjs";
11
+ import { dispositionLabel, statusLabel } from "../packages/cases/case-record-decisions.mjs";
12
+ import { normalizeDomain } from "../packages/cases/case-record-core.mjs";
13
+ import { safeTerminalValue } from "./formatters/terminal.mjs";
14
+ import { CliUsageError } from "./errors.mjs";
15
+ import EXIT_CODES from "./exit-codes.mjs";
16
+ function fields(input, allowed, label) {
17
+ if (Object.keys(input).some(key => !allowed.includes(key)))
18
+ throw new CliUsageError(`${label} contains an unsupported field.`);
19
+ }
20
+ function prose(input, maximum, label) {
21
+ if (typeof input !== 'string' || !input.trim() || input.trim().length > maximum
22
+ || /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/u.test(input)) {
23
+ throw new CliUsageError(`${label} must contain 1–${maximum} characters without unsafe control characters.`);
24
+ }
25
+ return input.trim();
26
+ }
27
+ function selection(cases, args, required) {
28
+ const domain = args.domain === null ? null : normalizeDomain(args.domain);
29
+ if (args.domain !== null && !domain)
30
+ throw new CliUsageError('Select a valid Case domain.');
31
+ const selected = cases.filter(record => (!args.caseId || record.id === args.caseId) && (!domain || record.domain === domain));
32
+ if ((args.caseId || domain || required) && selected.length === 0)
33
+ throw new CliUsageError('The selected Case is not present in this file.');
34
+ if (required && selected.length !== 1)
35
+ throw new CliUsageError('Select exactly one Case with --case-id; no arbitrary Case was chosen.');
36
+ return selected;
37
+ }
38
+ function pin(input, now) {
39
+ if (!input || typeof input !== 'object' || Array.isArray(input))
40
+ throw new CliUsageError('An evidence pin must be a JSON object.');
41
+ const raw = input;
42
+ if (Object.hasOwn(raw, 'id') || Object.hasOwn(raw, 'createdAt'))
43
+ throw new CliUsageError('New pin identity and creation time are allocated by the Case operation.');
44
+ const normal = appendCaseEvidencePin([], raw, now)[0];
45
+ // Validate the supplied subset against the domain result, without duplicating
46
+ // its field inventory or accepting silent truncation/default substitution.
47
+ for (const [key, value] of Object.entries(raw)) {
48
+ if (!Object.hasOwn(normal, key) || canonicalArtifactJsonV2(value) !== canonicalArtifactJsonV2(normal[key])) {
49
+ throw new CliUsageError(`Pin field ${safeTerminalValue(key)} is not canonical or exceeds its bound. Use explicit ISO timestamps and supported values; no pin was saved.`);
50
+ }
51
+ }
52
+ return raw;
53
+ }
54
+ function retainExistingEntries(before, after) {
55
+ const pairs = [
56
+ [before.notes, after.notes], [before.evidenceHistory, after.evidenceHistory], [before.evidencePins, after.evidencePins],
57
+ [before.decisions, after.decisions], [before.actions, after.actions], [before.assertions, after.assertions],
58
+ [before.manualTrail, after.manualTrail], [before.sightings, after.sightings],
59
+ [before.observedEffects.reviews, after.observedEffects.reviews], [before.closures.records, after.closures.records],
60
+ [before.branches ?? [], after.branches ?? []], [before.attachments ?? [], after.attachments ?? []],
61
+ ];
62
+ for (const [prior, next] of pairs) {
63
+ const retained = new Set(next.map(value => canonicalArtifactJsonV2(value)));
64
+ if (prior.some(value => !retained.has(canonicalArtifactJsonV2(value)))) {
65
+ throw new CliUsageError('The operation would remove or alter earlier retained evidence. Export a smaller working set or review it in the console; nothing was written.');
66
+ }
67
+ }
68
+ }
69
+ function mutate(cases, args, input, note, now) {
70
+ if (args.operation === 'open') {
71
+ const title = args.title === null ? undefined : prose(args.title, MAX_CASE_OBJECTIVE_LENGTH, 'Incident title');
72
+ const result = args.newIncident
73
+ ? createCaseIncident(cases, { domain: args.domain, title }, now)
74
+ : openOrCreateCase(cases, { domain: args.domain, ...(title ? { title } : {}) }, now, args.caseId ? { caseId: args.caseId } : {});
75
+ if (!result.created && title && title !== result.record.title)
76
+ throw new CliUsageError('The selected Case already exists with another title. Use --new-incident for a separate investigation.');
77
+ return result.cases;
78
+ }
79
+ const current = selection(cases, args, true)[0];
80
+ let result;
81
+ if (args.operation === 'note') {
82
+ result = updateCase(cases, current.id, { note: prose(note, MAX_NOTE_LENGTH, 'Case note') }, now);
83
+ }
84
+ else if (args.operation === 'pin') {
85
+ result = updateCase(cases, current.id, { evidencePin: pin(input, now) }, now);
86
+ }
87
+ else if (args.operation === 'assess') {
88
+ if (!input)
89
+ throw new CliUsageError('An assessment requires JSON input.');
90
+ fields(input, ['disposition', 'reviewReasonCode', 'summary', 'rationale', 'evidence'], 'Assessment');
91
+ if (!Array.isArray(input.evidence) || !input.evidence.length || input.evidence.length > MAX_CASE_CHECKPOINT_FACTS) {
92
+ throw new CliUsageError(`Select 1–${MAX_CASE_CHECKPOINT_FACTS} evidence facts for the assessment.`);
93
+ }
94
+ const evidence = input.evidence.map(value => {
95
+ if (!value || typeof value !== 'object' || Array.isArray(value))
96
+ throw new CliUsageError('Each assessment fact needs a pin and stance.');
97
+ fields(value, ['pin', 'pinId', 'stance'], 'Assessment evidence');
98
+ if (!['supports', 'contradicts', 'unresolved'].includes(value.stance))
99
+ throw new CliUsageError('Use supports, contradicts or unresolved for each evidence stance.');
100
+ if (Object.hasOwn(value, 'pin') === Object.hasOwn(value, 'pinId'))
101
+ throw new CliUsageError('Select one new pin or one retained pinId for each assessment fact.');
102
+ return Object.hasOwn(value, 'pinId') ? { pinId: value.pinId, stance: value.stance } : { pin: pin(value.pin, now), stance: value.stance };
103
+ });
104
+ const summary = prose(input.summary, MAX_RESPONSE_VALUE_LENGTH, 'Assessment summary');
105
+ const rationale = prose(input.rationale, MAX_RESPONSE_RATIONALE_LENGTH, 'Assessment rationale');
106
+ result = recordCaseConclusion(cases, current.id, { disposition: input.disposition, reviewReasonCode: input.reviewReasonCode, summary, rationale, evidence }, now);
107
+ if (result.record.decisions.at(-1)?.summary !== summary || result.record.decisions.at(-1)?.rationale !== rationale) {
108
+ throw new CliUsageError('Assessment text needs canonical single-line values; no assessment was written.');
109
+ }
110
+ }
111
+ else if (args.operation === 'recheck') {
112
+ if (!input)
113
+ throw new CliUsageError('A recheck requires JSON input.');
114
+ fields(input, ['state', 'observedAt', 'completeness', 'comparisonSummary', 'source', 'followUpAt', 'limitations', 'collectionDepth', 'recheck', 'observationHostname'], 'Recheck');
115
+ const recheck = readCaseRecheckAnswerContext(input.recheck);
116
+ if (input.state === 'not_reproduced' && !recheck) {
117
+ throw new CliUsageError('Not reproduced requires a saved recheck question and confirmed comparable conditions. Import the planned Case or record unavailable for a limited observation.');
118
+ }
119
+ const summary = prose(input.comparisonSummary, MAX_RESPONSE_VALUE_LENGTH, 'Comparison summary');
120
+ result = recordCaseRecheckOutcome(cases, current.id, {
121
+ ...input, state: input.state, observedAt: input.observedAt, completeness: input.completeness,
122
+ comparisonSummary: summary, source: input.source, ...(recheck ? { recheck } : {}),
123
+ }, now);
124
+ const review = result.record.observedEffects.reviews.at(-1);
125
+ const comparison = result.record.evidencePins.find(value => value.id === review.evidencePinId);
126
+ const projected = { state: review.state, observedAt: review.observedAt, completeness: review.completeness,
127
+ comparisonSummary: comparison.value, source: review.source, followUpAt: review.followUpAt,
128
+ limitations: review.limitations, collectionDepth: comparison.collectionDepth,
129
+ ...(review.recheck ? { recheck: review.recheck } : {}),
130
+ ...(comparison.observationHostname ? { observationHostname: comparison.observationHostname } : {}),
131
+ };
132
+ for (const [key, value] of Object.entries(input)) {
133
+ if (canonicalArtifactJsonV2(value) !== canonicalArtifactJsonV2(projected[key]))
134
+ throw new CliUsageError(`Recheck field ${safeTerminalValue(key)} would be repaired or truncated; nothing was written.`);
135
+ }
136
+ }
137
+ else
138
+ throw new CliUsageError('Select a supported Case mutation.');
139
+ retainExistingEntries(current, result.record);
140
+ return result.cases;
141
+ }
142
+ function formatCases(cases, digest) {
143
+ const lines = ['Local Case review', `File digest ${digest}`, `Cases ${cases.length}`, ''];
144
+ for (const record of cases) {
145
+ lines.push(`Case ${safeTerminalValue(record.id)} · ${safeTerminalValue(record.title || record.domain)}`, `Domain ${safeTerminalValue(record.domain)}`, `Status ${statusLabel(record.status)}`, `Disposition ${dispositionLabel(record.disposition)} (analyst assessment)`, `Updated ${record.updatedAt}`);
146
+ for (const note of record.notes)
147
+ lines.push(`Note ${note.createdAt}`, safeTerminalValue(note.body, '—', MAX_NOTE_LENGTH));
148
+ for (const pin of record.evidencePins)
149
+ lines.push(`Pin ${safeTerminalValue(pin.id)} · ${safeTerminalValue(pin.label)}`, safeTerminalValue(pin.value, '—', MAX_RESPONSE_VALUE_LENGTH), ` Source ${safeTerminalValue(pin.source)} · ${safeTerminalValue(pin.observedAt, 'time unavailable')} · ${pin.completeness}`);
150
+ for (const decision of record.decisions)
151
+ lines.push(`Assessment ${decision.createdAt}`, safeTerminalValue(decision.summary, '—', MAX_RESPONSE_VALUE_LENGTH), safeTerminalValue(decision.rationale, '—', MAX_RESPONSE_RATIONALE_LENGTH));
152
+ for (const review of record.observedEffects.reviews)
153
+ lines.push(`Recheck ${safeTerminalValue(review.observedAt, 'time unavailable')} · ${review.state} · ${review.completeness} · ${safeTerminalValue(review.source)}`);
154
+ lines.push(`Other retained records: ${record.evidenceHistory.length} snapshots, ${record.actions.length} actions, ${record.assertions.length} assertions, ${record.attachments?.length ?? 0} file references.`, '');
155
+ }
156
+ lines.push('Use --json for the full Case export. Attached file bytes remain separate. This command makes no request.', '');
157
+ return lines.join('\n');
158
+ }
159
+ export async function runCaseCommand(args, dependencies, context) {
160
+ context.setFailureLabel('Local Case operation');
161
+ try {
162
+ const raw = args.source ? dependencies.caseFileInput ?? await context.readInput(args.source, MAX_EDITABLE_CASE_INPUT_BYTES, 'Case file') : null;
163
+ const digest = raw === null ? null : `sha256:${createHash('sha256').update(raw).digest('hex')}`;
164
+ if (args.expectedFileDigest && args.expectedFileDigest !== digest)
165
+ throw new CliUsageError('The Case file differs from the reviewed file digest. Nothing was written.');
166
+ const cases = raw === null ? [] : readEditableCaseExport(raw);
167
+ const now = normalizeExplicitIsoTimestamp(context.now());
168
+ if (!now)
169
+ throw new CliUsageError('A valid current timestamp is required.');
170
+ if (args.operation === 'show') {
171
+ const selected = selection(cases, args, false);
172
+ context.writeStdout(args.output === 'json' ? `${JSON.stringify(buildCaseExport(selected, now))}\n` : context.terminal(formatCases(selected, digest), args.color));
173
+ return EXIT_CODES.SUCCESS;
174
+ }
175
+ const input = args.inputSource ? parseBoundedJsonObject(await context.readInput(args.inputSource, MAX_EDITABLE_CASE_INPUT_BYTES, 'Case operation input'), {
176
+ maximumBytes: MAX_EDITABLE_CASE_INPUT_BYTES, limits: boundedJsonLimitsForBytes(MAX_CASE_STORE_BYTES), label: 'Case operation input',
177
+ }) : null;
178
+ const note = args.noteSource ? await context.readInput(args.noteSource, MAX_NOTE_LENGTH * 4 + 4, 'Case note') : args.text;
179
+ const next = mutate(cases, args, input, note, now);
180
+ if (Buffer.byteLength(serializeCaseStore(next)) > MAX_CASE_STORE_BYTES)
181
+ throw new CliUsageError('The updated Case store exceeds its byte budget. No evidence was pruned and nothing was written.');
182
+ const output = `${JSON.stringify(buildCaseExport(next, now))}\n`;
183
+ // Apply the same strict admission to our output before atomic publication.
184
+ readEditableCaseExport(output);
185
+ dependencies.signal?.throwIfAborted();
186
+ context.writeStdout(output);
187
+ return EXIT_CODES.SUCCESS;
188
+ }
189
+ catch (cause) {
190
+ dependencies.signal?.throwIfAborted();
191
+ if (cause instanceof CliUsageError)
192
+ throw cause;
193
+ throw new CliUsageError(cause instanceof Error ? cause.message : 'The Case operation could not be completed.');
194
+ }
195
+ }
package/cli/case-pack.mjs CHANGED
@@ -1,45 +1,20 @@
1
1
  import { createHash } from 'node:crypto';
2
2
  import { Buffer } from 'node:buffer';
3
3
  import { canonicalArtifactJson, canonicalArtifactJsonV2, SORTED_JSON_V2, } from "../packages/evidence/artifact-integrity.mjs";
4
- import { buildCaseReport } from "../packages/cases/case-report.mjs";
4
+ import { buildCaseReport, buildCaseReportVerificationProjection } from "../packages/cases/case-report.mjs";
5
5
  import { WHOISLEUTH_APPLICATION_VERSION } from "../lib/application-version.mjs";
6
6
  import { assertBoundedJsonStructure, scanBoundedJson } from "../lib/bounded-json.mjs";
7
- import { assertCaseBrandProfileIds, normalizeDomain, normalizeCaseStore, safeId, } from "../packages/cases/case-model.mjs";
8
- import { CASE_REPORT_SCHEMA, CASE_IMPORT_VERSIONS, CASE_SCHEMA_VERSION, CLI_CASE_PACK_SCHEMA, CLI_CASE_PACK_VERSION, CLI_CASE_PACK_CURRENT_REDACTION_KEYS, CLI_CASE_PACK_PUBLIC_REPORT_KEYS, CLI_CASE_PACK_INTEGRITY_KEYS as INTEGRITY_KEYS, CLI_CASE_PACK_LIMITATIONS as PACKET_LIMITATIONS, CLI_CASE_PACK_PACKET_KEYS as PACKET_KEYS, CLI_CASE_PACK_REPORT_KEYS as REPORT_KEYS, CLI_CASE_PACK_ROOT_KEYS as ROOT_KEYS, MAX_CASE_IMPORT_BYTES, MAX_CASE_PACK_CASES, MAX_CASE_PACK_INPUT_BYTES, PUBLIC_CASE_SCHEMA_VERSION, caseReportVersionMatchesCase, } from "../packages/contracts/case-portability.mjs";
7
+ import { assertCaseBrandProfileIds, CASE_AUDIENCE_SENSITIVE_FIELD_NAMES, caseAudienceExclusions, normalizeDomain, normalizeCaseStore, projectCaseForAudience, safeId, } from "../packages/cases/case-model.mjs";
8
+ import { CASE_REPORT_SCHEMA, CASE_IMPORT_VERSIONS, CASE_SCHEMA_VERSION, INCIDENT_CASE_SCHEMA_VERSION, CLI_CASE_PACK_SCHEMA, CLI_CASE_PACK_VERSION, CLI_CASE_PACK_INPUT_CASE_VERSIONS, CLI_CASE_PACK_CURRENT_REDACTION_KEYS, CLI_CASE_PACK_PUBLIC_REPORT_KEYS, CLI_CASE_PACK_INTEGRITY_KEYS as INTEGRITY_KEYS, CLI_CASE_PACK_LIMITATIONS as PACKET_LIMITATIONS, CLI_CASE_PACK_PACKET_KEYS as PACKET_KEYS, CLI_CASE_PACK_REPORT_KEYS as REPORT_KEYS, CLI_CASE_PACK_ROOT_KEYS as ROOT_KEYS, MAX_CASE_IMPORT_BYTES, MAX_CASE_PACK_CASES, MAX_CASE_PACK_INPUT_BYTES, PUBLIC_CASE_SCHEMA_VERSION, PUBLISHED_V2_3_CASE_SCHEMA_VERSION, caseReportVersionMatchesCase, } from "../packages/contracts/case-portability.mjs";
9
9
  import { CliUsageError } from "./errors.mjs";
10
+ import { canonicalCaseExportProjection as canonicalPackProjection } from "../packages/cases/case-export-input.mjs";
10
11
  export { CLI_CASE_PACK_SCHEMA, CLI_CASE_PACK_VERSION, MAX_CASE_PACK_CASES, MAX_CASE_PACK_INPUT_BYTES, };
11
12
  const MAX_CASE_PACK_REFERENCE_SCAN_DEPTH = 64;
12
- const INTERNAL_EXCLUSIONS = Object.freeze(['Raw upstream payloads and credentials are outside the case schema.']);
13
- const TRUSTED_EXCLUSIONS = Object.freeze(['Case notes', 'Recipient values', 'Manual trail targets', 'Raw upstream payloads and credentials']);
14
- const PUBLIC_EXCLUSIONS = Object.freeze([
15
- 'Case notes',
16
- 'Brand Profile references',
17
- 'Actions and recipient values',
18
- 'Analyst assertions',
19
- 'Investigation branches',
20
- 'Manual trail targets',
21
- 'Raw upstream payloads and credentials',
22
- 'Independent observed-effect reviews and closure history',
23
- ]);
24
- const PUBLIC_V12_EXCLUSIONS = Object.freeze(PUBLIC_EXCLUSIONS.slice(0, -1));
25
- const SENSITIVE_CASE_PACK_FIELDS = Object.freeze([
26
- 'brandProfileIds', 'notes', 'actions', 'assertions', 'manualTrail', 'observedEffects',
27
- 'closures', 'branches', 'recipient', 'target',
28
- ]);
29
- const PUBLIC_OBSERVED_EFFECT_LIMITATION = 'Independent observed-effect review records were excluded from this public Case pack.';
30
- const PUBLIC_CLOSURE_LIMITATION = 'Deliberate closure records were excluded from this public Case pack.';
31
13
  function record(value) {
32
14
  return value && typeof value === 'object' && !Array.isArray(value)
33
15
  ? value
34
16
  : null;
35
17
  }
36
- function expectedExclusions(audience, caseVersion = CASE_SCHEMA_VERSION) {
37
- if (audience === 'internal')
38
- return INTERNAL_EXCLUSIONS;
39
- if (audience === 'trusted')
40
- return TRUSTED_EXCLUSIONS;
41
- return caseVersion === PUBLIC_CASE_SCHEMA_VERSION ? PUBLIC_V12_EXCLUSIONS : PUBLIC_EXCLUSIONS;
42
- }
43
18
  function stringListsMatch(value, expected) {
44
19
  return Array.isArray(value)
45
20
  && value.length === expected.length
@@ -105,7 +80,7 @@ function assertSensitiveFieldPlacement(root, serializedLength) {
105
80
  const entry = stack.pop();
106
81
  if (!entry || !entry.value || typeof entry.value !== 'object')
107
82
  continue;
108
- for (const key of SENSITIVE_CASE_PACK_FIELDS) {
83
+ for (const key of CASE_AUDIENCE_SENSITIVE_FIELD_NAMES) {
109
84
  if (Object.hasOwn(entry.value, key) && !allowedSensitiveFieldPath(key, entry.path)) {
110
85
  throw new TypeError('The CLI case pack contains audience-sensitive data outside its versioned case or report fields.');
111
86
  }
@@ -151,7 +126,7 @@ function canonicalValuesMatch(left, right) {
151
126
  return false;
152
127
  }
153
128
  }
154
- function assertCanonicalCaseIdentities(cases, label) {
129
+ function assertCanonicalCaseIdentities(cases, label, caseVersion) {
155
130
  const ids = new Set();
156
131
  const domains = new Set();
157
132
  for (const value of cases) {
@@ -162,50 +137,27 @@ function assertCanonicalCaseIdentities(cases, label) {
162
137
  || typeof item.domain !== 'string'
163
138
  || normalizeDomain(item.domain) !== item.domain
164
139
  || ids.has(item.id)
165
- || domains.has(item.domain)) {
140
+ || (caseVersion < INCIDENT_CASE_SCHEMA_VERSION && domains.has(item.domain))) {
166
141
  throw new TypeError(`${label} contains a missing, unsafe, non-canonical, or duplicate Case identity.`);
167
142
  }
168
143
  ids.add(item.id);
169
144
  domains.add(item.domain);
170
145
  }
171
146
  }
172
- function assertCurrentCaseProjection(rawCases, normalised, label) {
173
- if (rawCases.length !== normalised.length
174
- || rawCases.some((item, index) => !canonicalValuesMatch(item, normalised[index]))) {
175
- throw new TypeError(`${label} contains a schema ${CASE_SCHEMA_VERSION} Case that would be repaired, truncated, or otherwise changed during normalisation.`);
147
+ function assertCurrentCaseProjection(rawCases, normalised, label, caseVersion = CASE_SCHEMA_VERSION) {
148
+ if (rawCases.length !== normalised.length || rawCases.some((item, index) => {
149
+ const raw = record(item);
150
+ return !raw
151
+ || !canonicalValuesMatch(raw, normalised[index]);
152
+ })) {
153
+ throw new TypeError(`${label} contains a schema ${caseVersion} Case that would be repaired, truncated, or otherwise changed during normalisation.`);
176
154
  }
177
155
  }
178
- function publicObservedEffectHistory(preV13HistoryUnavailable) {
179
- return {
180
- reviews: [],
181
- omitted: 0,
182
- preV13HistoryUnavailable,
183
- limitations: [
184
- ...(preV13HistoryUnavailable
185
- ? ['Migrated from a pre-v13 Case; earlier independent observed-effect review history is unavailable.']
186
- : []),
187
- 'Observed-effect reviews are independent point-in-time records; provider workflow events do not create or replace them.',
188
- PUBLIC_OBSERVED_EFFECT_LIMITATION,
189
- ].sort(),
190
- };
191
- }
192
- function publicClosureHistory(preV13HistoryUnavailable) {
193
- return {
194
- records: [],
195
- omitted: 0,
196
- preV13HistoryUnavailable,
197
- limitations: [
198
- ...(preV13HistoryUnavailable
199
- ? ['Migrated from a pre-v13 Case; earlier deliberate closure history is unavailable.']
200
- : []),
201
- 'Closure records are deliberate analyst actions and do not establish absence, safety, provider performance, or legal sufficiency.',
202
- PUBLIC_CLOSURE_LIMITATION,
203
- ].sort(),
204
- };
205
- }
206
- function assertAudienceFields(value, audience) {
156
+ /** Historical packs keep their immutable wire shape, so only their retained audience guarantees are checked here. */
157
+ function assertLegacyAudienceFields(value, normalised, audience) {
207
158
  if (audience === 'internal')
208
159
  return;
160
+ const expected = projectCaseForAudience(normalised, audience);
209
161
  if (Object.hasOwn(value, 'notes') && (!Array.isArray(value.notes) || value.notes.length !== 0)) {
210
162
  throw new TypeError('The CLI case pack contains Case notes excluded by its audience.');
211
163
  }
@@ -218,11 +170,8 @@ function assertAudienceFields(value, audience) {
218
170
  for (const [field, list] of [['observedEffects', 'reviews'], ['closures', 'records']]) {
219
171
  if (Object.hasOwn(value, field)) {
220
172
  const history = record(value[field]);
221
- const expected = field === 'observedEffects'
222
- ? publicObservedEffectHistory(history?.preV13HistoryUnavailable === true)
223
- : publicClosureHistory(history?.preV13HistoryUnavailable === true);
224
173
  if (!history || !Array.isArray(history[list]) || history[list].length !== 0
225
- || !canonicalValuesMatch(history, expected)) {
174
+ || !canonicalValuesMatch(history, expected[field])) {
226
175
  throw new TypeError(`The public CLI case pack contains ${field} excluded by its audience.`);
227
176
  }
228
177
  }
@@ -245,6 +194,20 @@ function assertAudienceFields(value, audience) {
245
194
  }
246
195
  }
247
196
  }
197
+ function assertCurrentAudienceProjection(value, normalised, audience, caseVersion) {
198
+ const expected = canonicalPackProjection([projectCaseForAudience(normalised, audience)], caseVersion)[0];
199
+ for (const field of Object.keys(expected)) {
200
+ if (!canonicalValuesMatch(value[field], expected[field])) {
201
+ throw new TypeError(`The CLI case pack contains ${field} excluded by its audience or transformed incorrectly.`);
202
+ }
203
+ }
204
+ }
205
+ function assertAudienceProjection(value, normalised, audience, currentSchema, caseVersion) {
206
+ if (currentSchema)
207
+ assertCurrentAudienceProjection(value, normalised, audience, caseVersion);
208
+ else
209
+ assertLegacyAudienceFields(value, normalised, audience);
210
+ }
248
211
  function reportApplicationVersion(report) {
249
212
  return record(report.application)?.version;
250
213
  }
@@ -252,37 +215,15 @@ function assertCurrentReportProjection(report, rawCase) {
252
215
  if (typeof report.generatedAt !== 'string') {
253
216
  throw new TypeError('The CLI case pack contains an invalid or mismatched Case report.');
254
217
  }
255
- const expected = buildCaseReport(rawCase, {
218
+ const expected = buildCaseReportVerificationProjection(rawCase, {
256
219
  applicationVersion: reportApplicationVersion(report),
257
220
  includeNotes: false,
258
221
  generatedAt: report.generatedAt,
259
- }).json;
222
+ }, report.schemaVersion);
260
223
  if (!canonicalValuesMatch(report, expected)) {
261
224
  throw new TypeError('The CLI case pack contains an invalid or mismatched Case report projection.');
262
225
  }
263
226
  }
264
- function redactedCase(record, audience) {
265
- if (audience === 'internal')
266
- return structuredClone(record);
267
- return {
268
- ...structuredClone(record),
269
- status: audience === 'public' && record.status === 'resolved' && record.closures.records.length
270
- ? 'reviewing'
271
- : record.status,
272
- brandProfileIds: audience === 'public' ? [] : [...record.brandProfileIds],
273
- notes: [],
274
- actions: audience === 'public' ? [] : record.actions.map((item) => ({ ...item, recipient: '[redacted]' })),
275
- manualTrail: record.manualTrail.map((item) => ({ ...item, target: null })),
276
- assertions: audience === 'public' ? [] : structuredClone(record.assertions),
277
- observedEffects: audience === 'public'
278
- ? publicObservedEffectHistory(record.observedEffects.preV13HistoryUnavailable)
279
- : structuredClone(record.observedEffects),
280
- closures: audience === 'public'
281
- ? publicClosureHistory(record.closures.preV13HistoryUnavailable)
282
- : structuredClone(record.closures),
283
- branches: audience === 'public' ? [] : structuredClone(record.branches ?? []),
284
- };
285
- }
286
227
  export function buildCliCasePack(input, options, generatedAt = new Date().toISOString()) {
287
228
  if (!options.reviewed)
288
229
  throw new CliUsageError('case-pack requires --reviewed after the audience-specific output has been checked.');
@@ -298,17 +239,18 @@ export function buildCliCasePack(input, options, generatedAt = new Date().toISOS
298
239
  throw new CliUsageError('Case-pack input must be valid bounded JSON without duplicate keys.');
299
240
  }
300
241
  const root = parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
301
- if (Number.isSafeInteger(root.version) && root.version < CASE_SCHEMA_VERSION) {
302
- throw new CliUsageError(`Case schema ${String(root.version)} is retired. Export it as schema ${CASE_SCHEMA_VERSION} with the last broad-reader release before packaging; no data was changed.`);
242
+ const supportedInput = CLI_CASE_PACK_INPUT_CASE_VERSIONS.some((version) => version === root.version);
243
+ if (Number.isSafeInteger(root.version) && root.version < CASE_SCHEMA_VERSION && !supportedInput) {
244
+ throw new CliUsageError(`Case schema ${String(root.version)} cannot be packaged directly. This command accepts schemas ${CLI_CASE_PACK_INPUT_CASE_VERSIONS.join(', ')}. Use a supported Case import to create a current export first; no data was changed.`);
303
245
  }
304
246
  if (Number.isSafeInteger(root.version) && root.version > CASE_SCHEMA_VERSION) {
305
247
  throw new CliUsageError(`Case schema ${String(root.version)} is newer than the supported schema ${CASE_SCHEMA_VERSION}; no data was changed.`);
306
248
  }
307
- if (root.version !== CASE_SCHEMA_VERSION || !Array.isArray(root.cases)) {
308
- throw new CliUsageError(`Case-pack input must be a well-formed WHOISleuth Case schema ${CASE_SCHEMA_VERSION} export.`);
249
+ if (!supportedInput || !Array.isArray(root.cases)) {
250
+ throw new CliUsageError(`Case-pack input must be a well-formed WHOISleuth Case export with schema ${CLI_CASE_PACK_INPUT_CASE_VERSIONS.join(' or ')}.`);
309
251
  }
310
252
  try {
311
- assertCanonicalCaseIdentities(root.cases, 'Case-pack input');
253
+ assertCanonicalCaseIdentities(root.cases, 'Case-pack input', root.version);
312
254
  for (const item of root.cases) {
313
255
  const rawCase = record(item);
314
256
  if (!rawCase || !Object.hasOwn(rawCase, 'brandProfileIds'))
@@ -332,12 +274,12 @@ export function buildCliCasePack(input, options, generatedAt = new Date().toISOS
332
274
  throw new CliUsageError(`Case packs are limited to ${MAX_CASE_PACK_CASES} reviewed cases. Export a smaller selected set so no case is silently omitted.`);
333
275
  }
334
276
  try {
335
- assertCurrentCaseProjection(root.cases, normalised, 'Case-pack input');
277
+ assertCurrentCaseProjection(root.cases, canonicalPackProjection(normalised, root.version), 'Case-pack input', root.version);
336
278
  }
337
279
  catch (cause) {
338
280
  throw new CliUsageError(cause instanceof Error ? cause.message : `Case-pack schema ${CASE_SCHEMA_VERSION} input is not exact.`);
339
281
  }
340
- const cases = normalised.map((item) => redactedCase(item, options.audience));
282
+ const cases = normalised.map((item) => projectCaseForAudience(item, options.audience));
341
283
  const brandProfileReferencesOmitted = options.audience === 'public'
342
284
  ? normalised.reduce((count, item) => count + item.brandProfileIds.length, 0)
343
285
  : 0;
@@ -346,7 +288,7 @@ export function buildCliCasePack(input, options, generatedAt = new Date().toISOS
346
288
  includeNotes: false,
347
289
  generatedAt,
348
290
  }).json);
349
- const exclusions = expectedExclusions(options.audience);
291
+ const exclusions = caseAudienceExclusions(options.audience, CASE_SCHEMA_VERSION);
350
292
  const packet = Object.freeze({
351
293
  schema: CLI_CASE_PACK_SCHEMA,
352
294
  version: CLI_CASE_PACK_VERSION,
@@ -370,7 +312,7 @@ export function buildCliCasePack(input, options, generatedAt = new Date().toISOS
370
312
  }),
371
313
  });
372
314
  if (Buffer.byteLength(JSON.stringify(document), 'utf8') > MAX_CASE_IMPORT_BYTES) {
373
- throw new CliUsageError('The generated case pack exceeds the browser 2 MiB import limit. Select fewer cases or a more restrictive audience so no evidence is silently omitted.');
315
+ throw new CliUsageError(`The generated case pack exceeds the browser import limit of ${MAX_CASE_IMPORT_BYTES} bytes. Select fewer cases or a more restrictive audience so no evidence is silently omitted.`);
374
316
  }
375
317
  return document;
376
318
  }
@@ -436,17 +378,19 @@ export function verifyCliCasePack(input) {
436
378
  const audience = packet.audience;
437
379
  if (!Number.isSafeInteger(redactionManifest.sourceCaseCount)
438
380
  || redactionManifest.sourceCaseCount !== root.cases.length
439
- || !stringListsMatch(redactionManifest.excluded, expectedExclusions(audience, root.version))) {
381
+ || !stringListsMatch(redactionManifest.excluded, caseAudienceExclusions(audience, root.version))) {
440
382
  throw new TypeError('The CLI case pack has an invalid audience redaction manifest.');
441
383
  }
442
- assertCanonicalCaseIdentities(root.cases, 'The CLI case pack');
384
+ assertCanonicalCaseIdentities(root.cases, 'The CLI case pack', root.version);
443
385
  const normalised = normalizeCaseStore(root).cases;
444
386
  if (normalised.length !== root.cases.length) {
445
387
  throw new TypeError('The CLI case pack contains an invalid case collection.');
446
388
  }
447
- if (root.version === CASE_SCHEMA_VERSION)
448
- assertCurrentCaseProjection(root.cases, normalised, 'The CLI case pack');
449
- const normalisedByDomain = new Map(normalised.map((item) => [item.domain, item]));
389
+ const currentCaseSchema = root.version >= PUBLISHED_V2_3_CASE_SCHEMA_VERSION;
390
+ const projected = canonicalPackProjection(normalised, root.version);
391
+ if (currentCaseSchema)
392
+ assertCurrentCaseProjection(root.cases, projected, 'The CLI case pack', root.version);
393
+ const normalisedById = new Map(projected.map((item) => [item.id, item]));
450
394
  const caseReferenceLists = [];
451
395
  const reportReferenceLists = [];
452
396
  for (let index = 0; index < root.cases.length; index += 1) {
@@ -457,14 +401,18 @@ export function verifyCliCasePack(input) {
457
401
  || !report
458
402
  || !reportCase
459
403
  || typeof rawCase.domain !== 'string'
404
+ || typeof rawCase.id !== 'string'
460
405
  || report.schema !== CASE_REPORT_SCHEMA
461
406
  || !caseReportVersionMatchesCase(root.version, report.schemaVersion)
462
407
  || reportCase.id !== rawCase.id
463
408
  || reportCase.domain !== rawCase.domain
464
- || !normalisedByDomain.has(rawCase.domain)) {
409
+ || !normalisedById.has(rawCase.id)) {
465
410
  throw new TypeError('The CLI case pack contains an invalid or mismatched Case report.');
466
411
  }
467
- assertAudienceFields(rawCase, audience);
412
+ const normalisedCase = normalisedById.get(rawCase.id);
413
+ if (!normalisedCase)
414
+ throw new TypeError('The CLI case pack contains an invalid Case projection.');
415
+ assertAudienceProjection(rawCase, normalisedCase, audience, currentCaseSchema, root.version);
468
416
  try {
469
417
  if (!Object.hasOwn(rawCase, 'brandProfileIds') || !Object.hasOwn(reportCase, 'brandProfileIds'))
470
418
  throw new Error('missing');
@@ -474,8 +422,15 @@ export function verifyCliCasePack(input) {
474
422
  catch {
475
423
  throw new TypeError('The CLI case pack contains invalid Brand Profile references.');
476
424
  }
477
- if (root.version === CASE_SCHEMA_VERSION)
478
- assertCurrentReportProjection(report, rawCase);
425
+ if (currentCaseSchema)
426
+ assertCurrentReportProjection(report, normalisedCase);
427
+ else {
428
+ assertLegacyAudienceFields(reportCase, normalisedCase, audience);
429
+ const response = record(report.analystResponse);
430
+ if (!response)
431
+ throw new TypeError('The CLI case pack contains an invalid analyst-response report.');
432
+ assertLegacyAudienceFields(response, normalisedCase, audience);
433
+ }
479
434
  }
480
435
  const omitted = redactionManifest.brandProfileReferencesOmitted;
481
436
  if (!Object.hasOwn(redactionManifest, 'brandProfileReferencesOmitted')