@slicedearth/whoisleuth-cli 2.4.0 → 2.6.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 (340) hide show
  1. package/DISCLOSURE +1 -1
  2. package/NOTICE +2 -2
  3. package/cli/argument-values.mjs +55 -0
  4. package/cli/arguments.mjs +24 -470
  5. package/cli/artifact-validation/case-response.mjs +67 -9
  6. package/cli/artifact-validation/lookup-evidence.mjs +18 -4
  7. package/cli/artifact-validation/signed-review.mjs +61 -64
  8. package/cli/artifact-verify.mjs +11 -2
  9. package/cli/assurance-arguments.mjs +56 -0
  10. package/cli/assurance-command-definitions.mjs +179 -0
  11. package/cli/assurance-command-runner.mjs +1 -1
  12. package/cli/bounded-json.mjs +1 -1
  13. package/cli/bulk-checkpoint.mjs +1 -1
  14. package/cli/bulk-command-runner.mjs +6 -8
  15. package/cli/bulk.mjs +2 -3
  16. package/cli/case-command.mjs +99 -6
  17. package/cli/case-pack.mjs +19 -4
  18. package/cli/ci-report.mjs +6 -3
  19. package/cli/collection-arguments.mjs +123 -0
  20. package/cli/collection-command-definitions.mjs +330 -0
  21. package/cli/collection-preflight.mjs +8 -1
  22. package/cli/command-argument-grammar.mjs +8 -210
  23. package/cli/command-definition.mjs +330 -0
  24. package/cli/command-reference.mjs +55 -1388
  25. package/cli/command-seeds.mjs +18 -0
  26. package/cli/compare.mjs +1 -1
  27. package/cli/config-profile.mjs +1 -1
  28. package/cli/ct-event-intake.mjs +4 -4
  29. package/cli/discover.mjs +2 -2
  30. package/cli/discovery-observation-snapshot.mjs +2 -2
  31. package/cli/discovery-scan-command-runner.mjs +7 -9
  32. package/cli/discovery-scan.mjs +1 -1
  33. package/cli/discovery-snapshot.mjs +2 -2
  34. package/cli/discovery-workflow.mjs +4 -4
  35. package/cli/domain-control-monitor.mjs +6 -6
  36. package/cli/domain-control-observations.mjs +2 -1
  37. package/cli/domain-feed.mjs +61 -0
  38. package/cli/errors.mjs +5 -6
  39. package/cli/evidence-arguments.mjs +46 -0
  40. package/cli/evidence-command-definitions.mjs +69 -0
  41. package/cli/evidence-command-runner.mjs +2 -3
  42. package/cli/evidence-signing.mjs +3 -2
  43. package/cli/external-observation-mapping.mjs +2 -2
  44. package/cli/formatters/json.mjs +1 -13
  45. package/cli/formatters/terminal-command-formats.mjs +6 -0
  46. package/cli/formatters/terminal-lookup.mjs +6 -0
  47. package/cli/history-arguments.mjs +39 -0
  48. package/cli/history-command-definitions.mjs +88 -0
  49. package/cli/indicator-set-command.mjs +32 -0
  50. package/cli/inline-command-families.mjs +6 -47
  51. package/cli/inline-command-runner.mjs +4 -31
  52. package/cli/input.mjs +2 -2
  53. package/cli/intake-command.mjs +147 -0
  54. package/cli/interchange-report.mjs +4 -2
  55. package/cli/investigation-artifacts.mjs +3 -4
  56. package/cli/investigation-package-review.mjs +2 -0
  57. package/cli/investigation-plan.mjs +32 -154
  58. package/cli/investigation-recipes.mjs +177 -0
  59. package/cli/investigation-run.mjs +78 -12
  60. package/cli/local-mmdb-review.mjs +220 -17
  61. package/cli/lookalike-calibration.mjs +1 -1
  62. package/cli/lookup-brief.mjs +2 -2
  63. package/cli/lookup-command-runner.mjs +19 -35
  64. package/cli/lookup-diff.mjs +10 -9
  65. package/cli/lookup-plan.mjs +10 -14
  66. package/cli/lookup-reconcile.mjs +6 -5
  67. package/cli/lookup-timeline.mjs +7 -6
  68. package/cli/mail-header-review.mjs +24 -247
  69. package/cli/mail-review.mjs +9 -7
  70. package/cli/network-arguments.mjs +51 -0
  71. package/cli/network-command-definitions.mjs +130 -0
  72. package/cli/network-command-runner.mjs +43 -31
  73. package/cli/offline-evidence-review.mjs +85 -6
  74. package/cli/posture.mjs +1 -1
  75. package/cli/registry-fixture-scaffold.mjs +1 -1
  76. package/cli/retained-artifact-diff.mjs +8 -7
  77. package/cli/review-arguments.mjs +132 -0
  78. package/cli/review-command-definitions.mjs +279 -0
  79. package/cli/review-command-runner.mjs +72 -159
  80. package/cli/risk-calibration.mjs +14 -12
  81. package/cli/runner.mjs +20 -32
  82. package/cli/saved-lookup.mjs +2 -2
  83. package/cli/selected-input-worker.mjs +73 -0
  84. package/cli/sharing-review.mjs +5 -64
  85. package/cli/source-reliability.mjs +2 -2
  86. package/cli/support-arguments.mjs +49 -0
  87. package/cli/support-command-definitions.mjs +209 -0
  88. package/cli/support-command-runner.mjs +4 -4
  89. package/cli/watchlist-review.mjs +43 -0
  90. package/cli/workflow-arguments.mjs +100 -0
  91. package/cli/workflow-command-definitions.mjs +103 -0
  92. package/cli/workflow-command-runner.mjs +40 -1
  93. package/docs/cli-reference.md +27 -0
  94. package/docs/cli.md +322 -4
  95. package/docs/contextual-reviews.md +178 -0
  96. package/docs/infrastructure-observations.md +126 -0
  97. package/docs/offline-intake-context.md +213 -0
  98. package/lib/analyst-taxonomy.mjs +2 -40
  99. package/lib/application-version.mjs +1 -1
  100. package/lib/availability.mjs +13 -789
  101. package/lib/bounded-contract-normalizers.mjs +4 -0
  102. package/lib/bounded-json.mjs +2 -330
  103. package/lib/browser-library-profile.mjs +62 -84
  104. package/lib/candidate-provenance-bounds.mjs +2 -0
  105. package/lib/classify.mjs +1 -1
  106. package/lib/credential-form-attribution.mjs +136 -0
  107. package/lib/credential-surface-profile.mjs +6 -2
  108. package/lib/ct-query.mjs +2 -27
  109. package/lib/ct-response-bounds.mjs +2 -9
  110. package/lib/ct-search.mjs +83 -47
  111. package/lib/dmarc-discovery.mjs +62 -0
  112. package/lib/dns-convergence-review.mjs +21 -8
  113. package/lib/dns-delegation-health.mjs +35 -15
  114. package/lib/dns-inheritance-review.mjs +53 -63
  115. package/lib/dns-intelligence.mjs +27 -11
  116. package/lib/dns-query-coverage.mjs +40 -0
  117. package/lib/domain-assurance.mjs +1 -1
  118. package/lib/domain-change-packet.mjs +12 -13
  119. package/lib/domain-change-review.mjs +32 -14
  120. package/lib/domain-control-flight-recorder.mjs +103 -27
  121. package/lib/domain-control-manifest.mjs +25 -17
  122. package/lib/domain-posture-analysis.mjs +75 -7
  123. package/lib/domain-posture-parsers.mjs +9 -0
  124. package/lib/domain-posture.mjs +45 -25
  125. package/lib/domain-registration.mjs +392 -0
  126. package/lib/evidence-export-privacy.mjs +27 -17
  127. package/lib/evidence-export.mjs +3 -3
  128. package/lib/evidence-report.mjs +5 -1
  129. package/lib/external-intelligence-risk.mjs +2 -124
  130. package/lib/favicon.mjs +13 -4
  131. package/lib/generated/cisa-kev-catalog.mjs +41 -3
  132. package/lib/generated/registrar-standing-catalogue.mjs +18 -10
  133. package/lib/generated/retire-browser-catalog.mjs +332 -19
  134. package/lib/generated/{unicode-confusables-17.mjs → unicode-confusables.mjs} +33 -33
  135. package/lib/html-signals.mjs +13 -2
  136. package/lib/http-evidence-bounds.mjs +2 -8
  137. package/lib/http-intelligence.mjs +13 -1
  138. package/lib/idn-confusable-policy.mjs +6 -4
  139. package/lib/idn-confusables.mjs +1 -1
  140. package/lib/interchange-fidelity-registry.mjs +10 -0
  141. package/lib/lookup-child-profile-contract.mjs +28 -11
  142. package/lib/lookup-contract-primitives.mjs +1 -1
  143. package/lib/lookup-diagnostics.mjs +1 -23
  144. package/lib/lookup-network-evidence-bounds.mjs +1 -10
  145. package/lib/lookup-progress.mjs +50 -1
  146. package/lib/lookup-response-contract.mjs +70 -40
  147. package/lib/lookup-response.mjs +5 -15
  148. package/lib/lookup-source-progress.mjs +8 -22
  149. package/lib/lookup.mjs +1 -1
  150. package/lib/operation-budget.mjs +4 -0
  151. package/lib/opportunity-scoring.mjs +2 -107
  152. package/lib/outbound-identity.mjs +1 -1
  153. package/lib/page-fingerprints.mjs +18 -1
  154. package/lib/perceptual-hash-comparison.mjs +2 -34
  155. package/lib/perceptual-hash.mjs +36 -10
  156. package/lib/portable-generator.mjs +2 -29
  157. package/lib/project-metadata.mjs +2 -8
  158. package/lib/rdap-client.mjs +11 -7
  159. package/lib/registrable-domain.mjs +2 -56
  160. package/lib/registrar-standing-contract.mjs +1 -1
  161. package/lib/registrar-standing.mjs +9 -13
  162. package/lib/registry-capabilities.mjs +2 -2
  163. package/lib/registry-capability-catalogue.mjs +27 -19
  164. package/lib/risk-calibration-summary.mjs +2 -352
  165. package/lib/risk-scoring.mjs +2 -345
  166. package/lib/scoring-evidence-quality.mjs +2 -107
  167. package/lib/semantic-version.mjs +2 -54
  168. package/lib/service-binding-dns.mjs +46 -2
  169. package/lib/smtp-transport-review.mjs +48 -19
  170. package/lib/sslbl-certificates.generated.mjs +142 -139
  171. package/lib/static-html-analysis.mjs +32 -4
  172. package/lib/structured-data-identity.mjs +1 -1
  173. package/lib/technology-evidence-role.mjs +1 -51
  174. package/lib/threat-intelligence-manifests.mjs +1 -1
  175. package/lib/threat-intelligence-runtime.mjs +1 -1
  176. package/lib/threat-intelligence-targets.mjs +1 -1
  177. package/lib/threat-intelligence-types.mjs +2 -75
  178. package/lib/tls-intelligence.mjs +30 -5
  179. package/lib/typosquat-generator.mjs +11 -2
  180. package/lib/website-enrichment.mjs +404 -0
  181. package/lib/whois-authority.mjs +43 -5
  182. package/lib/whois-chain.mjs +12 -3
  183. package/lib/whois-common-formats.mjs +3 -1
  184. package/lib/whois-contacts.mjs +13 -2
  185. package/lib/whois-normalization.mjs +3 -1
  186. package/lib/whois-parser.mjs +5 -3
  187. package/package.json +15 -14
  188. package/packages/analysis/analyst-taxonomy.mjs +40 -0
  189. package/packages/analysis/bounded-json.mjs +330 -0
  190. package/packages/analysis/candidate-provenance-bounds.mjs +4 -0
  191. package/packages/analysis/cli-argument-grammar.mjs +216 -0
  192. package/packages/analysis/ct-query.mjs +27 -0
  193. package/packages/analysis/ct-response-bounds.mjs +9 -0
  194. package/packages/analysis/external-intelligence-risk.mjs +124 -0
  195. package/packages/analysis/http-evidence-bounds.mjs +8 -0
  196. package/packages/analysis/lookup-network-evidence-bounds.mjs +10 -0
  197. package/packages/analysis/opportunity-scoring.mjs +107 -0
  198. package/packages/analysis/perceptual-hash-comparison.mjs +34 -0
  199. package/packages/analysis/portable-generator.mjs +29 -0
  200. package/packages/analysis/project-metadata.mjs +8 -0
  201. package/packages/analysis/registrable-domain.mjs +56 -0
  202. package/packages/analysis/risk-calibration-summary.mjs +352 -0
  203. package/packages/analysis/risk-scoring.mjs +348 -0
  204. package/packages/analysis/scoring-evidence-quality.mjs +107 -0
  205. package/packages/analysis/semantic-version.mjs +54 -0
  206. package/packages/analysis/threat-intelligence-types.mjs +75 -0
  207. package/packages/cases/case-containment-handoff.mjs +195 -0
  208. package/packages/cases/case-evidence-links.mjs +151 -0
  209. package/packages/cases/case-evidence-model.mjs +89 -12
  210. package/packages/cases/case-evidence-quality.mjs +5 -0
  211. package/packages/cases/case-export-input.mjs +7 -5
  212. package/packages/cases/case-incident-context.mjs +52 -0
  213. package/packages/cases/case-markdown.mjs +11 -0
  214. package/packages/cases/case-migration-model.mjs +40 -24
  215. package/packages/cases/case-object-coverage.mjs +33 -0
  216. package/packages/cases/case-packet-correction.mjs +106 -0
  217. package/packages/cases/case-recheck-model.mjs +124 -36
  218. package/packages/cases/case-record-contracts.mjs +1 -1
  219. package/packages/cases/case-record-core.mjs +1 -1
  220. package/packages/cases/case-record-operations.mjs +111 -118
  221. package/packages/cases/case-record-projection.mjs +23 -4
  222. package/packages/cases/case-record-version-input.mjs +41 -0
  223. package/packages/cases/case-report.mjs +93 -27
  224. package/packages/cases/case-requested-evidence.mjs +146 -0
  225. package/packages/cases/case-response-actions.mjs +159 -1
  226. package/packages/cases/case-response-model.mjs +16 -1
  227. package/packages/cases/case-response-object.mjs +119 -0
  228. package/packages/cases/case-response-outcomes.mjs +108 -20
  229. package/packages/cases/case-response-packet-render.mjs +35 -12
  230. package/packages/cases/case-response-packet.mjs +55 -13
  231. package/packages/cases/case-response-records.mjs +0 -1
  232. package/packages/cases/case-response-review-inputs.mjs +60 -8
  233. package/packages/cases/case-storage-model.mjs +1 -1
  234. package/packages/cases/case-workflow-metadata.mjs +217 -0
  235. package/packages/cases/case-workflow-migration.mjs +43 -0
  236. package/packages/cases/http-summary.mjs +6 -1
  237. package/packages/comparison/favicon-similarity.mjs +1 -1
  238. package/packages/comparison/page-similarity.mjs +1 -1
  239. package/packages/comparison/relationship-evidence.mjs +21 -9
  240. package/packages/comparison/relationship-provenance.mjs +7 -1
  241. package/packages/contracts/analyst-interchange.mjs +35 -15
  242. package/packages/contracts/candidate-watch-review.mjs +54 -0
  243. package/packages/contracts/case-portability.mjs +360 -49
  244. package/packages/contracts/cli-command-semantics.mjs +5 -0
  245. package/packages/contracts/cli-grammar.mjs +6 -0
  246. package/packages/contracts/cli-lookup-brief.mjs +2 -0
  247. package/packages/contracts/context-review.mjs +25 -0
  248. package/packages/contracts/document-intake.mjs +11 -0
  249. package/packages/contracts/domain-control-flight-recorder.mjs +30 -14
  250. package/packages/contracts/domain-control-manifest.mjs +1 -1
  251. package/packages/contracts/domain-control-monitor.mjs +13 -2
  252. package/packages/contracts/domain-control-review.mjs +36 -15
  253. package/packages/contracts/external-observation-interchange.mjs +96 -28
  254. package/packages/contracts/har-review.mjs +2 -0
  255. package/packages/contracts/identity-events.mjs +4 -0
  256. package/packages/contracts/investigation-run.mjs +2 -0
  257. package/packages/contracts/ip-address.mjs +10 -0
  258. package/packages/contracts/lookup-evidence.mjs +3 -2
  259. package/packages/contracts/mail-authentication.mjs +5 -0
  260. package/packages/contracts/message-intake.mjs +37 -0
  261. package/packages/contracts/offline-comparison.mjs +4 -1
  262. package/packages/contracts/passive-mail.mjs +10 -0
  263. package/packages/contracts/risk-calibration.mjs +44 -29
  264. package/packages/contracts/schema-lifecycle.mjs +3 -1
  265. package/packages/contracts/web-capture.mjs +2 -2
  266. package/packages/contracts/workspace-portability.mjs +144 -28
  267. package/packages/evidence/collection-quality.mjs +67 -0
  268. package/packages/evidence/credential-form-attribution.mjs +60 -0
  269. package/packages/evidence/dns-query-outcome.mjs +72 -0
  270. package/packages/evidence/email-recipient.mjs +18 -0
  271. package/packages/evidence/image-regions.mjs +15 -0
  272. package/packages/evidence/lookup-target.mjs +5 -1
  273. package/packages/evidence/observation.mjs +2 -0
  274. package/packages/evidence/png-pixels.mjs +117 -0
  275. package/packages/evidence/public-address-policy.mjs +83 -0
  276. package/packages/evidence/record-identity.mjs +60 -16
  277. package/packages/evidence/technology-evidence-role.mjs +51 -0
  278. package/packages/interchange/defensive-indicator-export.mjs +264 -0
  279. package/packages/interchange/external-findings-import.mjs +36 -29
  280. package/packages/interchange/json-output.mjs +14 -0
  281. package/packages/interchange/managed-indicator-export.mjs +36 -0
  282. package/packages/interchange/managed-indicator-set.mjs +223 -0
  283. package/packages/interchange/misp-indicator-export.mjs +93 -0
  284. package/packages/interchange/sharing-policy.mjs +56 -0
  285. package/packages/interchange/stix-indicator-export.mjs +129 -0
  286. package/packages/interchange/web-capture-import.mjs +22 -15
  287. package/packages/investigation/acquisition-decision-packet.mjs +79 -1
  288. package/packages/investigation/bulk-mail-exposure.mjs +7 -13
  289. package/packages/investigation/capture-coverage.mjs +57 -0
  290. package/packages/investigation/connector-provenance-review.mjs +100 -0
  291. package/packages/investigation/context-review.mjs +35 -0
  292. package/packages/investigation/document-intake.mjs +87 -0
  293. package/packages/investigation/docx-intake.mjs +133 -0
  294. package/packages/investigation/domain-history-review.mjs +132 -0
  295. package/packages/investigation/domain-transition-review.mjs +49 -0
  296. package/packages/investigation/evidence-coverage-ledger.mjs +9 -1
  297. package/packages/investigation/har-intake.mjs +68 -0
  298. package/packages/investigation/identity-event-intake.mjs +131 -0
  299. package/packages/investigation/identity-incident-review.mjs +39 -0
  300. package/packages/investigation/incident-sequence-review.mjs +58 -0
  301. package/packages/investigation/infrastructure-observation.mjs +234 -0
  302. package/packages/investigation/intake-context.mjs +284 -0
  303. package/packages/investigation/intake-indicators.mjs +116 -0
  304. package/packages/investigation/intake-phones.mjs +155 -0
  305. package/packages/investigation/intake-report.mjs +20 -0
  306. package/packages/investigation/investigation-manifest.mjs +35 -9
  307. package/packages/investigation/link-intake.mjs +209 -0
  308. package/packages/investigation/lookup-asset-graph.mjs +1 -1
  309. package/packages/investigation/mail-authentication-review.mjs +222 -0
  310. package/packages/investigation/mail-header-identity.mjs +209 -0
  311. package/packages/investigation/message-intake.mjs +235 -0
  312. package/packages/investigation/page-behaviour.mjs +113 -0
  313. package/packages/investigation/pdf-intake.mjs +155 -0
  314. package/packages/investigation/platform-continuity-review.mjs +60 -0
  315. package/packages/investigation/qr-intake.mjs +56 -0
  316. package/packages/investigation/requested-action-hints.mjs +11 -0
  317. package/packages/investigation/selected-input-review.mjs +17 -0
  318. package/packages/investigation/storefront-review.mjs +70 -0
  319. package/packages/monitoring/domain-feed.mjs +321 -0
  320. package/packages/workspace/brand-candidate-workflow.mjs +351 -0
  321. package/packages/workspace/brand-keyword-campaign.mjs +231 -0
  322. package/packages/workspace/brand-profile-model.mjs +48 -4
  323. package/packages/workspace/bulk-review-model.mjs +28 -26
  324. package/packages/workspace/bulk-session-model.mjs +27 -13
  325. package/packages/workspace/campaign-model.mjs +1 -1
  326. package/packages/workspace/candidate-watch-handoff.mjs +195 -0
  327. package/packages/workspace/detection-rule-model.mjs +11 -3
  328. package/packages/workspace/hostile-input.mjs +1 -1
  329. package/packages/workspace/investigation-guide.mjs +2 -2
  330. package/packages/workspace/investigation-template-model.mjs +26 -2
  331. package/packages/workspace/page-baseline.mjs +2 -2
  332. package/packages/workspace/relationship-observation-model.mjs +17 -13
  333. package/packages/workspace/shortlist-model.mjs +24 -7
  334. package/packages/workspace/watchlist-history.mjs +59 -16
  335. package/packages/workspace/watchlist-store.mjs +152 -11
  336. package/packages/workspace/website-snapshot-model.mjs +28 -3
  337. package/packages/workspace/workspace-archive-crypto.mjs +1 -1
  338. package/packages/workspace/workspace-archive.mjs +5 -4
  339. package/third-party-notices.txt +549 -16
  340. package/lib/hostname.mjs +0 -2
@@ -7,11 +7,13 @@ export const BRAND_PROFILE_SCHEMA = 'whoisleuth.brand-profiles';
7
7
  export const PUBLIC_BRAND_PROFILE_SCHEMA_VERSION = 6;
8
8
  // Historical readers retain a fixed published identity when the writer moves.
9
9
  export const PUBLISHED_V2_2_BRAND_PROFILE_SCHEMA_VERSION = 7;
10
- export const BRAND_PROFILE_SCHEMA_VERSION = 9;
10
+ export const BRAND_PROFILE_SCHEMA_VERSION = 11;
11
11
  export const BRAND_PROFILE_BROWSER_SUPPORTED_VERSIONS = Object.freeze([
12
12
  PUBLIC_BRAND_PROFILE_SCHEMA_VERSION,
13
13
  PUBLISHED_V2_2_BRAND_PROFILE_SCHEMA_VERSION,
14
14
  8,
15
+ 9,
16
+ 10,
15
17
  BRAND_PROFILE_SCHEMA_VERSION,
16
18
  ]);
17
19
  export const SUPPORTED_BRAND_PROFILE_SCHEMA_VERSIONS = BRAND_PROFILE_BROWSER_SUPPORTED_VERSIONS;
@@ -21,6 +23,17 @@ export const MAX_PROFILE_VALUE_INPUTS = MAX_PROFILE_VALUES * 4;
21
23
  export const MAX_PROFILE_STORE_BYTES = 4 * 1024 * 1024;
22
24
  export const MAX_PROFILE_IMPORT_BYTES = MAX_PROFILE_STORE_BYTES * 8;
23
25
  export const MAX_PROFILE_NAME_LENGTH = 100;
26
+ export const MAX_CANDIDATE_OBSERVATIONS = 200;
27
+ export const MAX_CANDIDATE_MATCHES = 20;
28
+ export const MAX_CANDIDATE_SOURCES = 12;
29
+ export const MAX_CANDIDATE_TEXT = 300;
30
+ export const MAX_CANDIDATE_EXCEPTIONS = 200;
31
+ export const MAX_CANDIDATE_EXCEPTION_HISTORY = 8;
32
+ export const MAX_BRAND_KEYWORD_CAMPAIGNS = 20;
33
+ export const MAX_BRAND_KEYWORD_CAMPAIGN_HISTORY = 8;
34
+ export const MAX_BRAND_KEYWORD_TERMS = 20;
35
+ export const MAX_BRAND_KEYWORD_TERM_LENGTH = 80;
36
+ export const MAX_WATCH_CONTEXTS = 20;
24
37
  export const MAX_PROFILE_TEXT_LENGTH = 200;
25
38
  export const MAX_PROFILE_DOMAIN_LENGTH = 253;
26
39
  export const MAX_PROFILE_URL_LENGTH = 2_048;
@@ -47,14 +60,21 @@ export const MAX_CAMPAIGN_IMPORT_BYTES = 2 * 1024 * 1024;
47
60
  export const MAX_CAMPAIGN_INPUT_RECORDS = 500;
48
61
  export const MAX_CAMPAIGN_STORE_BYTES = 512 * 1024;
49
62
  export const WATCHLIST_SCHEMA = 'whoisleuth.watchlists';
50
- export const WATCHLIST_SCHEMA_VERSION = 2;
51
- export const WATCHLIST_BROWSER_SUPPORTED_VERSIONS = Object.freeze([WATCHLIST_SCHEMA_VERSION]);
52
- export const WATCHLIST_EXPORT_SUPPORTED_VERSIONS = Object.freeze([WATCHLIST_SCHEMA_VERSION]);
63
+ export const WATCHLIST_SCHEMA_VERSION = 6;
64
+ export const WATCHLIST_BROWSER_SUPPORTED_VERSIONS = Object.freeze([2, 3, 4, 5, WATCHLIST_SCHEMA_VERSION]);
65
+ export const WATCHLIST_EXPORT_SUPPORTED_VERSIONS = WATCHLIST_BROWSER_SUPPORTED_VERSIONS;
53
66
  export const MAX_WATCHLISTS = 100;
54
67
  export const MAX_WATCHLIST_INPUTS = MAX_WATCHLISTS * 4;
55
68
  export const MAX_WATCHLIST_NAME_LENGTH = 100;
56
69
  export const MAX_WATCHLIST_STORE_BYTES = 2 * 1024 * 1024;
57
70
  export const MAX_WATCHLIST_IMPORT_BYTES = 2 * 1024 * 1024;
71
+ // Recovery metadata is not new analyst data. Reserve only its exact JSON cost;
72
+ // the store writer still enforces the ordinary budget after subtracting it.
73
+ export const WATCHLIST_RECOVERY_METADATA = Object.freeze({ membershipRecovery: 'legacy_overflow' });
74
+ export const WATCHLIST_RECOVERY_METADATA_BYTES = JSON.stringify(WATCHLIST_RECOVERY_METADATA).length - 1;
75
+ export const MAX_WATCHLIST_READ_BYTES = MAX_WATCHLIST_STORE_BYTES + MAX_WATCHLISTS * WATCHLIST_RECOVERY_METADATA_BYTES;
76
+ // Date.toISOString() produces at most 27 ASCII characters, including extended years.
77
+ export const MAX_WATCHLIST_PORTABLE_BYTES = MAX_WATCHLIST_READ_BYTES + JSON.stringify({ exportedAt: '+999999-12-31T23:59:59.999Z' }).length - 1;
58
78
  export const MAX_WATCHLIST_HISTORY_EVENTS = 12;
59
79
  export const MAX_WATCHLIST_CHANGES_PER_EVENT = 500;
60
80
  export const MAX_WATCHLIST_DOMAINS = 2_000;
@@ -82,9 +102,9 @@ export const MAX_CT_HISTORY_EVER_SEEN_DOMAINS = 1_000;
82
102
  export const MAX_CT_HISTORY_QUERY_LENGTH = 200;
83
103
  export const MAX_CT_HISTORY_STORE_BYTES = 1024 * 1024;
84
104
  export const DETECTION_RULE_SCHEMA = 'whoisleuth.detection-rules';
85
- export const DETECTION_RULE_SCHEMA_VERSION = 1;
86
- export const DETECTION_RULE_BROWSER_SUPPORTED_VERSIONS = Object.freeze([DETECTION_RULE_SCHEMA_VERSION]);
87
- export const DETECTION_RULE_EXPORT_SUPPORTED_VERSIONS = Object.freeze([DETECTION_RULE_SCHEMA_VERSION]);
105
+ export const DETECTION_RULE_SCHEMA_VERSION = 2;
106
+ export const DETECTION_RULE_BROWSER_SUPPORTED_VERSIONS = Object.freeze([1, DETECTION_RULE_SCHEMA_VERSION]);
107
+ export const DETECTION_RULE_EXPORT_SUPPORTED_VERSIONS = DETECTION_RULE_BROWSER_SUPPORTED_VERSIONS;
88
108
  export const MAX_DETECTION_RULES = 50;
89
109
  export const MAX_RULE_CONDITIONS = 8;
90
110
  export const MAX_RULE_INPUT_RECORDS = 250;
@@ -114,8 +134,8 @@ export const MAX_WEBSITE_SNAPSHOTS_PER_DOMAIN = 12;
114
134
  export const MAX_WEBSITE_SNAPSHOT_STORE_BYTES = 512 * 1024;
115
135
  export const MAX_WEBSITE_SNAPSHOT_IMPORT_BYTES = 768 * 1024;
116
136
  export const BULK_SESSION_SCHEMA = 'whoisleuth.bulk-sessions';
117
- export const BULK_SESSION_SCHEMA_VERSION = 5;
118
- export const BULK_SESSION_BROWSER_SUPPORTED_VERSIONS = Object.freeze([4, BULK_SESSION_SCHEMA_VERSION]);
137
+ export const BULK_SESSION_SCHEMA_VERSION = 7;
138
+ export const BULK_SESSION_BROWSER_SUPPORTED_VERSIONS = Object.freeze([4, 5, 6, BULK_SESSION_SCHEMA_VERSION]);
119
139
  export const SUPPORTED_BULK_SESSION_SCHEMA_VERSIONS = BULK_SESSION_BROWSER_SUPPORTED_VERSIONS;
120
140
  export const MAX_BULK_SESSIONS = 10;
121
141
  export const MAX_BULK_SESSION_ROWS = 2_000;
@@ -126,8 +146,8 @@ export const MAX_BULK_SESSION_ARRAY_VALUES = 100;
126
146
  export const MAX_BULK_SESSION_SOURCES = 12;
127
147
  export const MAX_BULK_PROFILE_CONTEXT_LIMITATION_LENGTH = 300;
128
148
  export const INVESTIGATION_TEMPLATE_SCHEMA = 'whoisleuth.investigation-templates';
129
- export const INVESTIGATION_TEMPLATE_VERSION = 2;
130
- export const INVESTIGATION_TEMPLATE_SUPPORTED_VERSIONS = Object.freeze([INVESTIGATION_TEMPLATE_VERSION]);
149
+ export const INVESTIGATION_TEMPLATE_VERSION = 3;
150
+ export const INVESTIGATION_TEMPLATE_SUPPORTED_VERSIONS = Object.freeze([2, INVESTIGATION_TEMPLATE_VERSION]);
131
151
  export const MAX_INVESTIGATION_TEMPLATES = 20;
132
152
  export const MAX_INVESTIGATION_TEMPLATE_STORE_BYTES = 256 * 1024;
133
153
  export const MAX_INVESTIGATION_TEMPLATE_IMPORT_BYTES = 384 * 1024;
@@ -180,12 +200,14 @@ export const WORKSPACE_PORTABILITY_IDENTITY_CONSTANTS = Object.freeze([
180
200
  export const WORKSPACE_PORTABILITY_BOUND_CONSTANTS = Object.freeze([
181
201
  'MAX_PROFILES', 'MAX_PROFILE_VALUES', 'MAX_PROFILE_VALUE_INPUTS', 'MAX_PROFILE_STORE_BYTES', 'MAX_PROFILE_IMPORT_BYTES',
182
202
  'MAX_PROFILE_NAME_LENGTH', 'MAX_PROFILE_TEXT_LENGTH', 'MAX_PROFILE_DOMAIN_LENGTH', 'MAX_PROFILE_URL_LENGTH', 'MAX_PROFILE_TLD_LENGTH',
203
+ 'MAX_BRAND_KEYWORD_CAMPAIGNS', 'MAX_BRAND_KEYWORD_CAMPAIGN_HISTORY', 'MAX_BRAND_KEYWORD_TERMS', 'MAX_BRAND_KEYWORD_TERM_LENGTH',
183
204
  'MAX_OFFICIAL_CHANNELS', 'MAX_RIGHTS_REFERENCES', 'MAX_DKIM_SELECTOR_LENGTH',
184
205
  'MAX_DKIM_SELECTORS', 'MAX_PROTECTION_ATTESTATIONS', 'MAX_DESIRED_POSTURE_BASELINES', 'MAX_DESIRED_POSTURE_RECORDS',
185
206
  'MAX_DESIRED_POSTURE_SUPPRESSIONS', 'MAX_DESIRED_POSTURE_OBSERVATIONS', 'MAX_DESIRED_POSTURE_CHANGE_WINDOWS',
186
207
  'MAX_CAMPAIGNS', 'MAX_CAMPAIGN_DOMAINS', 'MAX_CAMPAIGN_NAME_LENGTH', 'MAX_CAMPAIGN_DESCRIPTION_LENGTH',
187
208
  'MAX_CAMPAIGN_IMPORT_BYTES', 'MAX_CAMPAIGN_INPUT_RECORDS', 'MAX_CAMPAIGN_STORE_BYTES',
188
209
  'MAX_WATCHLISTS', 'MAX_WATCHLIST_INPUTS', 'MAX_WATCHLIST_NAME_LENGTH', 'MAX_WATCHLIST_STORE_BYTES', 'MAX_WATCHLIST_IMPORT_BYTES',
210
+ 'MAX_WATCHLIST_READ_BYTES', 'MAX_WATCHLIST_PORTABLE_BYTES',
189
211
  'MAX_WATCHLIST_HISTORY_EVENTS', 'MAX_WATCHLIST_CHANGES_PER_EVENT', 'MAX_WATCHLIST_DOMAINS', 'MAX_WATCHLIST_INPUT_RECORDS',
190
212
  'MAX_WATCHLIST_NAMESERVERS', 'MAX_WATCHLIST_MUTATION_TYPES', 'MAX_WATCHLIST_HISTORY_DOMAIN_OPTIONS',
191
213
  'MAX_SHORTLIST_ENTRIES', 'MAX_SHORTLIST_INPUTS', 'MAX_SHORTLIST_STORE_BYTES', 'MAX_SHORTLIST_IMPORT_BYTES', 'MAX_SHORTLIST_FACTORS',
@@ -229,7 +251,7 @@ export const BRAND_PROFILE_BROWSER_COMPATIBILITY = defineSchemaCompatibility({
229
251
  supportedVersions: BRAND_PROFILE_BROWSER_SUPPORTED_VERSIONS, acceptsUnversionedLegacy: false,
230
252
  futureVersionBehavior: 'preserve_without_write', migration: 'normalize_to_current', writeSemantics: 'normalized_rewrite',
231
253
  byteBudget: MAX_PROFILE_STORE_BYTES, owner: WORKSPACE_CONTRACT_OWNER,
232
- note: 'Published versions 6, 7 and 8 migrate directly to the current profile envelope. Version 9 preserves earlier page baselines while admitting independently versioned native-tree fingerprints; incompatible fingerprint algorithms are not compared.',
254
+ note: 'Versions 6–10 remain readable without invented keyword campaigns. Version 11 adds bounded, revisioned, time-limited Brand keyword intent without collection authority; candidate provenance and independent page-fingerprint algorithms remain intact.',
233
255
  });
234
256
  export const CAMPAIGN_BROWSER_COMPATIBILITY = defineSchemaCompatibility({
235
257
  id: 'browser.campaigns', kind: 'browser_store', schema: null, currentVersion: CAMPAIGN_SCHEMA_VERSION,
@@ -241,9 +263,9 @@ export const CAMPAIGN_BROWSER_COMPATIBILITY = defineSchemaCompatibility({
241
263
  export const WATCHLIST_BROWSER_COMPATIBILITY = defineSchemaCompatibility({
242
264
  id: 'browser.watchlists', kind: 'browser_store', schema: null, currentVersion: WATCHLIST_SCHEMA_VERSION,
243
265
  supportedVersions: WATCHLIST_BROWSER_SUPPORTED_VERSIONS, acceptsUnversionedLegacy: false,
244
- futureVersionBehavior: 'preserve_without_write', migration: 'exact_current_only', writeSemantics: 'normalized_rewrite',
245
- byteBudget: MAX_WATCHLIST_STORE_BYTES, owner: WORKSPACE_CONTRACT_OWNER,
246
- note: 'The unchanged public version 2 envelope is the browser baseline; unversioned maps are outside the compatibility boundary.',
266
+ futureVersionBehavior: 'preserve_without_write', migration: 'normalize_to_current', writeSemantics: 'normalized_rewrite',
267
+ byteBudget: MAX_WATCHLIST_READ_BYTES, owner: WORKSPACE_CONTRACT_OWNER,
268
+ note: 'Public version 2 remains readable without inferred collection outcomes. Version 3 retains explicit page and favicon collection quality; unversioned maps are outside the compatibility boundary.',
247
269
  });
248
270
  export const SHORTLIST_BROWSER_COMPATIBILITY = defineSchemaCompatibility({
249
271
  id: 'browser.shortlist', kind: 'browser_store', schema: null, currentVersion: SHORTLIST_SCHEMA_VERSION,
@@ -262,7 +284,7 @@ export const CT_HISTORY_BROWSER_COMPATIBILITY = defineSchemaCompatibility({
262
284
  export const DETECTION_RULE_BROWSER_COMPATIBILITY = defineSchemaCompatibility({
263
285
  id: 'browser.detection-rules', kind: 'browser_store', schema: null, currentVersion: DETECTION_RULE_SCHEMA_VERSION,
264
286
  supportedVersions: DETECTION_RULE_BROWSER_SUPPORTED_VERSIONS, acceptsUnversionedLegacy: false,
265
- futureVersionBehavior: 'preserve_without_write', migration: 'exact_current_only', writeSemantics: 'normalized_rewrite',
287
+ futureVersionBehavior: 'preserve_without_write', migration: 'normalize_to_current', writeSemantics: 'normalized_rewrite',
266
288
  byteBudget: MAX_RULE_STORE_BYTES, owner: WORKSPACE_CONTRACT_OWNER,
267
289
  note: 'Only allowlisted structured rule fields and operators survive normalisation; the schema string belongs to portable exports.',
268
290
  });
@@ -285,14 +307,14 @@ export const BULK_SESSION_BROWSER_COMPATIBILITY = defineSchemaCompatibility({
285
307
  supportedVersions: BULK_SESSION_BROWSER_SUPPORTED_VERSIONS, acceptsUnversionedLegacy: false,
286
308
  futureVersionBehavior: 'preserve_without_write', migration: 'normalize_to_current', writeSemantics: 'normalized_rewrite',
287
309
  byteBudget: MAX_BULK_SESSION_STORE_BYTES, owner: WORKSPACE_CONTRACT_OWNER,
288
- note: 'Public schema 4 sessions migrate with unknown row and per-source observation times and contributing-source provenance. Schema 5 retains those bounded fields without retaining raw lookup payloads; future versions are preserved without rewrite.',
310
+ note: 'Public schemas 4 and 5 remain readable with their original observation provenance. Schema 6 retains explicit page and favicon collection quality for saved evidence handoffs; future versions are preserved without rewrite.',
289
311
  });
290
312
  export const INVESTIGATION_TEMPLATE_BROWSER_COMPATIBILITY = defineSchemaCompatibility({
291
313
  id: 'browser.investigation-templates', kind: 'browser_store', schema: null,
292
314
  currentVersion: INVESTIGATION_TEMPLATE_VERSION, supportedVersions: INVESTIGATION_TEMPLATE_SUPPORTED_VERSIONS,
293
- acceptsUnversionedLegacy: false, futureVersionBehavior: 'preserve_without_write', migration: 'exact_current_only',
315
+ acceptsUnversionedLegacy: false, futureVersionBehavior: 'preserve_without_write', migration: 'normalize_to_current',
294
316
  writeSemantics: 'normalized_rewrite', byteBudget: MAX_INVESTIGATION_TEMPLATE_STORE_BYTES, owner: WORKSPACE_CONTRACT_OWNER,
295
- note: 'Version 2 adds the fixed response-playbook recipe identifiers. Analyst-authored guidance remains bound to allowlisted built-in stages and cannot run code, start collection, submit evidence, or remove mandatory request gates.',
317
+ note: 'Public version 2 remains readable. Version 3 retains optional lesson-revision provenance without Case identities or note text. Guidance remains bound to built-in stages and cannot execute actions or remove mandatory request gates.',
296
318
  });
297
319
  export const BULK_REVIEW_BROWSER_COMPATIBILITY = defineSchemaCompatibility({
298
320
  id: 'browser.bulk-review', kind: 'browser_store', schema: null, currentVersion: BULK_REVIEW_SCHEMA_VERSION,
@@ -306,7 +328,7 @@ export const BRAND_PROFILE_EXPORT_COMPATIBILITY = defineSchemaCompatibility({
306
328
  supportedVersions: SUPPORTED_BRAND_PROFILE_SCHEMA_VERSIONS, acceptsUnversionedLegacy: false,
307
329
  futureVersionBehavior: 'reject', migration: 'normalize_to_current', writeSemantics: 'non_destructive_merge',
308
330
  byteBudget: MAX_PROFILE_IMPORT_BYTES, owner: WORKSPACE_CONTRACT_OWNER,
309
- note: 'Supported exports merge non-destructively by bounded normalised profile identity.',
331
+ note: 'Supported exports merge non-destructively by bounded profile identity. Candidate decisions cannot be reassigned to a different Brand identifier with the same name.',
310
332
  });
311
333
  export const CAMPAIGN_EXPORT_COMPATIBILITY = defineSchemaCompatibility({
312
334
  id: 'export.campaigns', kind: 'export', schema: CAMPAIGN_SCHEMA, currentVersion: CAMPAIGN_SCHEMA_VERSION,
@@ -318,9 +340,9 @@ export const CAMPAIGN_EXPORT_COMPATIBILITY = defineSchemaCompatibility({
318
340
  export const WATCHLIST_EXPORT_COMPATIBILITY = defineSchemaCompatibility({
319
341
  id: 'export.watchlists', kind: 'export', schema: WATCHLIST_SCHEMA, currentVersion: WATCHLIST_SCHEMA_VERSION,
320
342
  supportedVersions: WATCHLIST_EXPORT_SUPPORTED_VERSIONS, acceptsUnversionedLegacy: false,
321
- futureVersionBehavior: 'reject', migration: 'exact_current_only', writeSemantics: 'non_destructive_merge',
322
- byteBudget: MAX_WATCHLIST_IMPORT_BYTES, owner: WORKSPACE_CONTRACT_OWNER,
323
- note: 'Non-destructive collection merge with current schema required.',
343
+ futureVersionBehavior: 'reject', migration: 'normalize_to_current', writeSemantics: 'non_destructive_merge',
344
+ byteBudget: MAX_WATCHLIST_PORTABLE_BYTES, owner: WORKSPACE_CONTRACT_OWNER,
345
+ note: 'Versions 2–5 remain readable. Version 6 preserves older membership overflow as paused, exportable records without activating or pruning domains. New active membership remains bounded. Brand contexts and incomplete-check baselines remain preserved.',
324
346
  });
325
347
  export const SHORTLIST_EXPORT_COMPATIBILITY = defineSchemaCompatibility({
326
348
  id: 'export.shortlist', kind: 'export', schema: SHORTLIST_SCHEMA, currentVersion: SHORTLIST_SCHEMA_VERSION,
@@ -332,7 +354,7 @@ export const SHORTLIST_EXPORT_COMPATIBILITY = defineSchemaCompatibility({
332
354
  export const DETECTION_RULE_EXPORT_COMPATIBILITY = defineSchemaCompatibility({
333
355
  id: 'export.detection-rules', kind: 'export', schema: DETECTION_RULE_SCHEMA, currentVersion: DETECTION_RULE_SCHEMA_VERSION,
334
356
  supportedVersions: DETECTION_RULE_EXPORT_SUPPORTED_VERSIONS, acceptsUnversionedLegacy: false,
335
- futureVersionBehavior: 'reject', migration: 'exact_current_only', writeSemantics: 'non_destructive_merge',
357
+ futureVersionBehavior: 'reject', migration: 'normalize_to_current', writeSemantics: 'non_destructive_merge',
336
358
  byteBudget: MAX_RULE_IMPORT_BYTES, owner: WORKSPACE_CONTRACT_OWNER,
337
359
  note: 'Non-destructive rule merge; imported conditions remain allowlisted and non-executable.',
338
360
  });
@@ -355,14 +377,14 @@ export const BULK_SESSION_EXPORT_COMPATIBILITY = defineSchemaCompatibility({
355
377
  supportedVersions: SUPPORTED_BULK_SESSION_SCHEMA_VERSIONS, acceptsUnversionedLegacy: false,
356
378
  futureVersionBehavior: 'reject', migration: 'normalize_to_current', writeSemantics: 'non_destructive_merge',
357
379
  byteBudget: MAX_BULK_SESSION_STORE_BYTES, owner: WORKSPACE_CONTRACT_OWNER,
358
- note: 'Public schema 4 sessions migrate directly to schema 5 with unknown historical row and per-source observation times and source provenance. Current exports retain source-qualified relationship evidence and bounded profile context.',
380
+ note: 'Public schemas 4 and 5 migrate without invented observation times or collection outcomes. Current exports retain source-qualified relationship evidence, bounded profile context and collection quality.',
359
381
  });
360
382
  export const INVESTIGATION_TEMPLATE_EXPORT_COMPATIBILITY = defineSchemaCompatibility({
361
383
  id: 'export.investigation-templates', kind: 'export', schema: INVESTIGATION_TEMPLATE_SCHEMA,
362
384
  currentVersion: INVESTIGATION_TEMPLATE_VERSION, supportedVersions: INVESTIGATION_TEMPLATE_SUPPORTED_VERSIONS,
363
- acceptsUnversionedLegacy: false, futureVersionBehavior: 'reject', migration: 'exact_current_only',
385
+ acceptsUnversionedLegacy: false, futureVersionBehavior: 'reject', migration: 'normalize_to_current',
364
386
  writeSemantics: 'non_destructive_merge', byteBudget: MAX_INVESTIGATION_TEMPLATE_IMPORT_BYTES, owner: WORKSPACE_CONTRACT_OWNER,
365
- note: 'The unchanged public version 2 writer remains the exact bounded analyst-authored guide baseline with allowlisted stage identities and request gates.',
387
+ note: 'Version 3 exports authored guidance and optional content-hash lesson provenance. Public version 2 remains readable; future versions fail closed.',
366
388
  });
367
389
  export const BULK_REVIEW_EXPORT_COMPATIBILITY = defineSchemaCompatibility({
368
390
  id: 'export.bulk-review', kind: 'export', schema: BULK_REVIEW_SCHEMA, currentVersion: BULK_REVIEW_SCHEMA_VERSION,
@@ -421,6 +443,15 @@ const WORKSPACE_LIFECYCLE_DEFINITIONS = Object.freeze([
421
443
  itemBounds: [
422
444
  { id: 'profiles', path: 'profiles', maximum: MAX_PROFILES, handling: 'truncate' },
423
445
  { id: 'profile-values', path: 'profiles[].values', maximum: MAX_PROFILE_VALUES, handling: 'truncate' },
446
+ { id: 'candidate-observations', path: 'profiles[].candidateObservations', maximum: MAX_CANDIDATE_OBSERVATIONS, handling: 'truncate' },
447
+ { id: 'candidate-matches', path: 'profiles[].candidateObservations[].matches', maximum: MAX_CANDIDATE_MATCHES, handling: 'reject' },
448
+ { id: 'candidate-sources', path: 'profiles[].candidateObservations[].sources', maximum: MAX_CANDIDATE_SOURCES, handling: 'reject' },
449
+ { id: 'candidate-exceptions', path: 'profiles[].candidateExceptions', maximum: MAX_CANDIDATE_EXCEPTIONS, handling: 'truncate' },
450
+ { id: 'exception-history', path: 'profiles[].candidateExceptions[].history', maximum: MAX_CANDIDATE_EXCEPTION_HISTORY, handling: 'truncate' },
451
+ { id: 'keyword-campaigns', path: 'profiles[].keywordCampaigns', maximum: MAX_BRAND_KEYWORD_CAMPAIGNS, handling: 'reject' },
452
+ { id: 'keyword-campaign-history', path: 'profiles[].keywordCampaigns[].history', maximum: MAX_BRAND_KEYWORD_CAMPAIGN_HISTORY, handling: 'reject' },
453
+ { id: 'keyword-positive-terms', path: 'profiles[].keywordCampaigns[].positiveTerms', maximum: MAX_BRAND_KEYWORD_TERMS, handling: 'reject' },
454
+ { id: 'keyword-negative-terms', path: 'profiles[].keywordCampaigns[].negativeTerms', maximum: MAX_BRAND_KEYWORD_TERMS, handling: 'reject' },
424
455
  ],
425
456
  portable: {
426
457
  descriptor: BRAND_PROFILE_EXPORT_COMPATIBILITY,
@@ -466,14 +497,17 @@ const WORKSPACE_LIFECYCLE_DEFINITIONS = Object.freeze([
466
497
  { id: 'watchlists', path: 'watchlists', maximum: MAX_WATCHLISTS, handling: 'truncate' },
467
498
  { id: 'domains', path: 'watchlists[].results', maximum: MAX_WATCHLIST_DOMAINS, handling: 'drop_value' },
468
499
  { id: 'history', path: 'watchlists[].history', maximum: MAX_WATCHLIST_HISTORY_EVENTS, handling: 'truncate' },
500
+ { id: 'domain-contexts', path: 'watchlists[].domainMetadata', maximum: MAX_WATCHLIST_DOMAINS, handling: 'reject' },
501
+ { id: 'brand-contexts', path: 'watchlists[].domainMetadata[].contexts', maximum: MAX_WATCH_CONTEXTS, handling: 'truncate' },
469
502
  ],
470
503
  portable: {
471
504
  descriptor: WATCHLIST_EXPORT_COMPATIBILITY,
472
505
  schema: WATCHLIST_SCHEMA,
473
506
  builder: 'buildWatchlistExport',
474
507
  merger: 'mergeWatchlistStores',
475
- serializerHookId: 'workspace.portable.json',
508
+ serializerHookId: 'workspace.portable.watchlist-json',
476
509
  indentSpaces: 2,
510
+ compactFallback: true,
477
511
  terminalLf: false,
478
512
  },
479
513
  },
@@ -649,6 +683,64 @@ const WORKSPACE_LIFECYCLE_DEFINITIONS = Object.freeze([
649
683
  },
650
684
  ]);
651
685
  const WORKSPACE_LIFECYCLE_FIXTURE_SOURCE = Object.freeze([
686
+ { id: 'workspace.browser.brand.v11', path: 'test/fixtures/workspace-lifecycle/browser-brand-v11.json', bytes: 38, sha256: 'c727cd501774f0f89a39f399a01216065b6ed7048bfed97bbcdaf10e651a7133', schema: 'whoisleuth.browser.brand-profile-store', version: 11 },
687
+ { id: 'workspace.portable.brand.v11', path: 'test/fixtures/workspace-lifecycle/portable-brand-v11.json', bytes: 123, sha256: 'c5023e1eebe187cf355c4fb53b7d8a755dc0ffb01a3deeb9b9dd7032007933d9', schema: 'whoisleuth.brand-profiles', version: 11 },
688
+ { id: 'workspace.browser.watchlist.v6', path: 'test/fixtures/workspace-lifecycle/browser-watchlist-v6.json', bytes: 76, sha256: '16df0f813b17f1765862f5c2ff41c2db9c7d6d76e75f24746aa6d75b59d3f02e', schema: 'whoisleuth.browser.watchlist-store', version: 6 },
689
+ { id: 'workspace.portable.watchlist.v6', path: 'test/fixtures/workspace-lifecycle/portable-watchlist-v6.json', bytes: 120, sha256: 'ced22d0d16e839e0b4625f8799eef30a776908abf3af998de6fad5b549898987', schema: 'whoisleuth.watchlists', version: 6 },
690
+ { id: 'workspace.browser.brand.v10', path: 'test/fixtures/workspace-lifecycle/browser-brand-v10.json', bytes: 38, sha256: 'bebf3a72ba57c06246e6169ebd9a597eb5b867508db85e1db0e3d763bc0839bc', schema: 'whoisleuth.browser.brand-profile-store', version: 10 },
691
+ { id: 'workspace.portable.brand.v10', path: 'test/fixtures/workspace-lifecycle/portable-brand-v10.json', bytes: 123, sha256: '85cd7c58696f8219484ac88210ad7ab4a0ad1c9798e8cb02ff6ebb3fd2c82d8a', schema: 'whoisleuth.brand-profiles', version: 10 },
692
+ { id: 'workspace.browser.watchlist.v5', path: 'test/fixtures/workspace-lifecycle/browser-watchlist-v5.json', bytes: 76, sha256: '11eabd20f952611307a5c84291c7892c15d3748f7ada5549813e1d705967c2c0', schema: 'whoisleuth.browser.watchlist-store', version: 5 },
693
+ { id: 'workspace.portable.watchlist.v5', path: 'test/fixtures/workspace-lifecycle/portable-watchlist-v5.json', bytes: 120, sha256: '732d4bda46e907bdd4d343ce36999a2787a0057029ee4dba0c4ef462f2822fed', schema: 'whoisleuth.watchlists', version: 5 },
694
+ {
695
+ id: 'workspace.browser.detection.v2', path: 'test/fixtures/workspace-lifecycle/browser-detection-v2.json',
696
+ bytes: 34, sha256: '2db6ae8eb9d24cb86924a7020c6f3eebfa41b5fc5848a53e239465db622c2312',
697
+ schema: 'whoisleuth.browser.detection-rule-store', version: 2,
698
+ },
699
+ {
700
+ id: 'workspace.portable.detection.v2', path: 'test/fixtures/workspace-lifecycle/portable-detection-v2.json',
701
+ bytes: 293, sha256: 'bf4409e18ef0b721f5a7e2f14a06f2fada88287114ec17775fef42e7ed38072a',
702
+ schema: 'whoisleuth.detection-rules', version: 2,
703
+ },
704
+ {
705
+ id: 'workspace.browser.bulk.v7', path: 'test/fixtures/workspace-lifecycle/browser-bulk-v7.json',
706
+ bytes: 77, sha256: '6b02cbd48a939a34d8f292da2b6bc8e8b59f3ed1b9c106a8e9e271ec0e949459',
707
+ schema: 'whoisleuth.browser.bulk-session-store', version: 7,
708
+ },
709
+ {
710
+ id: 'workspace.portable.bulk.v7', path: 'test/fixtures/workspace-lifecycle/portable-bulk-v7.json',
711
+ bytes: 338, sha256: 'f7e14d866eeb4e94b5390086e278348bfa0610ace72776fee4fbf24871ab3393',
712
+ schema: 'whoisleuth.bulk-sessions', version: 7,
713
+ },
714
+ {
715
+ id: 'workspace.browser.watchlist.v4', path: 'test/fixtures/workspace-lifecycle/browser-watchlist-v4.json',
716
+ bytes: 76, sha256: '059930ee54d1bc65ac68ef6d3a8ac8cd8e53f682fe9a857512e2b6c57e93d5fe',
717
+ schema: 'whoisleuth.browser.watchlist-store', version: 4,
718
+ },
719
+ {
720
+ id: 'workspace.portable.watchlist.v4', path: 'test/fixtures/workspace-lifecycle/portable-watchlist-v4.json',
721
+ bytes: 120, sha256: '496f1ec39add1d3fe080ad8f9f991639cfbbe659ddb1c58932ac6ff563ce32aa',
722
+ schema: 'whoisleuth.watchlists', version: 4,
723
+ },
724
+ {
725
+ id: 'workspace.browser.watchlist.v3', path: 'test/fixtures/workspace-lifecycle/browser-watchlist-v3.json',
726
+ bytes: 76, sha256: '740246c1666c332ae907a5b40364e03a4f008484fce7b1c6dce8082a6ac7b654',
727
+ schema: 'whoisleuth.browser.watchlist-store', version: 3,
728
+ },
729
+ {
730
+ id: 'workspace.portable.watchlist.v3', path: 'test/fixtures/workspace-lifecycle/portable-watchlist-v3.json',
731
+ bytes: 120, sha256: 'ad07c8a530c369b49192bd3d8dde1b17c58b737da4e3d00a834a64be6aa429bf',
732
+ schema: 'whoisleuth.watchlists', version: 3,
733
+ },
734
+ {
735
+ id: 'workspace.browser.bulk.v6', path: 'test/fixtures/workspace-lifecycle/browser-bulk-v6.json',
736
+ bytes: 77, sha256: '811ef0b05415f19dc7bd940a4786d53a6398121cbb7cd060fcd6d21ed562487b',
737
+ schema: 'whoisleuth.browser.bulk-session-store', version: 6,
738
+ },
739
+ {
740
+ id: 'workspace.portable.bulk.v6', path: 'test/fixtures/workspace-lifecycle/portable-bulk-v6.json',
741
+ bytes: 338, sha256: 'ec7f6445cf42c013163ea835d5f919213b69978124d6883c1f10d62a0868235f',
742
+ schema: 'whoisleuth.bulk-sessions', version: 6,
743
+ },
652
744
  {
653
745
  "id": "workspace.browser.brand.v6",
654
746
  "path": "test/fixtures/workspace-lifecycle/browser-brand-v6.json",
@@ -761,6 +853,14 @@ const WORKSPACE_LIFECYCLE_FIXTURE_SOURCE = Object.freeze([
761
853
  "schema": "whoisleuth.browser.investigation-template-store",
762
854
  "version": 2
763
855
  },
856
+ {
857
+ "id": "workspace.browser.template.v3",
858
+ "path": "test/fixtures/workspace-lifecycle/browser-template-v3.json",
859
+ "bytes": 88,
860
+ "sha256": "70a6dfe2c27c75d86c74a9dfe05148c3186be68191002cbea8c18816a64792c9",
861
+ "schema": "whoisleuth.browser.investigation-template-store",
862
+ "version": 3
863
+ },
764
864
  {
765
865
  "id": "workspace.browser.watchlist.v2",
766
866
  "path": "test/fixtures/workspace-lifecycle/browser-watchlist-v2.json",
@@ -889,6 +989,14 @@ const WORKSPACE_LIFECYCLE_FIXTURE_SOURCE = Object.freeze([
889
989
  "schema": "whoisleuth.investigation-templates",
890
990
  "version": 2
891
991
  },
992
+ {
993
+ "id": "workspace.portable.template.v3",
994
+ "path": "test/fixtures/workspace-lifecycle/portable-template-v3.json",
995
+ "bytes": 471,
996
+ "sha256": "6b211af44661594337731767cd212beae305d81e03e090a783633eebaa62d03e",
997
+ "schema": "whoisleuth.investigation-templates",
998
+ "version": 3
999
+ },
892
1000
  {
893
1001
  "id": "workspace.portable.watchlist.v2",
894
1002
  "path": "test/fixtures/workspace-lifecycle/portable-watchlist-v2.json",
@@ -1100,6 +1208,13 @@ const WORKSPACE_LIFECYCLE_HOOKS = Object.freeze([
1100
1208
  module: WORKSPACE_CONTRACT_OWNER,
1101
1209
  exportName: 'serialiseWorkspacePortableJson',
1102
1210
  },
1211
+ {
1212
+ id: 'workspace.portable.watchlist-json',
1213
+ role: 'serialiser',
1214
+ runtime: 'shared',
1215
+ module: 'packages/workspace/watchlist-store.mts',
1216
+ exportName: 'serializeWatchlistExport',
1217
+ },
1103
1218
  {
1104
1219
  id: 'workspace.portable.json-line',
1105
1220
  role: 'serialiser',
@@ -1208,6 +1323,7 @@ function workspacePortableSerialisation(definition) {
1208
1323
  encoding: 'utf-8',
1209
1324
  bom: false,
1210
1325
  indentSpaces: definition.portable.indentSpaces,
1326
+ ...(definition.portable.compactFallback ? { compactFallback: true } : {}),
1211
1327
  terminalLf: definition.portable.terminalLf,
1212
1328
  propertyOrder: 'normalised_fixed',
1213
1329
  canonicalisation: null,
@@ -0,0 +1,67 @@
1
+ const STATES = new Set(['complete', 'partial', 'unavailable', 'not_collected', 'unknown']);
2
+ export function isWebCollectionQuality(value) {
3
+ if (!value || typeof value !== 'object' || Array.isArray(value))
4
+ return false;
5
+ const prototype = Object.getPrototypeOf(value);
6
+ if (prototype !== Object.prototype && prototype !== null)
7
+ return false;
8
+ const record = value;
9
+ const keys = Object.keys(record);
10
+ if (keys.length !== 4 || keys.some(key => !['version', 'page', 'favicon', 'combined'].includes(key)
11
+ || !Object.hasOwn(Object.getOwnPropertyDescriptor(record, key) ?? {}, 'value')))
12
+ return false;
13
+ return record.version === 1
14
+ && typeof record.page === 'string' && STATES.has(record.page)
15
+ && typeof record.favicon === 'string' && STATES.has(record.favicon)
16
+ && typeof record.combined === 'string' && STATES.has(record.combined)
17
+ && (record.combined !== 'complete' || (record.page === 'complete' && record.favicon === 'complete'));
18
+ }
19
+ export function normalizeWebCollectionQuality(value, depth) {
20
+ if (value === undefined)
21
+ return undefined;
22
+ if (!isWebCollectionQuality(value))
23
+ throw new TypeError('Unsupported or invalid web collection quality.');
24
+ if (depth === 'fast')
25
+ return capturedWebCollectionQuality('not_collected', 'not_collected');
26
+ return { version: 1, page: value.page, favicon: value.favicon, combined: value.combined };
27
+ }
28
+ export function capturedWebCollectionQuality(page, favicon) {
29
+ const combined = page === 'complete' && favicon === 'complete' ? 'complete'
30
+ : page === favicon ? page : 'partial';
31
+ return { version: 1, page, favicon, combined };
32
+ }
33
+ export function webCollectionQualityForCapture(value, depth) {
34
+ if (depth !== 'deep')
35
+ return capturedWebCollectionQuality('not_collected', 'not_collected');
36
+ return normalizeWebCollectionQuality(value) ?? capturedWebCollectionQuality('unknown', 'unknown');
37
+ }
38
+ const PAGE_FIELDS = new Set(['activityStatus', 'pageTitle', 'hasPasswordField', 'hasExternalFormAction', 'hasExternalPasswordForm', 'phishingLanguageMatch', 'pageBaselineMatch']);
39
+ const FAVICON_FIELDS = new Set(['faviconHash', 'faviconPHash', 'faviconMatch', 'faviconNearMatch']);
40
+ const COMBINED_FIELDS = new Set(['riskScore', 'riskFactors', 'riskModelVersion', 'opportunityScore', 'opportunityFactors', 'reusesOfficialAssets']);
41
+ /** Missing historical quality never proves that a false/null value was observed. */
42
+ export function webCollectionAllowsComparison(field, quality, depth) {
43
+ if (PAGE_FIELDS.has(field))
44
+ return quality?.page === 'complete';
45
+ if (FAVICON_FIELDS.has(field))
46
+ return quality?.favicon === 'complete';
47
+ if (COMBINED_FIELDS.has(field) && depth === 'deep')
48
+ return quality?.combined === 'complete';
49
+ return true;
50
+ }
51
+ /** Retain the measured score while qualifying incomplete inputs at presentation. */
52
+ export function webCollectionScoreLimitation(quality, depth) {
53
+ if (depth === 'unknown')
54
+ return 'Collection depth is unknown; score is not comparable.';
55
+ return webCollectionAllowsComparison('riskScore', quality, depth)
56
+ ? null : 'Incomplete or unknown web collection; score is not comparable.';
57
+ }
58
+ /** An incomplete current attempt cannot erase the last comparable baseline. */
59
+ export function mergeWebCollectionQuality(previous, current) {
60
+ if (!previous && !current)
61
+ return undefined;
62
+ return { version: 1,
63
+ page: current?.page === 'complete' ? 'complete' : previous?.page ?? current?.page ?? 'unknown',
64
+ favicon: current?.favicon === 'complete' ? 'complete' : previous?.favicon ?? current?.favicon ?? 'unknown',
65
+ combined: current?.combined === 'complete' ? 'complete' : previous?.combined ?? current?.combined ?? 'unknown',
66
+ };
67
+ }
@@ -0,0 +1,60 @@
1
+ export const CREDENTIAL_CATEGORY_NAMES = ['password', 'email', 'username', 'one_time_code', 'payment'];
2
+ export const MAX_CREDENTIAL_FORMS = 50;
3
+ export const MAX_CREDENTIAL_INPUTS = 500;
4
+ export const MAX_FORM_DESTINATIONS = 10;
5
+ function record(value) {
6
+ return Boolean(value && typeof value === 'object' && !Array.isArray(value));
7
+ }
8
+ function keys(value, expected) {
9
+ return Object.keys(value).length === expected.length && expected.every(key => Object.hasOwn(value, key));
10
+ }
11
+ const count = (value, max) => typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 && value <= max;
12
+ export function validCredentialFormAttribution(value) {
13
+ if (!record(value) || !keys(value, ['complete', 'unassociatedInputs', 'forms']) || typeof value.complete !== 'boolean'
14
+ || !count(value.unassociatedInputs, MAX_CREDENTIAL_INPUTS) || !Array.isArray(value.forms) || value.forms.length > MAX_CREDENTIAL_FORMS)
15
+ return false;
16
+ let classified = 0;
17
+ for (const [index, form] of value.forms.entries()) {
18
+ if (!record(form) || !keys(form, ['index', 'categories', 'destinations']) || form.index !== index + 1
19
+ || !record(form.categories) || !keys(form.categories, CREDENTIAL_CATEGORY_NAMES)
20
+ || !CREDENTIAL_CATEGORY_NAMES.every(key => count(form.categories[key], MAX_CREDENTIAL_INPUTS))
21
+ || !Array.isArray(form.destinations) || !form.destinations.length || form.destinations.length > MAX_FORM_DESTINATIONS)
22
+ return false;
23
+ classified += Math.max(...CREDENTIAL_CATEGORY_NAMES.map(key => Number(form.categories[key])));
24
+ const seen = new Set();
25
+ for (const destination of form.destinations) {
26
+ if (!record(destination) || !keys(destination, ['relationship', 'origin']))
27
+ return false;
28
+ if (destination.relationship === 'unknown' || destination.relationship === 'no_submission') {
29
+ if (destination.origin !== null || (destination.relationship === 'unknown' && value.complete))
30
+ return false;
31
+ }
32
+ else if (destination.relationship === 'external' || destination.relationship === 'same_origin') {
33
+ if (typeof destination.origin !== 'string' || destination.origin.length > 320)
34
+ return false;
35
+ try {
36
+ const url = new URL(destination.origin);
37
+ if (!['http:', 'https:'].includes(url.protocol) || url.origin !== destination.origin || url.username || url.password)
38
+ return false;
39
+ }
40
+ catch {
41
+ return false;
42
+ }
43
+ }
44
+ else
45
+ return false;
46
+ const identity = `${destination.relationship}:${destination.origin}`;
47
+ if (seen.has(identity))
48
+ return false;
49
+ seen.add(identity);
50
+ }
51
+ }
52
+ return classified + value.unassociatedInputs <= MAX_CREDENTIAL_INPUTS && (!value.complete || value.unassociatedInputs === 0);
53
+ }
54
+ export function externalPasswordFormObservation(value) {
55
+ if (!validCredentialFormAttribution(value))
56
+ return null;
57
+ if (value.forms.some(form => form.categories.password > 0 && form.destinations.some(destination => destination.relationship === 'external')))
58
+ return true;
59
+ return value.complete ? false : null;
60
+ }
@@ -0,0 +1,72 @@
1
+ import { MAX_LOOKUP_DNS_RECORDS_PER_TYPE } from "../analysis/lookup-network-evidence-bounds.mjs";
2
+ import { addressValue, canonicalIpAddress } from "../contracts/ip-address.mjs";
3
+ /** Fixed values retained in Observation v1's existing diagnostic detail slot. */
4
+ export const DNS_QUERY_OUTCOMES = Object.freeze({
5
+ records: { status: 'success', label: 'Records returned' },
6
+ empty_answer: { status: 'not_found', label: 'No records returned' },
7
+ no_data: { status: 'not_found', label: 'No data for this record type (NODATA)' },
8
+ name_not_found: { status: 'not_found', label: 'Name not found by resolver' },
9
+ timeout: { status: 'error', label: 'DNS query timed out' },
10
+ server_failure: { status: 'error', label: 'DNS server failed to answer (SERVFAIL)' },
11
+ refused: { status: 'error', label: 'DNS query refused' },
12
+ invalid_response: { status: 'error', label: 'DNS answer could not be read completely' },
13
+ error: { status: 'error', label: 'DNS query failed' },
14
+ skipped: { status: 'skipped', label: 'Not queried' },
15
+ });
16
+ function record(value) {
17
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
18
+ ? value : {};
19
+ }
20
+ /** Legacy not_found diagnostics do not identify which negative answer occurred. */
21
+ export function readDnsQueryOutcome(value) {
22
+ const diagnostic = record(value);
23
+ const detail = diagnostic.detail;
24
+ return typeof detail === 'string' && Object.hasOwn(DNS_QUERY_OUTCOMES, detail)
25
+ && DNS_QUERY_OUTCOMES[detail].status === diagnostic.status
26
+ ? detail : null;
27
+ }
28
+ export function dnsQueryFailureOutcome(code) {
29
+ switch (code) {
30
+ case 'ENODATA': return 'no_data';
31
+ case 'ENOTFOUND': return 'name_not_found';
32
+ case 'ETIMEOUT': return 'timeout';
33
+ case 'ESERVFAIL': return 'server_failure';
34
+ case 'EREFUSED': return 'refused';
35
+ case 'EBADRESP': return 'invalid_response';
36
+ default: return 'error';
37
+ }
38
+ }
39
+ /** One family, one source state. Unrelated mail/delegation failures cannot erase it. */
40
+ export function dnsAddressFamilyEvidence(dnsValue, family) {
41
+ const dns = record(dnsValue);
42
+ const diagnostic = record(record(dns.diagnostics)[family]);
43
+ const raw = record(dns.records)[family];
44
+ const candidates = Array.isArray(raw) ? raw.slice(0, MAX_LOOKUP_DNS_RECORDS_PER_TYPE) : [];
45
+ const addresses = candidates.flatMap(value => {
46
+ const address = canonicalIpAddress(value);
47
+ return address && addressValue(address)?.family === (family === 'a' ? 4 : 6) ? [address] : [];
48
+ });
49
+ const malformed = raw !== undefined && !Array.isArray(raw)
50
+ || candidates.length !== addresses.length;
51
+ const truncated = diagnostic.truncated === true
52
+ || typeof diagnostic.discarded === 'number' && diagnostic.discarded > 0
53
+ || Array.isArray(raw) && raw.length > candidates.length;
54
+ const outcome = readDnsQueryOutcome(diagnostic);
55
+ const inconsistent = outcome === 'records' && !addresses.length
56
+ || outcome !== null && outcome !== 'records' && addresses.length > 0;
57
+ const complete = !malformed && !truncated && !inconsistent && Array.isArray(raw)
58
+ && outcome !== null && DNS_QUERY_OUTCOMES[outcome].status !== 'error' && outcome !== 'skipped';
59
+ const state = malformed || inconsistent ? 'error'
60
+ : truncated ? 'partial'
61
+ : outcome ? DNS_QUERY_OUTCOMES[outcome].status
62
+ : diagnostic.status === 'error' ? 'error'
63
+ : diagnostic.status === 'skipped' || dns.status === 'skipped' ? 'skipped' : 'unknown';
64
+ const value = addresses.length ? [...new Set(addresses)].sort().join(' · ')
65
+ : malformed || inconsistent ? DNS_QUERY_OUTCOMES.invalid_response.label
66
+ : truncated ? 'DNS answer incomplete'
67
+ : outcome ? DNS_QUERY_OUTCOMES[outcome].label
68
+ : state === 'error' ? DNS_QUERY_OUTCOMES.error.label
69
+ : state === 'skipped' ? DNS_QUERY_OUTCOMES.skipped.label
70
+ : diagnostic.status === 'not_found' ? 'Negative DNS outcome not recorded' : null;
71
+ return { outcome, value, state, complete, truncated };
72
+ }
@@ -0,0 +1,18 @@
1
+ // Single plain recipient admission for manually opened drafts, not delivery validation.
2
+ const SINGLE_ADDRESS = /^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$/u;
3
+ export function emailRecipient(value) {
4
+ if (typeof value !== 'string' || value.length > 320 || /[\u0000-\u001f\u007f]/u.test(value))
5
+ return null;
6
+ const address = value.trim();
7
+ return SINGLE_ADDRESS.test(address) ? address : null;
8
+ }
9
+ /** Encode the recipient independently; upstream text never becomes a header. */
10
+ export function recipientMailto(value, headers) {
11
+ const address = emailRecipient(value);
12
+ if (!address)
13
+ return null;
14
+ const separator = address.indexOf('@');
15
+ const recipient = `${encodeURIComponent(address.slice(0, separator))}@${encodeURIComponent(address.slice(separator + 1))}`;
16
+ const query = headers ? `?subject=${encodeURIComponent(headers.subject)}&body=${encodeURIComponent(headers.body)}` : '';
17
+ return `mailto:${recipient}${query}`;
18
+ }
@@ -5,6 +5,21 @@ export const MAX_EVIDENCE_IMAGE_PIXELS = 16 * 1024 * 1024;
5
5
  export const MAX_EVIDENCE_IMAGE_DIMENSION = 10_000;
6
6
  // Each selected region emits at most four solid rectangles; no nested effects.
7
7
  export const MAX_EVIDENCE_IMAGE_REGIONS = 64;
8
+ export function readImageDerivationDeclaration(raw) {
9
+ const value = exact(raw, ['method', 'source', 'operations'], 'Image derivation declaration');
10
+ if (value.method !== 'png-regions-v1')
11
+ throw new TypeError('This image derivation method is unsupported.');
12
+ const operations = Array.from(array(value.operations, 'Image operations', 2, 1), operation => enumeration(operation, ['redact', 'outline'], 'Image operation'));
13
+ if (new Set(operations).size !== operations.length)
14
+ throw new TypeError('Image operations must be unique.');
15
+ return Object.freeze({ method: value.method, source: readRetainedFileReference(value.source), operations: Object.freeze(operations) });
16
+ }
17
+ /** Retain the immediate parent's fingerprint, without identifiers or region coordinates. */
18
+ export function declareImageDerivation(raw) {
19
+ const derivation = readImageDerivation(raw);
20
+ const operations = ['redact', 'outline'].filter(kind => derivation.plan.regions.some(region => region.kind === kind));
21
+ return readImageDerivationDeclaration({ method: derivation.method, source: derivation.source, operations });
22
+ }
8
23
  export function readEvidenceImageDimensions(width, height) {
9
24
  const result = {
10
25
  width: integer(width, 'Image width', 1, MAX_EVIDENCE_IMAGE_DIMENSION),
@@ -5,6 +5,10 @@ import { MAX_OUTBOUND_HTTP_URL_CHARACTERS } from "../contracts/http-url.mjs";
5
5
  export const MAX_LOOKUP_INPUT_CHARACTERS = 2 * 1024 * 1024;
6
6
  const URL_SCHEME = /^[a-z][a-z\d+.-]*:\/\//iu;
7
7
  const CONTROL = /[\u0000-\u001f\u007f]/u;
8
+ /** Lexical recognition only; protocol, credentials and target admission remain separate. */
9
+ export function hasLookupUrlScheme(value) {
10
+ return URL_SCHEME.test(value);
11
+ }
8
12
  export function validWebObservationMode(value) {
9
13
  return value === undefined || value === 'selected_url';
10
14
  }
@@ -76,7 +80,7 @@ export function prepareLookupCollectionTarget(value) {
76
80
  const raw = value.trim();
77
81
  if (!raw)
78
82
  throw new TypeError('Enter a domain, IP address or ASN.');
79
- const absolute = URL_SCHEME.test(raw);
83
+ const absolute = hasLookupUrlScheme(raw);
80
84
  // An unbracketed IPv6 literal is not a URL with a port. Use the same URL
81
85
  // parser to admit its syntax, then leave address safety and ASN bounds to
82
86
  // the classifier; malformed colon-separated text must not become a request.