@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
@@ -3,12 +3,17 @@
3
3
  // bounds, import merging, and exact serialized-byte accounting.
4
4
  import { normalizeDomain } from "../cases/case-model.mjs";
5
5
  import { normalizeExplicitIsoTimestamp } from "../evidence/observation.mjs";
6
+ import { canonicalDomainControlRecords, normalizeDeclaredDomainControlRecordModes, normalizeDomainControlRecordModes } from "../evidence/domain-control-runtime.mjs";
7
+ import { DOMAIN_CONTROL_RECORD_LIST_FIELDS } from "../contracts/domain-control-manifest.mjs";
8
+ import { latestObservationCohort } from "../evidence/latest-observations.mjs";
9
+ import { MAX_IDENTITY_DIGEST_BYTES, sha256IdentityHex } from "../evidence/record-identity.mjs";
10
+ import { DOMAIN_POSTURE_COMPARISON_VERSION, MAX_POSTURE_CHECKS, MAX_POSTURE_CHECK_RECORDS, MAX_POSTURE_RECORD_LENGTH, normalizeDomainPostureSourceContext, normalizeDomainPostureProfileContext } from "../evidence/domain-posture-context.mjs";
6
11
  import { normalizeOpaqueReferenceId } from "../cases/opaque-reference-id.mjs";
7
12
  import { normalizePageBaseline } from "./page-baseline.mjs";
8
13
  import { isInformativePerceptualHash as isInformativeFaviconHash } from "../../lib/perceptual-hash-comparison.mjs";
9
14
  import { assertWorkspaceDeclaredVersion, assertWorkspaceInputGraph, assertWorkspacePortableVersion, ordinaryWorkspaceRecord } from "./hostile-input.mjs";
10
- import { BRAND_PROFILE_SCHEMA, BRAND_PROFILE_SCHEMA_VERSION, MAX_DESIRED_POSTURE_BASELINES, MAX_DESIRED_POSTURE_CHANGE_WINDOWS, MAX_DESIRED_POSTURE_OBSERVATIONS, MAX_DESIRED_POSTURE_RECORDS, MAX_DESIRED_POSTURE_SUPPRESSIONS, MAX_DKIM_SELECTOR_LENGTH, MAX_DKIM_SELECTORS, MAX_PROFILES, MAX_PROFILE_DOMAIN_LENGTH, MAX_PROFILE_NAME_LENGTH, MAX_PROFILE_STORE_BYTES, MAX_PROFILE_TEXT_LENGTH, MAX_PROFILE_TLD_LENGTH, MAX_PROFILE_VALUE_INPUTS, MAX_PROFILE_VALUES, MAX_PROTECTION_ATTESTATIONS, SUPPORTED_BRAND_PROFILE_SCHEMA_VERSIONS, } from "../contracts/workspace-portability.mjs";
11
- export { BRAND_PROFILE_SCHEMA, BRAND_PROFILE_SCHEMA_VERSION, MAX_DESIRED_POSTURE_BASELINES, MAX_DESIRED_POSTURE_CHANGE_WINDOWS, MAX_DESIRED_POSTURE_OBSERVATIONS, MAX_DESIRED_POSTURE_RECORDS, MAX_DESIRED_POSTURE_SUPPRESSIONS, MAX_DKIM_SELECTOR_LENGTH, MAX_DKIM_SELECTORS, MAX_PROFILES, MAX_PROFILE_DOMAIN_LENGTH, MAX_PROFILE_NAME_LENGTH, MAX_PROFILE_STORE_BYTES, MAX_PROFILE_TEXT_LENGTH, MAX_PROFILE_TLD_LENGTH, MAX_PROFILE_VALUE_INPUTS, MAX_PROFILE_VALUES, MAX_PROTECTION_ATTESTATIONS, SUPPORTED_BRAND_PROFILE_SCHEMA_VERSIONS, } from "../contracts/workspace-portability.mjs";
15
+ import { BRAND_PROFILE_SCHEMA, BRAND_PROFILE_SCHEMA_VERSION, MAX_DESIRED_POSTURE_BASELINES, MAX_DESIRED_POSTURE_CHANGE_WINDOWS, MAX_DESIRED_POSTURE_OBSERVATIONS, MAX_DESIRED_POSTURE_RECORDS, MAX_DESIRED_POSTURE_SUPPRESSIONS, MAX_DKIM_SELECTOR_LENGTH, MAX_DKIM_SELECTORS, MAX_OFFICIAL_CHANNELS, MAX_PROFILES, MAX_PROFILE_DOMAIN_LENGTH, MAX_PROFILE_NAME_LENGTH, MAX_PROFILE_STORE_BYTES, MAX_PROFILE_TEXT_LENGTH, MAX_PROFILE_TLD_LENGTH, MAX_PROFILE_URL_LENGTH, MAX_PROFILE_VALUE_INPUTS, MAX_PROFILE_VALUES, MAX_PROTECTION_ATTESTATIONS, MAX_RIGHTS_REFERENCES, SUPPORTED_BRAND_PROFILE_SCHEMA_VERSIONS, } from "../contracts/workspace-portability.mjs";
16
+ export { BRAND_PROFILE_SCHEMA, BRAND_PROFILE_SCHEMA_VERSION, MAX_DESIRED_POSTURE_BASELINES, MAX_DESIRED_POSTURE_CHANGE_WINDOWS, MAX_DESIRED_POSTURE_OBSERVATIONS, MAX_DESIRED_POSTURE_RECORDS, MAX_DESIRED_POSTURE_SUPPRESSIONS, MAX_DKIM_SELECTOR_LENGTH, MAX_DKIM_SELECTORS, MAX_OFFICIAL_CHANNELS, MAX_PROFILES, MAX_PROFILE_DOMAIN_LENGTH, MAX_PROFILE_NAME_LENGTH, MAX_PROFILE_STORE_BYTES, MAX_PROFILE_TEXT_LENGTH, MAX_PROFILE_TLD_LENGTH, MAX_PROFILE_URL_LENGTH, MAX_PROFILE_VALUE_INPUTS, MAX_PROFILE_VALUES, MAX_PROTECTION_ATTESTATIONS, MAX_RIGHTS_REFERENCES, SUPPORTED_BRAND_PROFILE_SCHEMA_VERSIONS, } from "../contracts/workspace-portability.mjs";
12
17
  const SHA256_RE = /^[a-f0-9]{64}$/i;
13
18
  const CONTROL_RE = /[\x00-\x1f\x7f]/;
14
19
  const DNS_LABEL_RE = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
@@ -42,6 +47,17 @@ export const DESIRED_POSTURE_SUPPRESSION_FIELDS = Object.freeze([
42
47
  ]);
43
48
  const DESIRED_POSTURE_FIELDS = new Set(DESIRED_POSTURE_SUPPRESSION_FIELDS);
44
49
  const POSTURE_CHECK_STATUSES = new Set(['danger', 'info', 'pass', 'warning']);
50
+ export const OFFICIAL_CHANNEL_PLATFORMS = [
51
+ 'facebook',
52
+ 'instagram',
53
+ 'linkedin',
54
+ 'telegram',
55
+ 'tiktok',
56
+ 'x',
57
+ 'youtube',
58
+ 'other',
59
+ ];
60
+ export const RIGHTS_REFERENCE_KINDS = ['trademark', 'copyright', 'design', 'other'];
45
61
  function record(value) {
46
62
  return ordinaryWorkspaceRecord(value, 'Brand Profile input') ?? {};
47
63
  }
@@ -107,6 +123,32 @@ export function normalizeProfileDomains(value) {
107
123
  export function normalizeProfileTextValues(value) {
108
124
  return normalizeList(value, (item) => boundedText(item));
109
125
  }
126
+ export const MAX_ALLOWLIST_DRAFT_CHARACTERS = (MAX_PROFILE_DOMAIN_LENGTH + 2) * MAX_PROFILE_VALUE_INPUTS;
127
+ /** Admit the complete submitted list or leave the draft unchanged. */
128
+ export function addBrandAllowlistValues(profile, kind, current, raw) {
129
+ if (raw.length > MAX_ALLOWLIST_DRAFT_CHARACTERS)
130
+ throw new RangeError('The submitted allowlist text is too large.');
131
+ const inputs = raw.split(/[\n,]+/u).map((item) => item.trim()).filter(Boolean);
132
+ if (inputs.length > MAX_PROFILE_VALUE_INPUTS)
133
+ throw new RangeError(`Review at most ${MAX_PROFILE_VALUE_INPUTS} entries at a time.`);
134
+ const next = new Map(current.map((value) => [value.toLowerCase(), value]));
135
+ const trusted = new Set([...profile.officialDomains, ...profile.approvedPartnerDomains]);
136
+ for (const [index, input] of inputs.entries()) {
137
+ const value = kind === 'domains' ? normalizeProfileDomains([input])[0] : normalizeProfileTextValues([input])[0];
138
+ if (!value || (kind === 'registrars' && input.replace(/\s+/gu, ' ').length > MAX_PROFILE_TEXT_LENGTH)) {
139
+ throw new TypeError(`Allowlist entry ${index + 1} is invalid or too long. No entries were added.`);
140
+ }
141
+ if (kind === 'domains' && trusted.has(value))
142
+ continue;
143
+ if (!next.has(value.toLowerCase()))
144
+ next.set(value.toLowerCase(), value);
145
+ }
146
+ if (next.size > MAX_PROFILE_VALUES)
147
+ throw new RangeError(`This list would contain ${next.size} entries; the current profile supports ${MAX_PROFILE_VALUES}. No entries were added.`);
148
+ if (next.size === current.length)
149
+ throw new TypeError('No new entries remain after excluding existing and trusted values.');
150
+ return [...next.values()];
151
+ }
110
152
  export function normalizeProfileTlds(value) {
111
153
  return normalizeList(value, normalizeTld);
112
154
  }
@@ -148,6 +190,30 @@ export function normalizeProtectionAttestations(value) {
148
190
  }
149
191
  return output;
150
192
  }
193
+ /** Only submitted, validated reviews acquire a new analyst-review clock. */
194
+ export function reviewProtectionAttestations(existing, reviews, nowIso) {
195
+ const assertedAt = timestamp(nowIso, null);
196
+ if (!assertedAt)
197
+ throw new TypeError('The account-control review time is invalid.');
198
+ if (reviews.length > MAX_PROTECTION_ATTESTATIONS)
199
+ throw new RangeError('Too many account-control reviews were submitted.');
200
+ const next = new Map(existing.map((item) => [item.control, { ...item }]));
201
+ const seen = new Set();
202
+ for (const review of reviews) {
203
+ if (!PROTECTION_ATTESTATION_CONTROL_SET.has(review.control) || seen.has(review.control)
204
+ || !PROTECTION_ATTESTATION_STATES.has(review.state))
205
+ throw new TypeError('An account-control review is invalid or repeated.');
206
+ const expiresAt = review.expiresAt === null ? null : timestamp(review.expiresAt, null);
207
+ if (review.expiresAt !== null && !expiresAt)
208
+ throw new TypeError('Enter a valid account-control expiry date.');
209
+ if (typeof review.note !== 'string' || CONTROL_RE.test(review.note)
210
+ || review.note.replace(/\s+/gu, ' ').trim().length > MAX_PROFILE_TEXT_LENGTH)
211
+ throw new TypeError(`Account-control notes must contain at most ${MAX_PROFILE_TEXT_LENGTH} characters and no control characters.`);
212
+ seen.add(review.control);
213
+ next.set(review.control, { control: review.control, state: review.state, assertedAt, expiresAt, note: boundedText(review.note) });
214
+ }
215
+ return [...next.values()];
216
+ }
151
217
  function normalizeDesiredPostureRecords(value, normalizer) {
152
218
  if (!Array.isArray(value))
153
219
  return [];
@@ -165,9 +231,10 @@ function normalizeDesiredPostureRecords(value, normalizer) {
165
231
  }
166
232
  function normalizeDesiredPostureObservation(value) {
167
233
  const candidate = record(value);
168
- const observedAt = timestamp(candidate.observedAt, null);
169
- if (!observedAt || !Array.isArray(candidate.checks))
234
+ const observedAt = normalizeExplicitIsoTimestamp(candidate.observedAt) ?? '';
235
+ if (!Array.isArray(candidate.checks))
170
236
  return null;
237
+ const context = normalizeDomainPostureProfileContext(candidate.context);
171
238
  const checks = [];
172
239
  const seen = new Set();
173
240
  for (const item of candidate.checks.slice(0, 64)) {
@@ -179,28 +246,88 @@ function normalizeDesiredPostureObservation(value) {
179
246
  || !POSTURE_CHECK_STATUSES.has(check.status))
180
247
  continue;
181
248
  seen.add(id);
249
+ if (check.sourceContext !== undefined && !Array.isArray(check.records))
250
+ throw new TypeError('Source-qualified posture records must be an array.');
251
+ const inputRecords = Array.isArray(check.records) ? check.records : [];
252
+ const records = [];
253
+ for (const item of inputRecords.slice(0, MAX_POSTURE_CHECK_RECORDS)) {
254
+ if (typeof item === 'string' && item.length <= MAX_POSTURE_RECORD_LENGTH && !CONTROL_RE.test(item))
255
+ records.push(item);
256
+ }
257
+ const omitted = Math.max(0, inputRecords.length - records.length);
258
+ const source = normalizeDomainPostureSourceContext(check.sourceContext);
259
+ const sourceContext = source && omitted ? {
260
+ ...source,
261
+ state: source.state === 'unavailable' ? 'unavailable' : 'partial',
262
+ omittedRecords: source.omittedRecords === null ? null : Math.min(Number.MAX_SAFE_INTEGER, source.omittedRecords + omitted),
263
+ } : source;
182
264
  checks.push({
183
265
  id,
184
266
  status: check.status,
185
- records: normalizeDesiredPostureRecords(check.records),
267
+ records,
268
+ ...(sourceContext ? { sourceContext } : {}),
186
269
  });
187
- if (checks.length >= 32)
270
+ if (checks.length >= MAX_POSTURE_CHECKS)
188
271
  break;
189
272
  }
190
- return checks.length ? { observedAt, checks } : null;
273
+ const omittedChecks = Math.max(0, candidate.checks.length - checks.length)
274
+ + (Number.isSafeInteger(candidate.omittedChecks) && Number(candidate.omittedChecks) > 0 ? Number(candidate.omittedChecks) : 0);
275
+ return checks.length ? { observedAt, ...(context ? { context } : {}), checks, ...(omittedChecks ? { omittedChecks: Math.min(Number.MAX_SAFE_INTEGER, omittedChecks) } : {}) } : null;
191
276
  }
192
- function normalizeDesiredPostureObservationHistory(value, previous) {
277
+ export function normalizeDesiredPostureObservationHistory(value, previous) {
193
278
  const candidates = Array.isArray(value) ? value : previous ? [previous] : [];
194
279
  const byTime = new Map();
195
280
  for (const item of candidates.slice(0, MAX_DESIRED_POSTURE_OBSERVATIONS * 4)) {
196
281
  const normalized = normalizeDesiredPostureObservation(item);
197
- if (normalized)
198
- byTime.set(normalized.observedAt, normalized);
282
+ if (!normalized)
283
+ continue;
284
+ if (new TextEncoder().encode(JSON.stringify(normalized)).byteLength > MAX_IDENTITY_DIGEST_BYTES) {
285
+ throw new RangeError('Record identity exceeds its byte limit.');
286
+ }
287
+ const cohort = byTime.get(normalized.observedAt) ?? [];
288
+ cohort.push(normalized);
289
+ byTime.set(normalized.observedAt, cohort);
199
290
  }
200
- return [...byTime.values()]
201
- .sort((left, right) => Date.parse(left.observedAt) - Date.parse(right.observedAt))
291
+ return [...byTime.entries()]
292
+ .sort(([left], [right]) => !left ? 1 : !right ? -1 : Date.parse(left) - Date.parse(right))
293
+ .flatMap(([, cohort]) => {
294
+ // Different capture times already distinguish observations. Digest-based
295
+ // deduplication and ordering remain necessary only within a tied cohort.
296
+ if (cohort.length === 1)
297
+ return cohort;
298
+ const byIdentity = new Map(cohort.map((observation) => [desiredPostureObservationIdentity(observation), observation]));
299
+ return [...byIdentity.entries()].sort(([left], [right]) => left < right ? -1 : left > right ? 1 : 0)
300
+ .map(([, observation]) => observation);
301
+ })
202
302
  .slice(-MAX_DESIRED_POSTURE_OBSERVATIONS);
203
303
  }
304
+ export function desiredPostureObservationIdentity(observation) {
305
+ return sha256IdentityHex(new TextEncoder().encode(JSON.stringify(observation)));
306
+ }
307
+ export function desiredPostureObservations(baseline) {
308
+ return baseline.observationHistory?.length ? baseline.observationHistory : baseline.previousObservation ? [baseline.previousObservation] : [];
309
+ }
310
+ export function currentDesiredPostureObservation(baseline) {
311
+ const history = desiredPostureObservations(baseline);
312
+ const cohort = latestObservationCohort(history, (item) => item.observedAt);
313
+ return {
314
+ observation: !cohort.undated.length && cohort.latest.length === 1 ? cohort.latest[0] : null,
315
+ candidates: [...cohort.latest, ...cohort.undated],
316
+ limitation: cohort.undated.length ? 'Retained observations include an unknown capture time; no unique latest review is selected.'
317
+ : cohort.latest.length > 1 ? 'Distinct observations share the latest capture time; no unique latest review is selected.' : null,
318
+ };
319
+ }
320
+ export function brandPostureCollectionFingerprint(profile) {
321
+ return sha256IdentityHex(new TextEncoder().encode(JSON.stringify([
322
+ profile.id, [...profile.officialDomains].sort(), profile.mailProtectionProfile,
323
+ [...profile.dkimSelectors].sort(), [...profile.retiredDkimSelectors].sort(),
324
+ ])));
325
+ }
326
+ export function brandPostureObservationContext(profile, domain) {
327
+ if (!profile.officialDomains.includes(domain))
328
+ throw new TypeError('Posture observation target is not an official domain of this profile.');
329
+ return { version: DOMAIN_POSTURE_COMPARISON_VERSION, domain, profileId: profile.id, profileFingerprint: brandPostureCollectionFingerprint(profile) };
330
+ }
204
331
  function deterministicChangeWindowId(seed) {
205
332
  const states = [0x811c9dc5, 0x9e3779b9, 0x85ebca6b, 0xc2b2ae35];
206
333
  const factors = [0x01000193, 0x85ebca6b, 0xc2b2ae35, 0x27d4eb2f];
@@ -259,6 +386,23 @@ function normalizeTlsSanPatterns(value) {
259
386
  }
260
387
  return [...output].sort();
261
388
  }
389
+ function normalizeBaselineRecords(candidate) {
390
+ const records = {
391
+ nameservers: normalizeDesiredPostureRecords(candidate.nameservers, (entry) => normalizeDomain(entry)),
392
+ ds: normalizeDesiredPostureRecords(candidate.ds),
393
+ mx: normalizeDesiredPostureRecords(candidate.mx),
394
+ caa: normalizeDesiredPostureRecords(candidate.caa),
395
+ };
396
+ if (!Object.hasOwn(candidate, 'recordModes'))
397
+ return records;
398
+ const recordModes = normalizeDeclaredDomainControlRecordModes(candidate.recordModes);
399
+ for (const field of DOMAIN_CONTROL_RECORD_LIST_FIELDS) {
400
+ if (Object.hasOwn(recordModes, field))
401
+ records[field] = canonicalDomainControlRecords(candidate[field], field);
402
+ }
403
+ normalizeDomainControlRecordModes(recordModes, records);
404
+ return { ...records, recordModes };
405
+ }
262
406
  export function normalizeDesiredPostureBaselines(value, officialDomains, fallbackNow = new Date().toISOString(), identityScope = '') {
263
407
  if (!Array.isArray(value))
264
408
  return [];
@@ -295,10 +439,7 @@ export function normalizeDesiredPostureBaselines(value, officialDomains, fallbac
295
439
  output.push({
296
440
  version: 1,
297
441
  domain,
298
- nameservers: normalizeDesiredPostureRecords(candidate.nameservers, (entry) => normalizeDomain(entry)),
299
- ds: normalizeDesiredPostureRecords(candidate.ds),
300
- mx: normalizeDesiredPostureRecords(candidate.mx),
301
- caa: normalizeDesiredPostureRecords(candidate.caa),
442
+ ...normalizeBaselineRecords(candidate),
302
443
  tlsIssuer: boundedText(candidate.tlsIssuer, MAX_PROFILE_TEXT_LENGTH),
303
444
  tlsSanPatterns: normalizeTlsSanPatterns(candidate.tlsSanPatterns),
304
445
  tlsSpkiSha256: typeof candidate.tlsSpkiSha256 === 'string' && SHA256_RE.test(candidate.tlsSpkiSha256)
@@ -318,7 +459,7 @@ export function normalizeDesiredPostureBaselines(value, officialDomains, fallbac
318
459
  approvedChangeWindows: normalizeDesiredPostureChangeWindows(candidate.approvedChangeWindows, `${identityScope}\u001f${domain}`),
319
460
  suppressions,
320
461
  note: boundedText(candidate.note, MAX_PROFILE_TEXT_LENGTH),
321
- previousObservation: observationHistory.at(-1) ?? previousObservation,
462
+ previousObservation: currentDesiredPostureObservation({ observationHistory, previousObservation }).observation,
322
463
  observationHistory,
323
464
  updatedAt: timestamp(candidate.updatedAt, fallback),
324
465
  });
@@ -333,6 +474,82 @@ function normalizeFaviconHash(value) {
333
474
  function normalizeFaviconPHash(value) {
334
475
  return typeof value === 'string' && isInformativeFaviconHash(value) ? value.toLowerCase() : '';
335
476
  }
477
+ function normalizePublicUrl(value) {
478
+ if (typeof value !== 'string' || CONTROL_RE.test(value) || value.length > MAX_PROFILE_URL_LENGTH * 2)
479
+ return '';
480
+ try {
481
+ const parsed = new URL(value.trim());
482
+ if (!['http:', 'https:'].includes(parsed.protocol) || parsed.username || parsed.password)
483
+ return '';
484
+ const normalized = parsed.toString();
485
+ return normalized.length <= MAX_PROFILE_URL_LENGTH ? normalized : '';
486
+ }
487
+ catch {
488
+ return '';
489
+ }
490
+ }
491
+ function normalizeOfficialChannels(value) {
492
+ if (!Array.isArray(value))
493
+ return [];
494
+ const output = new Map();
495
+ for (const candidate of value.slice(0, MAX_OFFICIAL_CHANNELS * 4)) {
496
+ const item = record(candidate);
497
+ const platform = typeof item.platform === 'string'
498
+ && OFFICIAL_CHANNEL_PLATFORMS.includes(item.platform)
499
+ ? item.platform
500
+ : null;
501
+ const url = normalizePublicUrl(item.url);
502
+ if (!platform || !url)
503
+ continue;
504
+ const key = url.toLowerCase();
505
+ if (output.has(key))
506
+ continue;
507
+ output.set(key, {
508
+ platform,
509
+ url,
510
+ handle: boundedText(item.handle),
511
+ role: boundedText(item.role),
512
+ reviewedAt: timestamp(item.reviewedAt, null),
513
+ });
514
+ if (output.size >= MAX_OFFICIAL_CHANNELS)
515
+ break;
516
+ }
517
+ return [...output.values()];
518
+ }
519
+ function normalizeRightsReferences(value) {
520
+ if (!Array.isArray(value))
521
+ return [];
522
+ const output = new Map();
523
+ for (const candidate of value.slice(0, MAX_RIGHTS_REFERENCES * 4)) {
524
+ const item = record(candidate);
525
+ const kind = typeof item.kind === 'string' && RIGHTS_REFERENCE_KINDS.includes(item.kind)
526
+ ? item.kind
527
+ : null;
528
+ const owner = boundedText(item.owner);
529
+ const identifier = boundedText(item.identifier);
530
+ if (!kind || (!owner && !identifier))
531
+ continue;
532
+ const jurisdiction = boundedText(item.jurisdiction);
533
+ const sourceUrl = item.sourceUrl == null || item.sourceUrl === '' ? '' : normalizePublicUrl(item.sourceUrl);
534
+ if (item.sourceUrl && !sourceUrl)
535
+ continue;
536
+ const key = `${kind}\u0000${owner.toLowerCase()}\u0000${identifier.toLowerCase()}\u0000${jurisdiction.toLowerCase()}`;
537
+ if (output.has(key))
538
+ continue;
539
+ output.set(key, {
540
+ kind,
541
+ owner,
542
+ identifier,
543
+ jurisdiction,
544
+ sourceUrl,
545
+ reviewedAt: timestamp(item.reviewedAt, null),
546
+ note: boundedText(item.note),
547
+ });
548
+ if (output.size >= MAX_RIGHTS_REFERENCES)
549
+ break;
550
+ }
551
+ return [...output.values()];
552
+ }
336
553
  /** Normalize one profile while retaining only known, bounded fields. */
337
554
  export function normalizeBrandProfile(raw, options = {}) {
338
555
  const value = record(raw);
@@ -360,6 +577,7 @@ export function normalizeBrandProfile(raw, options = {}) {
360
577
  id: profileId,
361
578
  name,
362
579
  officialDomains,
580
+ officialChannels: normalizeOfficialChannels(value.officialChannels),
363
581
  productNames: normalizeProfileTextValues(value.productNames),
364
582
  tlds: normalizeProfileTlds(value.tlds),
365
583
  approvedPartnerDomains: normalizeProfileDomains(value.approvedPartnerDomains),
@@ -373,6 +591,7 @@ export function normalizeBrandProfile(raw, options = {}) {
373
591
  desiredPostureBaselines: normalizeDesiredPostureBaselines(value.desiredPostureBaselines, officialDomains, now, profileId),
374
592
  trademarkOwner: boundedText(value.trademarkOwner),
375
593
  trademarkRegistration: boundedText(value.trademarkRegistration),
594
+ rightsReferences: normalizeRightsReferences(value.rightsReferences),
376
595
  officialFaviconHash: baselineDomainMismatch ? '' : normalizeFaviconHash(value.officialFaviconHash),
377
596
  officialFaviconPHash: baselineDomainMismatch ? '' : normalizeFaviconPHash(value.officialFaviconPHash),
378
597
  pageBaseline,
@@ -380,6 +599,33 @@ export function normalizeBrandProfile(raw, options = {}) {
380
599
  updatedAt: options.touch === true ? now : timestamp(value.updatedAt, createdAt),
381
600
  };
382
601
  }
602
+ const BRAND_PROFILE_FIELD_PATCH_KEYS = Object.freeze([
603
+ 'allowlistedDomains',
604
+ 'allowlistedRegistrars',
605
+ 'desiredPostureBaselines',
606
+ 'protectionAttestations',
607
+ ]);
608
+ /**
609
+ * Applies one field-owned edit to the transaction-current profile. Identity,
610
+ * timestamps, and every field outside the explicit patch remain owned by that
611
+ * current record rather than by the caller's potentially stale snapshot.
612
+ */
613
+ export function applyBrandProfileFieldPatch(existing, patch, options = {}) {
614
+ const merged = { ...existing };
615
+ const supplied = patch;
616
+ for (const key of BRAND_PROFILE_FIELD_PATCH_KEYS) {
617
+ if (Object.hasOwn(supplied, key))
618
+ merged[key] = supplied[key];
619
+ }
620
+ const normalized = normalizeBrandProfile(merged, {
621
+ existing,
622
+ nowIso: options.nowIso,
623
+ touch: true,
624
+ });
625
+ if (!normalized)
626
+ throw new Error('The Brand Profile field update is invalid.');
627
+ return normalized;
628
+ }
383
629
  function profileList(raw) {
384
630
  if (Array.isArray(raw))
385
631
  return raw;
@@ -394,7 +640,7 @@ export function brandProfileStoreVersion(raw) {
394
640
  }
395
641
  /** Normalize an internal profile collection or current stored envelope. */
396
642
  export function normalizeBrandProfileStore(raw) {
397
- assertWorkspaceInputGraph(raw, 'Brand Profile store');
643
+ assertWorkspaceInputGraph(raw, 'Brand Profile store', { maximumBytes: MAX_PROFILE_STORE_BYTES });
398
644
  assertWorkspaceDeclaredVersion(raw, 'Brand Profile store');
399
645
  const sourceVersion = brandProfileStoreVersion(raw);
400
646
  if (!Array.isArray(raw) && sourceVersion !== null
@@ -416,6 +662,10 @@ export function normalizeBrandProfileStore(raw) {
416
662
  }
417
663
  return { version: BRAND_PROFILE_SCHEMA_VERSION, profiles: [...byId.values()] };
418
664
  }
665
+ /** New authored/imported records receive an identity only at the mutation boundary. */
666
+ export function createBrandProfileId() {
667
+ return crypto.randomUUID ? crypto.randomUUID() : `bp-${Date.now()}-${Math.random().toString(36).slice(2)}`;
668
+ }
419
669
  export function serializeBrandProfileStore(profiles) {
420
670
  return JSON.stringify(assertBrandProfileStoreBudget(profiles));
421
671
  }
@@ -430,8 +680,8 @@ export function assertBrandProfileStoreBudget(profiles) {
430
680
  return store;
431
681
  }
432
682
  export function mergeBrandProfiles(localRaw, importedRaw, options = {}) {
433
- assertWorkspaceInputGraph(localRaw, 'Local Brand Profile store');
434
- assertWorkspaceInputGraph(importedRaw, 'Imported Brand Profile document');
683
+ const local = normalizeBrandProfileStore(localRaw).profiles;
684
+ assertWorkspaceInputGraph(importedRaw, 'Imported Brand Profile document', { maximumBytes: MAX_PROFILE_STORE_BYTES });
435
685
  assertWorkspacePortableVersion(importedRaw, BRAND_PROFILE_SCHEMA_VERSION, 'Imported Brand Profile document');
436
686
  const imported = record(importedRaw);
437
687
  if (imported.schema !== BRAND_PROFILE_SCHEMA) {
@@ -463,7 +713,6 @@ export function mergeBrandProfiles(localRaw, importedRaw, options = {}) {
463
713
  if (id && name)
464
714
  retainIdName(id, name);
465
715
  }
466
- const local = normalizeBrandProfileStore(localRaw).profiles;
467
716
  for (const profile of local)
468
717
  retainIdName(profile.id, profile.name);
469
718
  const byName = new Map(local.map((profile) => [profile.name.toLowerCase(), profile]));
@@ -478,9 +727,14 @@ export function mergeBrandProfiles(localRaw, importedRaw, options = {}) {
478
727
  if (rawId && rawName)
479
728
  retainIdName(rawId, rawName);
480
729
  const existing = rawName ? byName.get(rawName.toLowerCase()) : null;
730
+ const incomingUpdatedAt = timestamp(value.updatedAt, null);
731
+ if (existing && (!incomingUpdatedAt || incomingUpdatedAt <= existing.updatedAt)) {
732
+ skipped++;
733
+ continue;
734
+ }
481
735
  const profile = normalizeBrandProfile(item, {
482
736
  existing,
483
- touch: Boolean(existing),
737
+ touch: false,
484
738
  nowIso: options.nowIso,
485
739
  makeId: options.makeId,
486
740
  });
@@ -0,0 +1,17 @@
1
+ /** Optional result columns. Domain identity and actions are always available. */
2
+ export const BULK_RESULT_COLUMNS = Object.freeze([
3
+ { id: 'registration', label: 'Registration' },
4
+ { id: 'risk', label: 'Risk' },
5
+ { id: 'website', label: 'Website' },
6
+ { id: 'registrar', label: 'Registrar' },
7
+ { id: 'mutation', label: 'Mutation' },
8
+ { id: 'review', label: 'Review' },
9
+ { id: 'case', label: 'Case' },
10
+ ]);
11
+ export const DEFAULT_BULK_RESULT_COLUMNS = Object.freeze(BULK_RESULT_COLUMNS.map((column) => column.id));
12
+ export function normalizeBulkResultColumns(value) {
13
+ // Missing choices preserve the full presentation used by public version 1.
14
+ if (!Array.isArray(value))
15
+ return [...DEFAULT_BULK_RESULT_COLUMNS];
16
+ return BULK_RESULT_COLUMNS.filter((column) => value.includes(column.id)).map((column) => column.id);
17
+ }
@@ -1,21 +1,28 @@
1
1
  import { normalizeDomain } from "../cases/case-model.mjs";
2
- import { normalizeBulkPresentationSortKey } from "./bulk-sort.mjs";
2
+ import { CASE_DISPOSITIONS as CASE_DISPOSITION_OPTIONS } from "../cases/case-record-contracts.mjs";
3
+ import { BULK_SORT_KEYS, normalizeBulkPresentationSortKey } from "./bulk-sort.mjs";
4
+ import { normalizeBulkResultColumns } from "./bulk-columns.mjs";
3
5
  import { normalizeExplicitIsoTimestamp } from "../evidence/observation.mjs";
4
6
  import { assertWorkspaceDeclaredVersion, assertWorkspaceInputGraph, assertWorkspacePortableVersion, ordinaryWorkspaceRecord } from "./hostile-input.mjs";
5
- import { BULK_REVIEW_SCHEMA, BULK_REVIEW_SCHEMA_VERSION, MAX_BULK_REVIEW_NAME_LENGTH, MAX_BULK_REVIEW_PRESETS, MAX_BULK_REVIEW_ROWS, MAX_BULK_REVIEW_STORE_BYTES, } from "../contracts/workspace-portability.mjs";
7
+ import { BULK_REVIEW_SCHEMA, BULK_REVIEW_SCHEMA_VERSION, BULK_REVIEW_EXPORT_SUPPORTED_VERSIONS, MAX_BULK_REVIEW_NAME_LENGTH, MAX_BULK_REVIEW_PRESETS, MAX_BULK_REVIEW_ROWS, MAX_BULK_REVIEW_STORE_BYTES, } from "../contracts/workspace-portability.mjs";
6
8
  export { BULK_REVIEW_SCHEMA, BULK_REVIEW_SCHEMA_VERSION, MAX_BULK_REVIEW_NAME_LENGTH, MAX_BULK_REVIEW_PRESETS, MAX_BULK_REVIEW_ROWS, MAX_BULK_REVIEW_STORE_BYTES, } from "../contracts/workspace-portability.mjs";
7
9
  export const BULK_REVIEW_STATES = ['unreviewed', 'reviewing', 'reviewed', 'deferred'];
10
+ export const BULK_SOURCE_FILTERS = ['', 'complete', 'limited', 'unrecorded'];
11
+ export const BULK_LIFECYCLE_FILTERS = ['', 'registered', 'for_sale', 'expiring', 'available', 'unknown', 'error'];
12
+ export const BULK_AGE_FILTERS = ['', 'new_30', 'new_365', 'older_365', 'unknown'];
13
+ export const BULK_MAIL_FILTERS = ['', 'mail', 'no_mail', 'authenticated', 'auth_gap', 'unknown'];
14
+ export const BULK_GROUP_OPTIONS = ['', 'mutation', 'tld', 'registrar', 'nameserver'];
8
15
  const CONTROL_RE = /[\u0000-\u001f\u007f]/u;
9
16
  const SAFE_ID_RE = /^[A-Za-z0-9_-]{1,128}$/u;
10
17
  const PRIMARY_FILTERS = new Set(['all', 'available', 'registered', 'high_risk', 'trusted', 'profile_unevaluated', 'errors']);
11
- const SOURCE_FILTERS = new Set(['', 'complete', 'limited', 'failed']);
12
- const LIFECYCLE_FILTERS = new Set(['', 'new', 'expiring', 'aged', 'unknown']);
13
- const AGE_FILTERS = new Set(['', '7d', '30d', '90d', '365d', 'older', 'unknown']);
14
- const MAIL_FILTERS = new Set(['', 'mx', 'no_mx', 'spf_missing', 'dmarc_missing', 'unknown']);
15
- const GROUPS = new Set(['', 'registrar', 'nameserver', 'source_state', 'lifecycle', 'mail']);
16
- const SORT_KEYS = new Set(['domain', 'availability', 'risk', 'opportunity', 'activity', 'registrar', 'mutation']);
18
+ const SOURCE_FILTERS = new Set(BULK_SOURCE_FILTERS);
19
+ const LIFECYCLE_FILTERS = new Set(BULK_LIFECYCLE_FILTERS);
20
+ const AGE_FILTERS = new Set(BULK_AGE_FILTERS);
21
+ const MAIL_FILTERS = new Set(BULK_MAIL_FILTERS);
22
+ const GROUPS = new Set(BULK_GROUP_OPTIONS);
23
+ const SORT_KEYS = new Set(BULK_SORT_KEYS);
17
24
  const REVIEW_STATES = new Set(BULK_REVIEW_STATES);
18
- const CASE_DISPOSITIONS = new Set(['', 'untracked', 'unreviewed', 'suspicious', 'confirmed_abuse', 'false_positive', 'expected', 'closed_no_action']);
25
+ const CASE_DISPOSITIONS = new Set(['', 'untracked', ...CASE_DISPOSITION_OPTIONS.map((item) => item.value)]);
19
26
  const SIGNAL_FILTERS = new Set(['favicon', 'password', 'phishing', 'asset_reuse', 'idn']);
20
27
  function record(value) {
21
28
  return ordinaryWorkspaceRecord(value, 'Bulk-review input') ?? {};
@@ -61,6 +68,7 @@ function normalizeView(raw) {
61
68
  groupBy: setValue(value.groupBy, GROUPS),
62
69
  sortKey,
63
70
  sortDirection: value.sortDirection === 1 ? 1 : -1,
71
+ columns: normalizeBulkResultColumns(value.columns),
64
72
  };
65
73
  }
66
74
  function normalizePreset(raw, fallback) {
@@ -107,6 +115,10 @@ function sourceLists(raw) {
107
115
  export function normalizeBulkReviewStore(raw) {
108
116
  assertWorkspaceInputGraph(raw, 'Bulk-review store');
109
117
  assertWorkspaceDeclaredVersion(raw, 'Bulk-review store');
118
+ const declaredVersion = record(raw).version;
119
+ if (typeof declaredVersion === 'number' && declaredVersion > BULK_REVIEW_SCHEMA_VERSION) {
120
+ throw new Error(`This Bulk review store uses newer schema ${declaredVersion}. Update the app before changing it.`);
121
+ }
110
122
  const fallback = new Date(0).toISOString();
111
123
  const source = sourceLists(raw);
112
124
  const presets = new Map();
@@ -148,7 +160,7 @@ export function enforceBulkReviewBudget(raw) {
148
160
  while (byteLength(JSON.stringify(store)) > MAX_BULK_REVIEW_STORE_BYTES && store.presets.length > 1)
149
161
  store.presets.pop();
150
162
  if (byteLength(JSON.stringify(store)) > MAX_BULK_REVIEW_STORE_BYTES) {
151
- throw new Error('Bulk review preferences exceed the browser-local storage limit.');
163
+ throw new Error('Bulk review preferences exceed the workspace storage limit.');
152
164
  }
153
165
  return store;
154
166
  }
@@ -211,7 +223,7 @@ export function mergeBulkReviewStores(localRaw, importedRaw) {
211
223
  if (importedRecord.schema !== BULK_REVIEW_SCHEMA) {
212
224
  throw new Error('This file is not a WHOISleuth Bulk review export.');
213
225
  }
214
- if (importedRecord.version !== BULK_REVIEW_SCHEMA_VERSION) {
226
+ if (!BULK_REVIEW_EXPORT_SUPPORTED_VERSIONS.some((version) => version === importedRecord.version)) {
215
227
  if (typeof importedRecord.version === 'number' && importedRecord.version > BULK_REVIEW_SCHEMA_VERSION) {
216
228
  throw new Error(`This Bulk review export uses newer schema ${importedRecord.version}. Update the app before importing it.`);
217
229
  }