@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
@@ -1,10 +1,22 @@
1
+ import { renderCaseResponsePacket } from "./case-response-packet-render.mjs";
2
+ import { responseContactLabel as contactLabel } from "./case-response-packet-vocabulary.mjs";
1
3
  // Pure abuse-evidence packet builder. It creates local review artifacts only:
2
4
  // no network requests, mailto links, submissions, or provider side effects.
3
- import { buildCaseActionOutcomeSummary, buildCaseResponseLifecycleSummary, 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-model.mjs";
4
- import { canonicalArtifactJsonV2, SORTED_JSON_V2 } from "../evidence/artifact-integrity.mjs";
5
- import { assertBoundedJsonStructure } from "../../lib/bounded-json.mjs";
6
- import { CASE_RESPONSE_PACKET_SCHEMA, CASE_RESPONSE_PACKET_VERSION, PUBLIC_CASE_RESPONSE_PACKET_VERSION, PUBLISHED_V2_CASE_RESPONSE_PACKET_VERSION, CASE_RESPONSE_REVIEW_INPUTS_SCHEMA, CASE_RESPONSE_REVIEW_INPUTS_VERSION, PUBLISHED_V2_CASE_RESPONSE_REVIEW_INPUTS_VERSION, MAX_ABUSE_CATEGORY_LENGTH, MAX_ABUSIVE_URLS, MAX_AFFECTED_PARTY_LENGTH, MAX_CASE_ACTION_EVENTS_PER_ACTION, MAX_CASE_ACTION_EVENTS_PER_CASE, MAX_CASE_ACTIONS, MAX_EXACT_URL_LENGTH, MAX_RESPONSE_ACTION_HISTORY, MAX_RESPONSE_ARTEFACT_REFERENCES, MAX_RESPONSE_AUTHORISATION_CLOCK_SKEW_MS, MAX_RESPONSE_CONTACTS, MAX_RESPONSE_CONTRADICTIONS, MAX_RESPONSE_HARM_LENGTH, MAX_RESPONSE_LIMITATION_LENGTH, MAX_RESPONSE_LIMITATIONS, MAX_RESPONSE_REFERENCE_LENGTH, MAX_RESPONSE_SELECTED_EVIDENCE, MAX_RESPONSE_VALUE_LENGTH, RESPONSE_ROUTE_STALE_AFTER_DAYS, SUPPORTED_CASE_RESPONSE_PACKET_VERSIONS, } from "../contracts/case-portability.mjs";
7
- export { CASE_RESPONSE_PACKET_SCHEMA, CASE_RESPONSE_PACKET_VERSION, PUBLIC_CASE_RESPONSE_PACKET_VERSION, PUBLISHED_V2_CASE_RESPONSE_PACKET_VERSION, CASE_RESPONSE_REVIEW_INPUTS_SCHEMA, CASE_RESPONSE_REVIEW_INPUTS_VERSION, PUBLISHED_V2_CASE_RESPONSE_REVIEW_INPUTS_VERSION, MAX_ABUSE_CATEGORY_LENGTH, MAX_ABUSIVE_URLS, MAX_AFFECTED_PARTY_LENGTH, MAX_EXACT_URL_LENGTH, MAX_RESPONSE_ACTION_HISTORY, MAX_RESPONSE_ARTEFACT_REFERENCES, MAX_RESPONSE_AUTHORISATION_CLOCK_SKEW_MS, MAX_RESPONSE_CONTACTS, MAX_RESPONSE_CONTRADICTIONS, MAX_RESPONSE_HARM_LENGTH, MAX_RESPONSE_SELECTED_EVIDENCE, RESPONSE_ROUTE_STALE_AFTER_DAYS, SUPPORTED_CASE_RESPONSE_PACKET_VERSIONS, };
5
+ import { assertBoundedJsonStructure, } from "../../lib/bounded-json.mjs";
6
+ import { CASE_RESPONSE_PACKET_SCHEMA, CASE_RESPONSE_PACKET_VERSION, CASE_RESPONSE_REVIEW_INPUTS_SCHEMA, CASE_RESPONSE_REVIEW_INPUTS_VERSION, MAX_ABUSE_CATEGORY_LENGTH, MAX_ABUSIVE_URLS, MAX_AFFECTED_PARTY_LENGTH, MAX_EXACT_URL_LENGTH, MAX_RESPONSE_ACTION_HISTORY, MAX_RESPONSE_ARTEFACT_REFERENCES, MAX_RESPONSE_AUTHORISATION_CLOCK_SKEW_MS, MAX_RESPONSE_CONTRADICTIONS, MAX_RESPONSE_HARM_LENGTH, MAX_RESPONSE_SELECTED_EVIDENCE, SUPPORTED_CASE_RESPONSE_PACKET_VERSIONS, } from "../contracts/case-portability.mjs";
7
+ import { SORTED_JSON_V2, canonicalArtifactJsonV2, } from "../evidence/artifact-integrity.mjs";
8
+ import { normalizeExplicitIsoTimestamp, } from "../evidence/observation.mjs";
9
+ import { latestCaseEvidence, } from "./case-evidence-model.mjs";
10
+ import {} from "./case-model.mjs";
11
+ import { caseDispositionSupportsDefensiveResponse, } from "./case-record-operations.mjs";
12
+ import { buildCaseActionOutcomeSummary, buildCaseResponseLifecycleSummary, } from "./case-response-model.mjs";
13
+ import { RESPONSE_AUTHORISATION_CONFIRMATION_IDS, RESPONSE_PACKET_PROFILE_IDS, RESPONSE_READINESS_STATES, } from "./case-response-packet-vocabulary.mjs";
14
+ import { validateCaseResponseReviewInputs, } from "./case-response-review-inputs.mjs";
15
+ import { responseRouteFreshness, } from "./response-route-freshness.mjs";
16
+ export * from "./case-response-packet-vocabulary.mjs";
17
+ export { validateCaseResponseReviewInputs, } from "./case-response-review-inputs.mjs";
18
+ export { CASE_RESPONSE_PACKET_SCHEMA, CASE_RESPONSE_PACKET_VERSION, PUBLISHED_V2_2_CASE_RESPONSE_PACKET_VERSION, PUBLISHED_V2_3_CASE_RESPONSE_PACKET_VERSION, PUBLIC_CASE_RESPONSE_PACKET_VERSION, PUBLISHED_V2_CASE_RESPONSE_PACKET_VERSION, CASE_RESPONSE_REVIEW_INPUTS_SCHEMA, CASE_RESPONSE_REVIEW_INPUTS_VERSION, PUBLISHED_V2_2_CASE_RESPONSE_REVIEW_INPUTS_VERSION, PUBLISHED_V2_3_CASE_RESPONSE_REVIEW_INPUTS_VERSION, PUBLISHED_V2_CASE_RESPONSE_REVIEW_INPUTS_VERSION, MAX_ABUSE_CATEGORY_LENGTH, MAX_ABUSIVE_URLS, MAX_AFFECTED_PARTY_LENGTH, MAX_EXACT_URL_LENGTH, MAX_RESPONSE_ACTION_HISTORY, MAX_RESPONSE_ARTEFACT_REFERENCES, MAX_RESPONSE_AUTHORISATION_CLOCK_SKEW_MS, MAX_RESPONSE_CONTACTS, MAX_RESPONSE_CONTRADICTIONS, MAX_RESPONSE_HARM_LENGTH, MAX_RESPONSE_SELECTED_EVIDENCE, RESPONSE_ROUTE_STALE_AFTER_DAYS, SUPPORTED_CASE_RESPONSE_PACKET_VERSIONS, } from "../contracts/case-portability.mjs";
19
+ export { LATEST_PUBLIC_CASE_RESPONSE_PACKET_VERSION, LATEST_PUBLIC_CASE_RESPONSE_REVIEW_INPUTS_VERSION, } from "../contracts/case-portability.mjs";
8
20
  export const CASE_RESPONSE_PREFLIGHT_EVIDENCE_SCOPE = Object.freeze({
9
21
  version: 1,
10
22
  owner: 'case',
@@ -18,42 +30,8 @@ export const CASE_RESPONSE_PREFLIGHT_EVIDENCE_SCOPE = Object.freeze({
18
30
  'case_actions',
19
31
  ]),
20
32
  lookupDecisionFacts: 'unavailable',
21
- limitation: 'Lookup Decision Facts are transient and are not copied into browser-local cases. Case-response preflight evaluates only explicit case-owned records and analyst-entered incident context; it does not reconstruct Decision Facts from weaker saved fields.',
33
+ limitation: 'Lookup Decision Facts are transient and are not copied into saved Cases. Case-response preflight evaluates only explicit case-owned records and analyst-entered incident context; it does not reconstruct Decision Facts from weaker saved fields.',
22
34
  });
23
- export const RESPONSE_CONTACT_KINDS = [
24
- 'registrar',
25
- 'registry',
26
- 'network_hosting',
27
- 'security_txt',
28
- ];
29
- export const RESPONSE_PACKET_PROFILE_IDS = [
30
- 'registrar',
31
- 'registry',
32
- 'network_hosting',
33
- 'security_contact',
34
- 'browser_blocklist',
35
- 'internal_soc',
36
- ];
37
- export const RESPONSE_READINESS_STATES = ['complete', 'partial', 'stale', 'unavailable', 'not_provided'];
38
- export const RESPONSE_READINESS_ROW_IDS = [
39
- 'observed_behaviour',
40
- 'exact_url',
41
- 'observation_time',
42
- 'capture_provenance',
43
- 'infrastructure_responsibility',
44
- 'recipient_route',
45
- 'authority_review',
46
- 'selected_evidence',
47
- 'contradictions',
48
- 'source_limitations',
49
- ];
50
- export const RESPONSE_AUTHORISATION_CONFIRMATION_IDS = [
51
- 'selectedEvidence',
52
- 'recipientScope',
53
- 'privacyRedactions',
54
- 'analystAuthority',
55
- 'evidenceFreshness',
56
- ];
57
35
  export const RESPONSE_PACKET_PROFILES = Object.freeze([
58
36
  {
59
37
  id: 'registrar',
@@ -111,6 +89,20 @@ export const RESPONSE_PACKET_PROFILES = Object.freeze([
111
89
  attachments: ['Reviewed response packet', 'Optional normalised case report'],
112
90
  followUpFields: ['Security reference', 'Triage acknowledgement', 'Remediation outcome'],
113
91
  },
92
+ {
93
+ id: 'application_platform',
94
+ label: 'Platform report review',
95
+ audience: 'Application-platform safety, impersonation, commerce, or rights team',
96
+ subjectPrefix: 'Reviewed platform report',
97
+ requiredContactKind: 'application_platform',
98
+ checklist: ['Confirm the exact account or content URL', 'Choose the route matching the evidence and reporter authority', 'Review the provider notice before sharing identity or rights-owner details'],
99
+ evidenceOrder: ['Incident facts', 'Exact platform URLs', 'Selected observations', 'Rights or authority evidence', 'Action history'],
100
+ includedEvidence: ['Exact URLs', 'Observation time', 'Selected evidence pins', 'Route provenance and review deadline', 'Relevant rights or authority context'],
101
+ excludedEvidence: ['Raw upstream payloads', 'Secrets', 'Unselected personal data', 'Unsupported policy or legal conclusions'],
102
+ redactions: ['Credential-bearing URLs are rejected; review query strings, fragments, tokens, identity documents, and unrelated personal data before sharing'],
103
+ attachments: ['Reviewed response packet', 'Optional normalised case report'],
104
+ followUpFields: ['Provider reference', 'Acknowledgement time', 'Policy response', 'Independent observed effect'],
105
+ },
114
106
  {
115
107
  id: 'browser_blocklist',
116
108
  label: 'Browser or blocklist review',
@@ -140,7 +132,6 @@ export const RESPONSE_PACKET_PROFILES = Object.freeze([
140
132
  followUpFields: ['Internal owner', 'Due date', 'Decision', 'Control or escalation outcome'],
141
133
  },
142
134
  ]);
143
- const CONTACT_KINDS = new Set(RESPONSE_CONTACT_KINDS);
144
135
  const RESPONSE_PROFILE_IDS = new Set(RESPONSE_PACKET_PROFILE_IDS);
145
136
  const CONTROL_RE = /[\u0000-\u001f\u007f]/u;
146
137
  const CONTROL_REPLACE_RE = /[\u0000-\u001f\u007f]+/gu;
@@ -153,15 +144,19 @@ function compareCodeUnits(left, right) {
153
144
  return left < right ? -1 : left > right ? 1 : 0;
154
145
  }
155
146
  function timestamp(value) {
156
- if (typeof value !== 'string' || value.length > 64 || CONTROL_RE.test(value))
157
- return null;
158
- const parsed = Date.parse(value);
159
- return Number.isFinite(parsed) ? new Date(parsed).toISOString() : null;
147
+ return normalizeExplicitIsoTimestamp(value);
160
148
  }
161
- function normalizeExactUrl(value) {
162
- const candidate = text(value, MAX_EXACT_URL_LENGTH);
149
+ function normalizeExactUrl(value, rejectOverlong) {
150
+ if (typeof value !== 'string' || CONTROL_RE.test(value))
151
+ return null;
152
+ const candidate = value.trim();
163
153
  if (!candidate)
164
154
  return null;
155
+ if (candidate.length > MAX_EXACT_URL_LENGTH) {
156
+ if (rejectOverlong)
157
+ throw new Error(`Each exact HTTP(S) URL is limited to ${MAX_EXACT_URL_LENGTH} characters; shorten or remove the overlong URL before continuing.`);
158
+ return null;
159
+ }
165
160
  try {
166
161
  const parsed = new URL(candidate);
167
162
  if (!['http:', 'https:'].includes(parsed.protocol) || parsed.username || parsed.password)
@@ -172,7 +167,7 @@ function normalizeExactUrl(value) {
172
167
  return null;
173
168
  }
174
169
  }
175
- function normalizeUrls(value) {
170
+ function normalizeUrls(value, rejectOverlong = false) {
176
171
  const source = Array.isArray(value)
177
172
  ? value
178
173
  : typeof value === 'string'
@@ -180,7 +175,7 @@ function normalizeUrls(value) {
180
175
  : [];
181
176
  const unique = new Set();
182
177
  for (const item of source.slice(0, MAX_ABUSIVE_URLS * 2)) {
183
- const normalized = normalizeExactUrl(item);
178
+ const normalized = normalizeExactUrl(item, rejectOverlong);
184
179
  if (normalized)
185
180
  unique.add(normalized);
186
181
  if (unique.size >= MAX_ABUSIVE_URLS)
@@ -213,17 +208,10 @@ function actionContactKind(type) {
213
208
  return 'network_hosting';
214
209
  if (type === 'security_contact_report')
215
210
  return 'security_txt';
211
+ if (type === 'platform_report')
212
+ return 'application_platform';
216
213
  return 'manual';
217
214
  }
218
- function routeFreshness(observedAt, generatedAt) {
219
- if (!observedAt)
220
- return 'unknown';
221
- const routeAge = Date.parse(generatedAt) - Date.parse(observedAt);
222
- return routeAge < -MAX_RESPONSE_AUTHORISATION_CLOCK_SKEW_MS
223
- || routeAge > RESPONSE_ROUTE_STALE_AFTER_DAYS * 86_400_000
224
- ? 'stale'
225
- : 'current';
226
- }
227
215
  function selectedPacketAction(caseRecord, input) {
228
216
  const actionId = typeof input.actionId === 'string' && /^[A-Za-z0-9_-]{1,64}$/u.test(input.actionId)
229
217
  ? input.actionId
@@ -282,13 +270,15 @@ function bindPacketRoute(caseRecord, input, generatedAt) {
282
270
  };
283
271
  }
284
272
  const observedAt = timestamp(action.routeObservedAt);
273
+ const reviewAfter = timestamp(action.routeReviewAfter);
285
274
  const recipientRoute = {
286
275
  actionId: action.id,
287
276
  kind,
288
277
  contact: text(action.recipient, 320),
289
278
  source: text(action.contactSource, 120) || 'analyst supplied',
290
279
  observedAt,
291
- freshness: routeFreshness(observedAt, generatedAt),
280
+ reviewAfter,
281
+ freshness: responseRouteFreshness(observedAt, reviewAfter, generatedAt),
292
282
  limitations: normalizeLimitations(action.contactLimitations),
293
283
  };
294
284
  const contacts = kind === 'manual' ? [] : [{
@@ -296,6 +286,7 @@ function bindPacketRoute(caseRecord, input, generatedAt) {
296
286
  contact: recipientRoute.contact,
297
287
  source: recipientRoute.source,
298
288
  observedAt,
289
+ reviewAfter,
299
290
  freshness: recipientRoute.freshness,
300
291
  limitations: [...recipientRoute.limitations],
301
292
  }];
@@ -325,7 +316,9 @@ function normalizeSelectedEvidence(caseRecord, value) {
325
316
  id: pin.id,
326
317
  label: text(pin.label, 80),
327
318
  source: text(pin.source, 120),
328
- observedAt: timestamp(pin.observedAt) ?? caseRecord.updatedAt,
319
+ ...(pin.observationHostname ? { observationHostname: pin.observationHostname } : {}),
320
+ ...(pin.webObservationMode ? { webObservationMode: pin.webObservationMode } : {}),
321
+ observedAt: timestamp(pin.observedAt),
329
322
  completeness: text(pin.completeness, 40),
330
323
  limitations: normalizeLimitations(pin.limitations),
331
324
  }));
@@ -358,10 +351,10 @@ function readinessOverride(value, key) {
358
351
  limitations: normalizeLimitations(item.limitations),
359
352
  };
360
353
  }
361
- function buildResponseReadiness(caseRecord, input, generatedAt, contacts, recipientRoute, selectedEvidence, contradictions) {
354
+ function buildResponseReadiness(input, generatedAt, contacts, recipientRoute, selectedEvidence, contradictions) {
362
355
  const profile = responsePacketProfile(input.profile);
363
356
  const urls = normalizeUrls(input.abusiveUrls);
364
- const observedAt = timestamp(input.observedAt) || caseRecord.evidenceHistory.at(-1)?.capturedAt || null;
357
+ const observedAt = timestamp(input.observedAt);
365
358
  const age = observedAt ? observationAge(observedAt, generatedAt) : null;
366
359
  const infrastructure = readinessOverride(input.readiness, 'infrastructureResponsibility');
367
360
  const authority = readinessOverride(input.readiness, 'authorityReview');
@@ -392,7 +385,10 @@ function buildResponseReadiness(caseRecord, input, generatedAt, contacts, recipi
392
385
  {
393
386
  id: 'capture_provenance', label: 'Capture provenance',
394
387
  state: !selectedEvidence.length ? 'unavailable' : selectedEvidence.every((item) => item.source && item.observedAt) ? 'complete' : 'partial',
395
- detail: selectedEvidence.length ? 'Selected evidence retains source and observation metadata.' : 'No selected evidence is available for capture-provenance review.',
388
+ detail: !selectedEvidence.length ? 'No selected evidence is available for capture-provenance review.'
389
+ : selectedEvidence.every((item) => item.source && item.observedAt)
390
+ ? 'Selected evidence retains source and observation metadata.'
391
+ : 'Selected evidence is retained, but some source or observation-time metadata is unavailable.',
396
392
  requiredForAuthorisation: true,
397
393
  limitations: selectedEvidence.flatMap((item) => item.limitations).slice(0, 8),
398
394
  },
@@ -462,7 +458,7 @@ export function buildCaseResponseReadiness(caseRecord, input, generatedAt = new
462
458
  const normalizedGeneratedAt = timestamp(generatedAt) || new Date().toISOString();
463
459
  const binding = bindPacketRoute(caseRecord, input, normalizedGeneratedAt);
464
460
  const selectedEvidence = normalizeSelectedEvidence(caseRecord, input.selectedEvidencePinIds);
465
- return buildResponseReadiness(caseRecord, input, normalizedGeneratedAt, binding.contacts, binding.recipientRoute, selectedEvidence, normalizeContradictions(caseRecord));
461
+ return buildResponseReadiness(input, normalizedGeneratedAt, binding.contacts, binding.recipientRoute, selectedEvidence, normalizeContradictions(caseRecord));
466
462
  }
467
463
  function normalizeArtefactReferences(value) {
468
464
  if (!Array.isArray(value))
@@ -509,7 +505,7 @@ export function buildResponsePacketProfilePreview(caseRecord, input) {
509
505
  const binding = bindPacketRoute(caseRecord, input, caseRecord.updatedAt);
510
506
  const missingEvidence = [
511
507
  ...(!normalizeUrls(input.abusiveUrls).length ? ['At least one exact HTTP(S) URL'] : []),
512
- ...(!timestamp(input.observedAt) && !caseRecord.evidenceHistory.at(-1)?.capturedAt ? ['Observation time'] : []),
508
+ ...(!timestamp(input.observedAt) ? ['Observation time'] : []),
513
509
  ...(!normalizeSelectedEvidence(caseRecord, input.selectedEvidencePinIds).length ? ['Explicitly selected evidence pin'] : []),
514
510
  ...(profile.id !== 'internal_soc' && binding.actionBinding.state !== 'selected'
515
511
  ? ['Selected retained Case action']
@@ -535,8 +531,7 @@ export function buildResponsePacketProfilePreview(caseRecord, input) {
535
531
  };
536
532
  }
537
533
  export function buildCaseResponsePreflight(caseRecord, input, generatedAt = new Date().toISOString()) {
538
- const latestEvidence = caseRecord.evidenceHistory.at(-1) ?? null;
539
- const observedAt = timestamp(input.observedAt) || latestEvidence?.capturedAt || null;
534
+ const observedAt = timestamp(input.observedAt);
540
535
  const normalizedGeneratedAt = timestamp(generatedAt) || new Date().toISOString();
541
536
  const binding = bindPacketRoute(caseRecord, input, normalizedGeneratedAt);
542
537
  const urls = normalizeUrls(input.abusiveUrls);
@@ -553,7 +548,7 @@ export function buildCaseResponsePreflight(caseRecord, input, generatedAt = new
553
548
  const actionSummary = buildCaseActionOutcomeSummary(caseRecord.actions, normalizedGeneratedAt);
554
549
  const retainedPinIds = new Set(caseRecord.evidencePins.map((pin) => pin.id));
555
550
  const evidenceLinkedDecisionCount = caseRecord.decisions.filter((decision) => decision.evidencePinIds.some((evidencePinId) => retainedPinIds.has(evidencePinId))).length;
556
- const responseDisposition = ['suspicious', 'confirmed_abuse'].includes(caseRecord.disposition);
551
+ const responseDisposition = caseDispositionSupportsDefensiveResponse(caseRecord.disposition);
557
552
  const reviewedActionCount = caseRecord.actions.filter((action) => ['reviewed', 'authorised', 'submitted', 'acknowledged', 'terminal'].includes(action.state)).length;
558
553
  const profile = responsePacketProfile(input.profile);
559
554
  const checks = [
@@ -607,6 +602,8 @@ export function buildCaseResponsePreflight(caseRecord, input, generatedAt = new
607
602
  : 'The browser or blocklist profile requires a manually reviewed submission destination recorded as a Case action.'
608
603
  : `${profile.label} has no fixed external contact-kind requirement.`,
609
604
  },
605
+ // Packet v9 retains its established machine-token wording for immutable
606
+ // fixture compatibility; interactive Case surfaces use the canonical label.
610
607
  {
611
608
  id: 'case_disposition',
612
609
  label: 'Case disposition',
@@ -681,6 +678,7 @@ function normalizeActionHistory(caseRecord, input) {
681
678
  recipient: text(action.recipient, 320),
682
679
  contactSource: text(action.contactSource, 120),
683
680
  routeObservedAt: timestamp(action.routeObservedAt),
681
+ routeReviewAfter: timestamp(action.routeReviewAfter),
684
682
  state: text(action.state, 80),
685
683
  reference: text(action.reference, 500) || null,
686
684
  providerOutcome: text(action.providerOutcome, 80) || null,
@@ -751,6 +749,7 @@ function normalizeResponseLifecycle(caseRecord, actionIds, lineageComplete) {
751
749
  'Provider workflow outcomes and independently observed technical effects are separate point-in-time records.',
752
750
  'A provider acknowledgement, terminal state, or reported resolution never becomes independently observed remediation, absence, or safety.',
753
751
  'Times are withheld when the corresponding typed event is missing or ambiguous.',
752
+ ...(!summary.latestObservedEffect && scopedRecord.observedEffects.reviews.length ? ['A single latest independent review cannot be selected from the retained observation times; all retained reviews remain separately attributed.'] : []),
754
753
  ...(!lineageComplete ? ['Provider-outcome time is withheld because the selected action origin lineage is incomplete.'] : []),
755
754
  ],
756
755
  };
@@ -779,7 +778,7 @@ export function buildCaseResponseReviewInputs(caseRecord, input, generatedAt) {
779
778
  const selectedEvidence = normalizeSelectedEvidence(caseRecord, input.selectedEvidencePinIds);
780
779
  const contradictions = normalizeContradictions(caseRecord);
781
780
  const binding = bindPacketRoute(caseRecord, input, generatedAt);
782
- const readiness = buildResponseReadiness(caseRecord, input, generatedAt, binding.contacts, binding.recipientRoute, selectedEvidence, contradictions);
781
+ const readiness = buildResponseReadiness(input, generatedAt, binding.contacts, binding.recipientRoute, selectedEvidence, contradictions);
783
782
  const escalation = normalizeActionHistory(caseRecord, input);
784
783
  return {
785
784
  contract: CASE_RESPONSE_REVIEW_INPUTS_SCHEMA,
@@ -804,9 +803,9 @@ export function buildCaseResponseReviewInputs(caseRecord, input, generatedAt) {
804
803
  incident: {
805
804
  category,
806
805
  affectedParty: text(input.affectedParty, MAX_AFFECTED_PARTY_LENGTH),
807
- abusiveUrls: normalizeUrls(input.abusiveUrls),
806
+ abusiveUrls: normalizeUrls(input.abusiveUrls, true),
808
807
  observedHarm: text(input.observedHarm, MAX_RESPONSE_HARM_LENGTH),
809
- observedAt: timestamp(input.observedAt) || caseRecord.evidenceHistory.at(-1)?.capturedAt || null,
808
+ observedAt: timestamp(input.observedAt),
810
809
  },
811
810
  contacts: binding.contacts,
812
811
  recipientRoute: binding.recipientRoute,
@@ -821,348 +820,6 @@ export function buildCaseResponseReviewInputs(caseRecord, input, generatedAt) {
821
820
  responseLifecycle: normalizeResponseLifecycle(caseRecord, escalation.actionBinding.lineageActionIds, escalation.lineageComplete),
822
821
  };
823
822
  }
824
- const CASE_RESPONSE_REVIEW_INPUT_KEYS = Object.freeze([
825
- 'contract',
826
- 'version',
827
- 'profile',
828
- 'case',
829
- 'incident',
830
- 'contacts',
831
- 'recipientRoute',
832
- 'actionBinding',
833
- 'selectedEvidence',
834
- 'contradictions',
835
- 'readiness',
836
- 'artefactReferences',
837
- 'escalationHistory',
838
- 'escalationHistoryOmitted',
839
- 'escalationHistoryLimitations',
840
- 'responseLifecycle',
841
- ]);
842
- const PUBLISHED_V2_CASE_RESPONSE_REVIEW_INPUT_KEYS = Object.freeze(CASE_RESPONSE_REVIEW_INPUT_KEYS.filter((key) => key !== 'recipientRoute' && key !== 'actionBinding'));
843
- function exactReviewRecord(value, keys, label) {
844
- if (!value || typeof value !== 'object' || Array.isArray(value)) {
845
- throw new TypeError(`${label} must be an exact object.`);
846
- }
847
- const ownKeys = Reflect.ownKeys(value);
848
- if (ownKeys.length !== keys.length
849
- || ownKeys.some((key) => typeof key !== 'string' || !keys.includes(key))) {
850
- throw new TypeError(`${label} contains an undeclared field.`);
851
- }
852
- const descriptors = Object.getOwnPropertyDescriptors(value);
853
- if (keys.some((key) => !descriptors[key] || !Object.hasOwn(descriptors[key], 'value'))) {
854
- throw new TypeError(`${label} must not contain accessors.`);
855
- }
856
- return value;
857
- }
858
- function boundedReviewArray(value, maximum, label) {
859
- if (!Array.isArray(value) || value.length > maximum) {
860
- throw new TypeError(`${label} exceeds its declared array bound.`);
861
- }
862
- const ownKeys = Reflect.ownKeys(value).filter((key) => key !== 'length');
863
- if (ownKeys.length !== value.length
864
- || ownKeys.some((key, index) => key !== String(index))) {
865
- throw new TypeError(`${label} must be a dense ordinary array.`);
866
- }
867
- const descriptors = Object.getOwnPropertyDescriptors(value);
868
- if (ownKeys.some((key) => typeof key !== 'string' || !Object.hasOwn(descriptors[key], 'value'))) {
869
- throw new TypeError(`${label} must not contain accessors.`);
870
- }
871
- return value;
872
- }
873
- function reviewText(value, maximum, label, nullable = false) {
874
- if (nullable && value === null)
875
- return null;
876
- if (typeof value !== 'string' || value.length > maximum || CONTROL_RE.test(value)) {
877
- throw new TypeError(`${label} is not a bounded text value.`);
878
- }
879
- return value;
880
- }
881
- function reviewStrings(value, maximum, itemMaximum, label) {
882
- const items = boundedReviewArray(value, maximum, label);
883
- for (const item of items)
884
- reviewText(item, itemMaximum, `${label} item`);
885
- return items;
886
- }
887
- function reviewCount(value, maximum, label) {
888
- if (!Number.isSafeInteger(value) || value < 0 || value > maximum) {
889
- throw new TypeError(`${label} is not a bounded count.`);
890
- }
891
- return value;
892
- }
893
- function reviewEnum(value, values, label) {
894
- if (typeof value !== 'string' || !values.includes(value)) {
895
- throw new TypeError(`${label} contains an unsupported value.`);
896
- }
897
- return value;
898
- }
899
- function reviewNullableEnum(value, values, label) {
900
- return value === null ? null : reviewEnum(value, values, label);
901
- }
902
- function validateReviewLifecycle(value) {
903
- const lifecycle = exactReviewRecord(value, [
904
- 'providerOutcomeState', 'latestProviderOutcome', 'observedChangeState',
905
- 'latestObservedEffect', 'latestObservedChangeAt', 'closure', 'limitations',
906
- ], 'Case-response review lifecycle');
907
- reviewEnum(lifecycle.providerOutcomeState, ['available', 'missing', 'ambiguous'], 'Case-response provider-outcome state');
908
- reviewEnum(lifecycle.observedChangeState, ['available', 'missing', 'ambiguous'], 'Case-response observed-change state');
909
- reviewText(lifecycle.latestObservedChangeAt, 64, 'Case-response latest observed change', true);
910
- reviewStrings(lifecycle.limitations, MAX_RESPONSE_LIMITATIONS, MAX_RESPONSE_LIMITATION_LENGTH, 'Case-response lifecycle limitations');
911
- if (lifecycle.latestProviderOutcome !== null) {
912
- const outcome = exactReviewRecord(lifecycle.latestProviderOutcome, [
913
- 'actionId', 'eventId', 'outcome', 'occurredAt', 'reference',
914
- ], 'Case-response latest provider outcome');
915
- reviewText(outcome.actionId, 64, 'Case-response provider action id');
916
- reviewText(outcome.eventId, 64, 'Case-response provider event id');
917
- reviewEnum(outcome.outcome, CASE_PROVIDER_OUTCOMES, 'Case-response provider outcome');
918
- reviewText(outcome.occurredAt, 64, 'Case-response provider outcome time');
919
- reviewText(outcome.reference, MAX_RESPONSE_REFERENCE_LENGTH, 'Case-response provider reference', true);
920
- }
921
- if (lifecycle.latestObservedEffect !== null) {
922
- const effect = exactReviewRecord(lifecycle.latestObservedEffect, [
923
- 'reviewId', 'state', 'observedAt', 'sourceClass', 'source',
924
- ], 'Case-response latest observed effect');
925
- reviewText(effect.reviewId, 64, 'Case-response observed-effect review id');
926
- reviewEnum(effect.state, CASE_OBSERVED_EFFECT_STATES, 'Case-response observed-effect state');
927
- reviewText(effect.observedAt, 64, 'Case-response observed-effect time');
928
- reviewEnum(effect.sourceClass, CASE_OBSERVED_EFFECT_SOURCE_CLASSES, 'Case-response observed-effect source class');
929
- reviewText(effect.source, MAX_RESPONSE_VALUE_LENGTH, 'Case-response observed-effect source');
930
- }
931
- if (lifecycle.closure !== null) {
932
- const closure = exactReviewRecord(lifecycle.closure, [
933
- 'id', 'reason', 'createdAt', 'limitations',
934
- ], 'Case-response latest closure');
935
- reviewText(closure.id, 64, 'Case-response closure id');
936
- reviewEnum(closure.reason, CASE_CLOSURE_REASONS, 'Case-response closure reason');
937
- reviewText(closure.createdAt, 64, 'Case-response closure time');
938
- reviewStrings(closure.limitations, MAX_RESPONSE_LIMITATIONS, MAX_RESPONSE_LIMITATION_LENGTH, 'Case-response closure limitations');
939
- }
940
- }
941
- function recursivelyFreezeReviewValue(value) {
942
- if (!value || typeof value !== 'object' || Object.isFrozen(value))
943
- return value;
944
- for (const child of Object.values(value)) {
945
- recursivelyFreezeReviewValue(child);
946
- }
947
- return Object.freeze(value);
948
- }
949
- export function validateCaseResponseReviewInputs(value) {
950
- if (!value || typeof value !== 'object' || Array.isArray(value)) {
951
- throw new TypeError('Case-response review inputs must be an exact object.');
952
- }
953
- const descriptors = Object.getOwnPropertyDescriptors(value);
954
- if (!descriptors.contract || !Object.hasOwn(descriptors.contract, 'value')
955
- || !descriptors.version || !Object.hasOwn(descriptors.version, 'value')) {
956
- throw new TypeError('Case-response review inputs must not contain accessors.');
957
- }
958
- const current = descriptors.version.value === CASE_RESPONSE_REVIEW_INPUTS_VERSION;
959
- const publishedV2 = descriptors.version.value === PUBLISHED_V2_CASE_RESPONSE_REVIEW_INPUTS_VERSION;
960
- if (descriptors.contract.value !== CASE_RESPONSE_REVIEW_INPUTS_SCHEMA || (!current && !publishedV2)) {
961
- throw new TypeError('Case-response review inputs contain an unsupported version, shape, or bound.');
962
- }
963
- const source = exactReviewRecord(value, current ? CASE_RESPONSE_REVIEW_INPUT_KEYS : PUBLISHED_V2_CASE_RESPONSE_REVIEW_INPUT_KEYS, 'Case-response review inputs');
964
- const profile = exactReviewRecord(source.profile, [
965
- 'id', 'label', 'audience', 'subject', 'checklist', 'includedEvidence',
966
- 'excludedEvidence', 'redactions',
967
- ], 'Case-response review profile');
968
- if (typeof profile.id !== 'string' || !RESPONSE_PROFILE_IDS.has(profile.id)) {
969
- throw new TypeError('Case-response review profile is unsupported.');
970
- }
971
- for (const key of ['label', 'audience', 'subject']) {
972
- reviewText(profile[key], 500, `Case-response profile ${key}`);
973
- }
974
- for (const key of ['checklist', 'includedEvidence', 'excludedEvidence', 'redactions']) {
975
- reviewStrings(profile[key], 32, 500, `Case-response profile ${key}`);
976
- }
977
- const caseRecord = exactReviewRecord(source.case, [
978
- 'id', 'domain', 'status', 'disposition', 'updatedAt',
979
- ], 'Case-response review Case');
980
- reviewText(caseRecord.id, 64, 'Case-response Case id');
981
- reviewText(caseRecord.domain, 253, 'Case-response Case domain');
982
- reviewText(caseRecord.status, 64, 'Case-response Case status');
983
- reviewText(caseRecord.disposition, 64, 'Case-response Case disposition');
984
- reviewText(caseRecord.updatedAt, 64, 'Case-response Case update time');
985
- const incident = exactReviewRecord(source.incident, [
986
- 'category', 'affectedParty', 'abusiveUrls', 'observedHarm', 'observedAt',
987
- ], 'Case-response review incident');
988
- reviewText(incident.category, MAX_ABUSE_CATEGORY_LENGTH, 'Case-response category');
989
- reviewText(incident.affectedParty, MAX_AFFECTED_PARTY_LENGTH, 'Case-response affected party');
990
- reviewStrings(incident.abusiveUrls, MAX_ABUSIVE_URLS, MAX_EXACT_URL_LENGTH, 'Case-response abusive URLs');
991
- reviewText(incident.observedHarm, MAX_RESPONSE_HARM_LENGTH, 'Case-response observed harm');
992
- reviewText(incident.observedAt, 64, 'Case-response observation time', true);
993
- for (const candidate of boundedReviewArray(source.contacts, MAX_RESPONSE_CONTACTS, 'Case-response contacts')) {
994
- const contact = exactReviewRecord(candidate, [
995
- 'kind', 'contact', 'source', 'observedAt', 'freshness', 'limitations',
996
- ], 'Case-response contact');
997
- if (typeof contact.kind !== 'string' || !CONTACT_KINDS.has(contact.kind)
998
- || typeof contact.freshness !== 'string'
999
- || !['current', 'stale', 'unknown'].includes(contact.freshness)) {
1000
- throw new TypeError('Case-response contact contains an unsupported enum.');
1001
- }
1002
- reviewText(contact.contact, 320, 'Case-response contact value');
1003
- reviewText(contact.source, 120, 'Case-response contact source');
1004
- reviewText(contact.observedAt, 64, 'Case-response contact observation time', true);
1005
- reviewStrings(contact.limitations, MAX_RESPONSE_LIMITATIONS, MAX_RESPONSE_LIMITATION_LENGTH, 'Case-response contact limitations');
1006
- }
1007
- if (current && source.recipientRoute !== null) {
1008
- const route = exactReviewRecord(source.recipientRoute, [
1009
- 'actionId', 'kind', 'contact', 'source', 'observedAt', 'freshness', 'limitations',
1010
- ], 'Case-response recipient route');
1011
- reviewText(route.actionId, 64, 'Case-response recipient action id');
1012
- reviewEnum(route.kind, [...RESPONSE_CONTACT_KINDS, 'manual'], 'Case-response recipient kind');
1013
- reviewText(route.contact, 320, 'Case-response recipient value');
1014
- reviewText(route.source, 120, 'Case-response recipient source');
1015
- reviewText(route.observedAt, 64, 'Case-response recipient observation time', true);
1016
- reviewEnum(route.freshness, ['current', 'stale', 'unknown'], 'Case-response recipient freshness');
1017
- reviewStrings(route.limitations, MAX_RESPONSE_LIMITATIONS, MAX_RESPONSE_LIMITATION_LENGTH, 'Case-response recipient limitations');
1018
- }
1019
- let currentLineageActionIds = null;
1020
- if (current) {
1021
- const actionBinding = exactReviewRecord(source.actionBinding, [
1022
- 'state', 'selectedActionId', 'lineageActionIds', 'limitations',
1023
- ], 'Case-response action binding');
1024
- const bindingState = reviewEnum(actionBinding.state, ['selected', 'not_selected'], 'Case-response action binding state');
1025
- const selectedActionId = reviewText(actionBinding.selectedActionId, 64, 'Case-response selected action id', true);
1026
- const lineageActionIds = reviewStrings(actionBinding.lineageActionIds, MAX_RESPONSE_ACTION_HISTORY, 64, 'Case-response action lineage');
1027
- currentLineageActionIds = lineageActionIds;
1028
- if ((bindingState === 'selected') !== Boolean(selectedActionId)
1029
- || (selectedActionId !== null && lineageActionIds[0] !== selectedActionId)
1030
- || new Set(lineageActionIds).size !== lineageActionIds.length) {
1031
- throw new TypeError('Case-response action binding is inconsistent.');
1032
- }
1033
- reviewStrings(actionBinding.limitations, MAX_RESPONSE_LIMITATIONS, MAX_RESPONSE_LIMITATION_LENGTH, 'Case-response action-binding limitations');
1034
- if (source.recipientRoute !== null
1035
- && source.recipientRoute.actionId !== selectedActionId) {
1036
- throw new TypeError('Case-response recipient route is not bound to the selected action.');
1037
- }
1038
- }
1039
- for (const candidate of boundedReviewArray(source.selectedEvidence, MAX_RESPONSE_SELECTED_EVIDENCE, 'Case-response selected evidence')) {
1040
- const evidence = exactReviewRecord(candidate, [
1041
- 'id', 'label', 'source', 'observedAt', 'completeness', 'limitations',
1042
- ], 'Case-response selected evidence item');
1043
- reviewText(evidence.id, 64, 'Case-response evidence id');
1044
- reviewText(evidence.label, 80, 'Case-response evidence label');
1045
- reviewText(evidence.source, 120, 'Case-response evidence source');
1046
- reviewText(evidence.observedAt, 64, 'Case-response evidence observation time');
1047
- reviewEnum(evidence.completeness, CASE_PIN_COMPLETENESS, 'Case-response evidence completeness');
1048
- reviewStrings(evidence.limitations, MAX_RESPONSE_LIMITATIONS, MAX_RESPONSE_LIMITATION_LENGTH, 'Case-response evidence limitations');
1049
- }
1050
- for (const candidate of boundedReviewArray(source.contradictions, MAX_RESPONSE_CONTRADICTIONS, 'Case-response contradictions')) {
1051
- const contradiction = exactReviewRecord(candidate, [
1052
- 'id', 'statement', 'state', 'limitations',
1053
- ], 'Case-response contradiction');
1054
- reviewText(contradiction.id, 64, 'Case-response contradiction id');
1055
- reviewText(contradiction.statement, 2_000, 'Case-response contradiction statement');
1056
- reviewEnum(contradiction.state, CASE_ASSERTION_STATES, 'Case-response contradiction state');
1057
- reviewStrings(contradiction.limitations, MAX_RESPONSE_LIMITATIONS, MAX_RESPONSE_LIMITATION_LENGTH, 'Case-response contradiction limitations');
1058
- }
1059
- const readiness = exactReviewRecord(source.readiness, [
1060
- 'profileId', 'rows', 'counts', 'limitations',
1061
- ], 'Case-response readiness');
1062
- if (typeof readiness.profileId !== 'string' || !RESPONSE_PROFILE_IDS.has(readiness.profileId)) {
1063
- throw new TypeError('Case-response readiness profile is unsupported.');
1064
- }
1065
- const readinessRows = boundedReviewArray(readiness.rows, RESPONSE_READINESS_ROW_IDS.length, 'Case-response readiness rows');
1066
- const readinessIds = new Set();
1067
- const readinessCounts = Object.fromEntries(RESPONSE_READINESS_STATES.map((state) => [state, 0]));
1068
- for (const candidate of readinessRows) {
1069
- const row = exactReviewRecord(candidate, [
1070
- 'id', 'label', 'state', 'detail', 'requiredForAuthorisation', 'limitations',
1071
- ], 'Case-response readiness row');
1072
- if (typeof row.id !== 'string'
1073
- || !RESPONSE_READINESS_ROW_IDS.includes(row.id)
1074
- || readinessIds.has(row.id)
1075
- || typeof row.state !== 'string'
1076
- || !RESPONSE_READINESS_STATES.includes(row.state)
1077
- || typeof row.requiredForAuthorisation !== 'boolean') {
1078
- throw new TypeError('Case-response readiness row is unsupported or duplicated.');
1079
- }
1080
- readinessIds.add(row.id);
1081
- readinessCounts[row.state] += 1;
1082
- reviewText(row.label, 120, 'Case-response readiness label');
1083
- reviewText(row.detail, 500, 'Case-response readiness detail');
1084
- reviewStrings(row.limitations, MAX_RESPONSE_LIMITATIONS, MAX_RESPONSE_LIMITATION_LENGTH, 'Case-response readiness limitations');
1085
- }
1086
- const counts = exactReviewRecord(readiness.counts, RESPONSE_READINESS_STATES, 'Case-response readiness counts');
1087
- for (const state of RESPONSE_READINESS_STATES) {
1088
- if (reviewCount(counts[state], RESPONSE_READINESS_ROW_IDS.length, `Case-response readiness ${state} count`) !== readinessCounts[state]) {
1089
- throw new TypeError('Case-response readiness counts do not match the exact rows.');
1090
- }
1091
- }
1092
- reviewStrings(readiness.limitations, MAX_RESPONSE_LIMITATIONS, MAX_RESPONSE_LIMITATION_LENGTH, 'Case-response readiness limitations');
1093
- for (const candidate of boundedReviewArray(source.artefactReferences, MAX_RESPONSE_ARTEFACT_REFERENCES, 'Case-response artefact references')) {
1094
- const reference = exactReviewRecord(candidate, [
1095
- 'id', 'label', 'mediaType', 'capturedAt', 'source', 'digestSha256',
1096
- 'byteLength', 'limitations',
1097
- ], 'Case-response artefact reference');
1098
- reviewText(reference.id, 64, 'Case-response artefact id');
1099
- reviewText(reference.label, 120, 'Case-response artefact label');
1100
- reviewText(reference.mediaType, 120, 'Case-response artefact media type');
1101
- reviewText(reference.capturedAt, 64, 'Case-response artefact capture time');
1102
- reviewText(reference.source, 120, 'Case-response artefact source');
1103
- if (typeof reference.digestSha256 !== 'string' || !/^[a-f0-9]{64}$/u.test(reference.digestSha256)) {
1104
- throw new TypeError('Case-response artefact digest is invalid.');
1105
- }
1106
- if (reference.byteLength !== null)
1107
- reviewCount(reference.byteLength, 100 * 1024 * 1024, 'Case-response artefact byte length');
1108
- reviewStrings(reference.limitations, MAX_RESPONSE_LIMITATIONS, MAX_RESPONSE_LIMITATION_LENGTH, 'Case-response artefact limitations');
1109
- }
1110
- const escalationActionIds = [];
1111
- for (const candidate of boundedReviewArray(source.escalationHistory, MAX_RESPONSE_ACTION_HISTORY, 'Case-response escalation history')) {
1112
- const action = exactReviewRecord(candidate, [
1113
- 'actionId', 'type', 'recipient', 'contactSource', 'state', 'reference',
1114
- ...(current ? ['routeObservedAt'] : []),
1115
- 'providerOutcome', 'outcomeDetail', 'originActionId', 'historyOmitted',
1116
- 'historyLimitations', 'transitions', 'createdAt', 'updatedAt',
1117
- ], 'Case-response escalation action');
1118
- for (const key of ['actionId', 'recipient', 'contactSource', 'createdAt', 'updatedAt']) {
1119
- reviewText(action[key], MAX_RESPONSE_VALUE_LENGTH, `Case-response action ${key}`);
1120
- }
1121
- if (escalationActionIds.includes(action.actionId)) {
1122
- throw new TypeError('Case-response escalation action identities must be unique.');
1123
- }
1124
- escalationActionIds.push(action.actionId);
1125
- if (current)
1126
- reviewText(action.routeObservedAt, 64, 'Case-response action route observation time', true);
1127
- reviewEnum(action.type, CASE_ACTION_TYPES, 'Case-response action type');
1128
- reviewEnum(action.state, CASE_ACTION_STATES, 'Case-response action state');
1129
- for (const key of ['reference', 'outcomeDetail', 'originActionId']) {
1130
- reviewText(action[key], MAX_RESPONSE_VALUE_LENGTH, `Case-response action ${key}`, true);
1131
- }
1132
- reviewNullableEnum(action.providerOutcome, CASE_PROVIDER_OUTCOMES, 'Case-response action provider outcome');
1133
- reviewCount(action.historyOmitted, MAX_CASE_ACTION_EVENTS_PER_CASE, 'Case-response omitted transition count');
1134
- reviewStrings(action.historyLimitations, MAX_RESPONSE_LIMITATIONS, MAX_RESPONSE_LIMITATION_LENGTH, 'Case-response action history limitations');
1135
- for (const candidateTransition of boundedReviewArray(action.transitions, MAX_CASE_ACTION_EVENTS_PER_ACTION, 'Case-response transitions')) {
1136
- const transition = exactReviewRecord(candidateTransition, [
1137
- 'id', 'previousState', 'nextState', 'occurredAt', 'sourceClass', 'provenance',
1138
- 'reference', 'evidencePinId', 'limitations', 'providerOutcome', 'outcomeDetail',
1139
- 'originActionId', 'applied',
1140
- ], 'Case-response transition');
1141
- for (const key of ['id', 'occurredAt', 'provenance']) {
1142
- reviewText(transition[key], MAX_RESPONSE_VALUE_LENGTH, `Case-response transition ${key}`);
1143
- }
1144
- reviewNullableEnum(transition.previousState, CASE_ACTION_STATES, 'Case-response previous action state');
1145
- reviewEnum(transition.nextState, CASE_ACTION_STATES, 'Case-response next action state');
1146
- reviewEnum(transition.sourceClass, CASE_ACTION_EVENT_SOURCE_CLASSES, 'Case-response transition source class');
1147
- for (const key of ['reference', 'evidencePinId', 'outcomeDetail', 'originActionId']) {
1148
- reviewText(transition[key], MAX_RESPONSE_VALUE_LENGTH, `Case-response transition ${key}`, true);
1149
- }
1150
- reviewNullableEnum(transition.providerOutcome, CASE_PROVIDER_OUTCOMES, 'Case-response transition provider outcome');
1151
- if (typeof transition.applied !== 'boolean')
1152
- throw new TypeError('Case-response transition applied state is invalid.');
1153
- reviewStrings(transition.limitations, MAX_RESPONSE_LIMITATIONS, MAX_RESPONSE_LIMITATION_LENGTH, 'Case-response transition limitations');
1154
- }
1155
- }
1156
- if (currentLineageActionIds
1157
- && (currentLineageActionIds.length !== escalationActionIds.length
1158
- || escalationActionIds.some((id, index) => currentLineageActionIds?.[index] !== id))) {
1159
- throw new TypeError('Case-response escalation history must match the selected action lineage in order.');
1160
- }
1161
- reviewCount(source.escalationHistoryOmitted, MAX_CASE_ACTIONS, 'Case-response omitted action count');
1162
- reviewStrings(source.escalationHistoryLimitations, MAX_RESPONSE_LIMITATIONS, MAX_RESPONSE_LIMITATION_LENGTH, 'Case-response escalation limitations');
1163
- validateReviewLifecycle(source.responseLifecycle);
1164
- return recursivelyFreezeReviewValue(structuredClone(source));
1165
- }
1166
823
  export async function buildCaseResponseReviewDigest(caseRecord, input, generatedAt = new Date().toISOString()) {
1167
824
  const normalizedGeneratedAt = timestamp(generatedAt) || new Date().toISOString();
1168
825
  return sha256(canonicalArtifactJsonV2(validateCaseResponseReviewInputs(buildCaseResponseReviewInputs(caseRecord, input, normalizedGeneratedAt))));
@@ -1232,25 +889,13 @@ export async function verifyCaseResponsePacketIntegrity(packet) {
1232
889
  }
1233
890
  return integrity.digestSha256 === await sha256(canonicalArtifactJsonV2(unsigned));
1234
891
  }
1235
- function escapeMarkdown(value) {
1236
- return value.replace(/([\\`*_[\]<>|])/gu, '\\$1').replace(/\r?\n/gu, ' ');
1237
- }
1238
- function contactLabel(value) {
1239
- if (value === 'network_hosting')
1240
- return 'Observed endpoint network registration';
1241
- if (value === 'security_txt')
1242
- return 'security.txt';
1243
- if (value === 'manual')
1244
- return 'Manual submission';
1245
- return value.charAt(0).toUpperCase() + value.slice(1);
1246
- }
1247
892
  export async function buildCaseResponsePacket(caseRecord, input, generatedAt = new Date().toISOString()) {
1248
893
  const category = text(input.category, MAX_ABUSE_CATEGORY_LENGTH);
1249
894
  const affectedParty = text(input.affectedParty, MAX_AFFECTED_PARTY_LENGTH);
1250
- const abusiveUrls = normalizeUrls(input.abusiveUrls);
895
+ const abusiveUrls = normalizeUrls(input.abusiveUrls, true);
1251
896
  const observedHarm = text(input.observedHarm, MAX_RESPONSE_HARM_LENGTH);
1252
- const latestEvidence = caseRecord.evidenceHistory.at(-1) ?? null;
1253
- const observedAt = timestamp(input.observedAt) || latestEvidence?.capturedAt || null;
897
+ const latestEvidence = latestCaseEvidence(caseRecord);
898
+ const observedAt = timestamp(input.observedAt);
1254
899
  if (!category || !affectedParty || !abusiveUrls.length || !observedHarm || !observedAt) {
1255
900
  throw new Error('Category, affected party, at least one exact HTTP(S) URL, observed harm, and an observation time are required.');
1256
901
  }
@@ -1345,150 +990,7 @@ export async function buildCaseResponsePacket(caseRecord, input, generatedAt = n
1345
990
  digestSha256,
1346
991
  },
1347
992
  };
1348
- const lines = [
1349
- `# ${escapeMarkdown(profile.label)} packet`,
1350
- '',
1351
- `**Domain:** ${escapeMarkdown(caseRecord.domain)}`,
1352
- `**Category:** ${escapeMarkdown(category)}`,
1353
- `**Affected party:** ${escapeMarkdown(affectedParty)}`,
1354
- `**Observed at (UTC):** ${observedAt}`,
1355
- `**Generated at (UTC):** ${normalizedGeneratedAt}`,
1356
- `**Audience:** ${escapeMarkdown(profile.audience)}`,
1357
- `**Suggested subject:** ${escapeMarkdown(profile.subject)}`,
1358
- '',
1359
- '## Observed harm',
1360
- '',
1361
- escapeMarkdown(observedHarm),
1362
- '',
1363
- '## Exact abusive URLs',
1364
- '',
1365
- ...abusiveUrls.map((url) => `- ${escapeMarkdown(url)}`),
1366
- '',
1367
- '## Selected response route',
1368
- '',
1369
- ...(recipientRoute
1370
- ? [
1371
- `### ${contactLabel(recipientRoute.kind)}`,
1372
- '',
1373
- `- Case action: ${escapeMarkdown(recipientRoute.actionId)}`,
1374
- `- Contact: ${escapeMarkdown(recipientRoute.contact)}`,
1375
- `- Source: ${escapeMarkdown(recipientRoute.source)}`,
1376
- `- Route observed: ${recipientRoute.observedAt ?? 'Not provided'} (${recipientRoute.freshness})`,
1377
- `- Limitations: ${recipientRoute.limitations.length ? recipientRoute.limitations.map(escapeMarkdown).join('; ') : 'None recorded'}`,
1378
- '',
1379
- ]
1380
- : ['No profile-appropriate response route was bound to this packet.', '']),
1381
- '## Selected action lineage',
1382
- '',
1383
- ...(escalationHistory.length
1384
- ? escalationHistory.flatMap((action) => [
1385
- `- ${escapeMarkdown(action.type.replaceAll('_', ' '))} to ${escapeMarkdown(action.recipient)} · ${escapeMarkdown(action.state.replaceAll('_', ' '))} · updated ${action.updatedAt}`,
1386
- ` - Route observed: ${action.routeObservedAt ?? 'Not provided'}`,
1387
- ...(action.reference ? [` - Reference: ${escapeMarkdown(action.reference)}`] : []),
1388
- ...(action.providerOutcome ? [` - Typed provider outcome: ${escapeMarkdown(action.providerOutcome.replaceAll('_', ' '))}`] : []),
1389
- ...(action.outcomeDetail ? [` - Outcome detail: ${escapeMarkdown(action.outcomeDetail)}`] : []),
1390
- ...(action.originActionId ? [` - Originating action: ${escapeMarkdown(action.originActionId)}`] : []),
1391
- ...action.transitions.flatMap((event) => [
1392
- ` - ${event.occurredAt}: ${escapeMarkdown(event.previousState ?? 'none')} → ${escapeMarkdown(event.nextState)} · ${escapeMarkdown(event.sourceClass)} · ${escapeMarkdown(event.provenance)}${event.providerOutcome ? ` · ${escapeMarkdown(event.providerOutcome.replaceAll('_', ' '))}` : ''}${event.applied ? '' : ' · retained conflict'}`,
1393
- ...(event.reference ? [` - Reference: ${escapeMarkdown(event.reference)}`] : []),
1394
- ...(event.evidencePinId ? [` - Evidence pin: ${escapeMarkdown(event.evidencePinId)}`] : []),
1395
- ...(event.originActionId ? [` - Originating action: ${escapeMarkdown(event.originActionId)}`] : []),
1396
- ...event.limitations.map((limitation) => ` - Limitation: ${escapeMarkdown(limitation)}`),
1397
- ]),
1398
- ...(action.historyOmitted ? [` - ${action.historyOmitted} earlier transition event${action.historyOmitted === 1 ? '' : 's'} omitted by bound.`] : []),
1399
- ...action.historyLimitations.map((limitation) => ` - History limitation: ${escapeMarkdown(limitation)}`),
1400
- ])
1401
- : ['No Case action was selected.']),
1402
- ...(escalationHistoryOmitted ? [`- Unrelated Case actions excluded from packet: ${escalationHistoryOmitted}`] : []),
1403
- ...escalationHistoryLimitations.map((limitation) => `- Packet history limitation: ${escapeMarkdown(limitation)}`),
1404
- '',
1405
- '## Readiness and authorisation',
1406
- '',
1407
- `- Packet state: ${authorisation.status}`,
1408
- `- Reviewed-input SHA-256: ${authorisation.reviewedInputDigestSha256}`,
1409
- `- Supplied review digest matches: ${authorisation.digestMatches ? 'yes' : 'no'}`,
1410
- ...RESPONSE_AUTHORISATION_CONFIRMATION_IDS.map((id) => `- Confirmation ${id}: ${authorisation.confirmations[id] ? 'yes' : 'no'}`),
1411
- ...readiness.rows.flatMap((row) => [
1412
- `- ${escapeMarkdown(row.label)} [${row.state}]: ${escapeMarkdown(row.detail)}`,
1413
- ...row.limitations.map((limitation) => ` - Limitation: ${escapeMarkdown(limitation)}`),
1414
- ]),
1415
- ...authorisation.limitations.map((limitation) => `- ${escapeMarkdown(limitation)}`),
1416
- '',
1417
- '## Selected evidence and integrity references',
1418
- '',
1419
- ...(selectedEvidence.length ? selectedEvidence.map((item) => `- ${escapeMarkdown(item.id)} · ${escapeMarkdown(item.label)} · ${escapeMarkdown(item.source)} · ${item.observedAt} · ${escapeMarkdown(item.completeness)}`) : ['- No evidence pin was explicitly selected.']),
1420
- ...artefactReferences.map((item) => `- ${escapeMarkdown(item.id)} · ${escapeMarkdown(item.label)} · SHA-256 ${item.digestSha256} · captured ${item.capturedAt}`),
1421
- '',
1422
- '## Provider outcome and independent effect',
1423
- '',
1424
- responseLifecycle.latestProviderOutcome
1425
- ? `- Provider outcome time: ${responseLifecycle.latestProviderOutcome.occurredAt} (${escapeMarkdown(responseLifecycle.latestProviderOutcome.outcome.replaceAll('_', ' '))})`
1426
- : `- Provider outcome time: Withheld because the typed event state is ${responseLifecycle.providerOutcomeState}.`,
1427
- responseLifecycle.latestObservedChangeAt
1428
- ? `- Independently observed change time: ${responseLifecycle.latestObservedChangeAt}`
1429
- : `- Independently observed change time: Withheld because the independent change state is ${responseLifecycle.observedChangeState}.`,
1430
- ...(responseLifecycle.latestObservedEffect ? [`- Latest independent review: ${escapeMarkdown(responseLifecycle.latestObservedEffect.state.replaceAll('_', ' '))} · ${responseLifecycle.latestObservedEffect.observedAt} · ${escapeMarkdown(responseLifecycle.latestObservedEffect.source)}`] : []),
1431
- ...responseLifecycle.limitations.map((limitation) => `- ${escapeMarkdown(limitation)}`),
1432
- '',
1433
- '## Review and provenance',
1434
- '',
1435
- `- Preflight: ${preflight.status.replaceAll('_', ' ')} (${preflight.counts.pass} pass, ${preflight.counts.caution} caution, ${preflight.counts.block} block)`,
1436
- ...preflight.checks.map((check) => `- ${escapeMarkdown(check.label)} [${check.state}]: ${escapeMarkdown(check.detail)}`),
1437
- ...limitations.map((limitation) => `- ${escapeMarkdown(limitation)}`),
1438
- `- Case evidence pins: ${caseRecord.evidencePins.length}`,
1439
- `- Case decision records: ${caseRecord.decisions.length}`,
1440
- `- Case structured assertions: ${caseRecord.assertions.length}`,
1441
- `- Observation-age band at export: ${age.band.replaceAll('_', ' ')}`,
1442
- `- Canonical packet SHA-256: ${digestSha256}`,
1443
- '- Digest scope: canonical sorted JSON packet excluding the integrity object',
1444
- '',
1445
- '## Audience profile',
1446
- '',
1447
- ...profile.checklist.map((item) => `- Checklist: ${escapeMarkdown(item)}`),
1448
- ...profile.includedEvidence.map((item) => `- Included: ${escapeMarkdown(item)}`),
1449
- ...profile.excludedEvidence.map((item) => `- Excluded: ${escapeMarkdown(item)}`),
1450
- ...profile.redactions.map((item) => `- Redaction to confirm: ${escapeMarkdown(item)}`),
1451
- ...profile.attachments.map((item) => `- Attachment expectation: ${escapeMarkdown(item)}`),
1452
- ...profile.followUpFields.map((item) => `- Follow-up field: ${escapeMarkdown(item)}`),
1453
- ];
1454
- const markdown = `${lines.join('\n').trim()}\n`;
1455
- const email = [
1456
- `Subject: ${profile.subject}`,
1457
- '',
1458
- 'Hello,',
1459
- '',
1460
- `I am reporting observed ${category} activity involving ${caseRecord.domain}.`,
1461
- `Affected party: ${affectedParty}`,
1462
- `Observed at (UTC): ${observedAt}`,
1463
- '',
1464
- 'Observed harm:',
1465
- observedHarm,
1466
- '',
1467
- 'Exact URLs:',
1468
- ...abusiveUrls.map((url) => `- ${url}`),
1469
- '',
1470
- 'Selected evidence:',
1471
- ...(selectedEvidence.length
1472
- ? selectedEvidence.map((item) => `- ${item.label} — ${item.source}, observed ${item.observedAt} (${item.completeness})`)
1473
- : ['- No Case evidence pin was selected.']),
1474
- '',
1475
- `Reviewed packet SHA-256: ${digestSha256}`,
1476
- 'Attach the reviewed packet (and any separately reviewed evidence files) through the recipient’s approved submission channel; this message does not embed or transmit attachments.',
1477
- '',
1478
- responseLifecycle.latestProviderOutcome
1479
- ? `Provider outcome time: ${responseLifecycle.latestProviderOutcome.occurredAt} (${responseLifecycle.latestProviderOutcome.outcome.replaceAll('_', ' ')})`
1480
- : `Provider outcome time: Withheld because the typed event state is ${responseLifecycle.providerOutcomeState}.`,
1481
- responseLifecycle.latestObservedChangeAt
1482
- ? `Independently observed change time: ${responseLifecycle.latestObservedChangeAt}`
1483
- : `Independently observed change time: Withheld because the independent change state is ${responseLifecycle.observedChangeState}.`,
1484
- '',
1485
- 'Please review this report under the applicable abuse and acceptable-use policies.',
1486
- '',
1487
- authorisation.status === 'authorised'
1488
- ? 'This locally prepared packet is bound to explicit review confirmations. It was not submitted automatically and does not promise any provider outcome.'
1489
- : 'This is an unauthorised local draft with cautions. It was not submitted automatically and does not promise any provider outcome.',
1490
- ].join('\n');
1491
- return { json, markdown, email: `${email}\n` };
993
+ return { json, ...renderCaseResponsePacket(json) };
1492
994
  }
1493
995
  export function caseResponsePacketFilename(domain, format, generatedAt) {
1494
996
  const safeDomain = domain.toLowerCase().replace(/[^a-z0-9.-]+/gu, '-').replace(/^-+|-+$/gu, '').slice(0, 80) || 'case';