@slicedearth/whoisleuth-cli 1.32.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 (189) hide show
  1. package/LICENSE +661 -0
  2. package/LICENSES/Retire.js-Apache-2.0.txt +199 -0
  3. package/NOTICE +76 -0
  4. package/README.md +84 -0
  5. package/TRADEMARKS.md +41 -0
  6. package/bin/whoisleuth.mjs +35 -0
  7. package/cli/archive-inspect.mjs +198 -0
  8. package/cli/arguments.mjs +1022 -0
  9. package/cli/artifact-verify.mjs +210 -0
  10. package/cli/bulk-checkpoint.mjs +218 -0
  11. package/cli/bulk-command-runner.mjs +109 -0
  12. package/cli/bulk-output.mjs +112 -0
  13. package/cli/bulk.mjs +133 -0
  14. package/cli/compare.mjs +238 -0
  15. package/cli/completion.mjs +175 -0
  16. package/cli/csv.mjs +1 -0
  17. package/cli/discover.mjs +50 -0
  18. package/cli/discovery-command-runner.mjs +36 -0
  19. package/cli/discovery-observation-snapshot.mjs +315 -0
  20. package/cli/discovery-scan-command-runner.mjs +141 -0
  21. package/cli/discovery-scan.mjs +325 -0
  22. package/cli/discovery-snapshot.mjs +133 -0
  23. package/cli/discovery-workflow.mjs +73 -0
  24. package/cli/doctor.mjs +137 -0
  25. package/cli/errors.mjs +21 -0
  26. package/cli/evidence-command-arguments.mjs +145 -0
  27. package/cli/evidence-command-runner.mjs +106 -0
  28. package/cli/evidence-signing.mjs +215 -0
  29. package/cli/exit-codes.mjs +9 -0
  30. package/cli/export-evidence.mjs +28 -0
  31. package/cli/formatters/evidence-report.mjs +3 -0
  32. package/cli/formatters/html.mjs +69 -0
  33. package/cli/formatters/json.mjs +135 -0
  34. package/cli/formatters/markdown.mjs +3 -0
  35. package/cli/formatters/terminal.mjs +658 -0
  36. package/cli/http.mjs +27 -0
  37. package/cli/lookup-command-runner.mjs +87 -0
  38. package/cli/lookup-diff.mjs +171 -0
  39. package/cli/mail-review.mjs +204 -0
  40. package/cli/manual.mjs +34 -0
  41. package/cli/network-command-runner.mjs +88 -0
  42. package/cli/output-file.mjs +97 -0
  43. package/cli/page-compare.mjs +123 -0
  44. package/cli/posture.mjs +20 -0
  45. package/cli/progress-events.mjs +53 -0
  46. package/cli/progress.mjs +56 -0
  47. package/cli/registry-access.mjs +3 -0
  48. package/cli/registry-support.mjs +120 -0
  49. package/cli/risk-calibration.mjs +294 -0
  50. package/cli/runner-types.mjs +1 -0
  51. package/cli/runner.mjs +726 -0
  52. package/cli/saved-lookup.mjs +87 -0
  53. package/cli/source-reliability.mjs +727 -0
  54. package/cli/strict-exit.mjs +55 -0
  55. package/cli/terminal-presentation.mjs +147 -0
  56. package/docs/cli.md +827 -0
  57. package/frontend/src/lib/analysis/acquisition-decision-packet.js +93 -0
  58. package/frontend/src/lib/analysis/acquisition-due-diligence.js +391 -0
  59. package/frontend/src/lib/analysis/artifact-integrity.js +21 -0
  60. package/frontend/src/lib/analysis/brand-profile-model.js +421 -0
  61. package/frontend/src/lib/analysis/bulk-domain-comparison.js +246 -0
  62. package/frontend/src/lib/analysis/bulk-mail-exposure.js +192 -0
  63. package/frontend/src/lib/analysis/bulk-retry-plan.js +86 -0
  64. package/frontend/src/lib/analysis/bulk-review-export.js +62 -0
  65. package/frontend/src/lib/analysis/bulk-review-model.js +266 -0
  66. package/frontend/src/lib/analysis/bulk-session-model.js +515 -0
  67. package/frontend/src/lib/analysis/bulk-sort.js +49 -0
  68. package/frontend/src/lib/analysis/campaign-model.js +265 -0
  69. package/frontend/src/lib/analysis/case-evidence-model.js +642 -0
  70. package/frontend/src/lib/analysis/case-migration-model.js +248 -0
  71. package/frontend/src/lib/analysis/case-model.js +5 -0
  72. package/frontend/src/lib/analysis/case-record-contracts.js +44 -0
  73. package/frontend/src/lib/analysis/case-record-core.js +232 -0
  74. package/frontend/src/lib/analysis/case-record-model.js +7 -0
  75. package/frontend/src/lib/analysis/case-record-operations.js +224 -0
  76. package/frontend/src/lib/analysis/case-response-model.js +640 -0
  77. package/frontend/src/lib/analysis/case-response-packet.js +573 -0
  78. package/frontend/src/lib/analysis/case-storage-model.js +93 -0
  79. package/frontend/src/lib/analysis/detection-rule-model.js +306 -0
  80. package/frontend/src/lib/analysis/dns-record-normalization.js +11 -0
  81. package/frontend/src/lib/analysis/http-summary.js +165 -0
  82. package/frontend/src/lib/analysis/investigation-guide.js +500 -0
  83. package/frontend/src/lib/analysis/investigation-template-model.js +144 -0
  84. package/frontend/src/lib/analysis/page-baseline.js +272 -0
  85. package/frontend/src/lib/analysis/page-similarity.js +179 -0
  86. package/frontend/src/lib/analysis/relationship-evidence.js +265 -0
  87. package/frontend/src/lib/analysis/relationship-observation-model.js +423 -0
  88. package/frontend/src/lib/analysis/scoring.js +135 -0
  89. package/frontend/src/lib/analysis/shortlist-model.js +209 -0
  90. package/frontend/src/lib/analysis/utils.js +265 -0
  91. package/frontend/src/lib/analysis/watchlist-history.js +643 -0
  92. package/frontend/src/lib/analysis/watchlist-store.js +127 -0
  93. package/frontend/src/lib/analysis/website-snapshot-model.js +302 -0
  94. package/frontend/src/lib/analysis/workspace-archive-crypto.js +282 -0
  95. package/frontend/src/lib/analysis/workspace-archive.js +472 -0
  96. package/lib/abort.mjs +26 -0
  97. package/lib/abusech-auth.mjs +19 -0
  98. package/lib/availability.mjs +716 -0
  99. package/lib/bounded-contract-normalizers.mjs +61 -0
  100. package/lib/browser-library-profile.mjs +310 -0
  101. package/lib/bulk-comparison-evidence.mjs +83 -0
  102. package/lib/classify.mjs +93 -0
  103. package/lib/client-behavior-profile.mjs +83 -0
  104. package/lib/credential-surface-profile.mjs +66 -0
  105. package/lib/csv.mjs +23 -0
  106. package/lib/ct-query.mjs +27 -0
  107. package/lib/ct-search.mjs +393 -0
  108. package/lib/distributed-operation-budget.mjs +461 -0
  109. package/lib/dns-delegation-health.mjs +344 -0
  110. package/lib/dns-intelligence.mjs +739 -0
  111. package/lib/dns-mx.mjs +24 -0
  112. package/lib/dns-resolver-selection.mjs +33 -0
  113. package/lib/domain-posture-analysis.mjs +254 -0
  114. package/lib/domain-posture-parsers.mjs +358 -0
  115. package/lib/domain-posture.mjs +745 -0
  116. package/lib/error-detail.mjs +10 -0
  117. package/lib/evidence-export.mjs +431 -0
  118. package/lib/evidence-report-markdown.mjs +71 -0
  119. package/lib/evidence-report.mjs +391 -0
  120. package/lib/external-intelligence-risk.mjs +108 -0
  121. package/lib/favicon.mjs +146 -0
  122. package/lib/feature-policy.mjs +70 -0
  123. package/lib/generated/retire-browser-catalog.mjs +8724 -0
  124. package/lib/generated/unicode-confusables-17.mjs +93 -0
  125. package/lib/hostname.mjs +38 -0
  126. package/lib/html-signals.mjs +680 -0
  127. package/lib/http-intelligence.mjs +232 -0
  128. package/lib/idn-confusable-policy.mjs +86 -0
  129. package/lib/idn-confusables.mjs +465 -0
  130. package/lib/lookup-cache.mjs +130 -0
  131. package/lib/lookup-progress.mjs +291 -0
  132. package/lib/lookup-source-progress.mjs +109 -0
  133. package/lib/lookup-threat-provider-inventory.mjs +13 -0
  134. package/lib/lookup.mjs +592 -0
  135. package/lib/observation.mjs +114 -0
  136. package/lib/observed-network-context.mjs +355 -0
  137. package/lib/operation-budget.mjs +489 -0
  138. package/lib/page-fingerprints.mjs +481 -0
  139. package/lib/page-role-profile.mjs +117 -0
  140. package/lib/perceptual-hash.mjs +451 -0
  141. package/lib/rdap-attempts.mjs +34 -0
  142. package/lib/rdap-bootstrap.mjs +173 -0
  143. package/lib/rdap-capabilities.mjs +292 -0
  144. package/lib/rdap-client.mjs +125 -0
  145. package/lib/rdap-normalization.mjs +691 -0
  146. package/lib/rdap-registrar.mjs +196 -0
  147. package/lib/rdap-transport.mjs +39 -0
  148. package/lib/rdap-types.mjs +1 -0
  149. package/lib/rdap-validation.mjs +94 -0
  150. package/lib/rdap.mjs +16 -0
  151. package/lib/registry-access.mjs +32 -0
  152. package/lib/registry-capabilities.mjs +117 -0
  153. package/lib/registry-capability-catalogue.mjs +1206 -0
  154. package/lib/registry-comparison.mjs +318 -0
  155. package/lib/registry-dates.mjs +190 -0
  156. package/lib/registry-insights.mjs +325 -0
  157. package/lib/response-policy.mjs +256 -0
  158. package/lib/risk-scoring.mjs +164 -0
  159. package/lib/safe-fetch.mjs +446 -0
  160. package/lib/security-txt.mjs +359 -0
  161. package/lib/service-binding-dns.mjs +608 -0
  162. package/lib/sslbl-certificates.generated.mjs +143 -0
  163. package/lib/sslbl-intelligence.mjs +241 -0
  164. package/lib/static-html-analysis.mjs +340 -0
  165. package/lib/structured-data-identity.mjs +323 -0
  166. package/lib/threat-intelligence-contract.mjs +5 -0
  167. package/lib/threat-intelligence-definition-registry.mjs +34 -0
  168. package/lib/threat-intelligence-fixtures.mjs +82 -0
  169. package/lib/threat-intelligence-manifests.mjs +512 -0
  170. package/lib/threat-intelligence-runtime.mjs +662 -0
  171. package/lib/threat-intelligence-targets.mjs +168 -0
  172. package/lib/threat-intelligence-types.mjs +3 -0
  173. package/lib/threatfox-intelligence.mjs +307 -0
  174. package/lib/tls-intelligence.mjs +814 -0
  175. package/lib/typosquat-generator.mjs +884 -0
  176. package/lib/urlhaus-intelligence.mjs +361 -0
  177. package/lib/urlscan-intelligence.mjs +349 -0
  178. package/lib/website-security-posture.mjs +325 -0
  179. package/lib/website-technology.mjs +437 -0
  180. package/lib/whois-authority.mjs +98 -0
  181. package/lib/whois-chain.mjs +261 -0
  182. package/lib/whois-contacts.mjs +163 -0
  183. package/lib/whois-contracts.mjs +3 -0
  184. package/lib/whois-normalization.mjs +27 -0
  185. package/lib/whois-transport.mjs +100 -0
  186. package/lib/whois-values.mjs +92 -0
  187. package/lib/whois.mjs +1187 -0
  188. package/package.json +55 -0
  189. package/third-party-notices.txt +5571 -0
@@ -0,0 +1,500 @@
1
+ import { normalizeDomain } from "./case-model.js";
2
+ import { parse } from 'tldts';
3
+ export const INVESTIGATION_GUIDE_SCHEMA = 'whoisleuth.investigation-recipe';
4
+ export const INVESTIGATION_GUIDE_VERSION = 4;
5
+ export const INVESTIGATION_GUIDE_LEGACY_VERSION = 1;
6
+ export const INVESTIGATION_GUIDE_EXPORT_VERSION = 3;
7
+ export const INVESTIGATION_GUIDE_SUPPORTED_VERSIONS = [2, 3, INVESTIGATION_GUIDE_VERSION];
8
+ export const INVESTIGATION_GUIDE_EXPORT_SCHEMA = 'whoisleuth.investigation-recipe-summary';
9
+ export const MAX_INVESTIGATION_GUIDE_DOMAIN_LENGTH = 253;
10
+ export const MAX_INVESTIGATION_GUIDE_REVIEW_DOMAINS = 25;
11
+ export const MAX_INVESTIGATION_GUIDE_REVIEW_NOTE_LENGTH = 500;
12
+ export const MAX_INVESTIGATION_GUIDE_TIMESTAMP_LENGTH = 64;
13
+ export const MAX_INVESTIGATION_GUIDE_SERIALIZED_BYTES = 12_288;
14
+ export const MAX_INVESTIGATION_GUIDE_EXPORT_BYTES = 16_384;
15
+ const CONTROL_RE = /[\x00-\x1f\x7f]/u;
16
+ const SAFE_TEMPLATE_ID_RE = /^[A-Za-z0-9_-]{1,128}$/u;
17
+ const GUIDE_STATUSES = new Set(['active', 'paused']);
18
+ const GUIDE_OUTCOMES = new Set(['pending', 'complete', 'partial', 'skipped']);
19
+ function stage(id, workspace, label, detail, expectedEvidence, requestImpact, prerequisite, completionCriteria, instructions, requiresApproval) {
20
+ return Object.freeze({
21
+ id,
22
+ workspace,
23
+ label,
24
+ path: `/${workspace}`,
25
+ detail,
26
+ expectedEvidence,
27
+ requestImpact,
28
+ prerequisite,
29
+ completionCriteria,
30
+ instructions: Object.freeze([...instructions]),
31
+ requiresApproval,
32
+ });
33
+ }
34
+ function registrableGuideDomain(domain) {
35
+ return parse(domain).domain || domain;
36
+ }
37
+ function normalizeReviewDomains(values) {
38
+ if (!Array.isArray(values))
39
+ return { domains: [], truncated: false };
40
+ const domains = [];
41
+ const seen = new Set();
42
+ let truncated = false;
43
+ for (const value of values.slice(0, MAX_INVESTIGATION_GUIDE_REVIEW_DOMAINS + 1)) {
44
+ const domain = normalizeInvestigationGuideDomain(value);
45
+ if (!domain || seen.has(domain))
46
+ continue;
47
+ if (domains.length >= MAX_INVESTIGATION_GUIDE_REVIEW_DOMAINS) {
48
+ truncated = true;
49
+ break;
50
+ }
51
+ seen.add(domain);
52
+ domains.push(domain);
53
+ }
54
+ if (values.length > MAX_INVESTIGATION_GUIDE_REVIEW_DOMAINS + 1)
55
+ truncated = true;
56
+ return { domains, truncated };
57
+ }
58
+ export const INVESTIGATION_RECIPES = Object.freeze([
59
+ Object.freeze({
60
+ id: 'brand_sweep',
61
+ label: 'Brand sweep',
62
+ summary: 'Define the official brand boundary, discover candidates, triage a bounded set, inspect priority domains, and retain reviewed cases.',
63
+ targetLabel: 'Official domain',
64
+ stages: Object.freeze([
65
+ stage('brands', 'brands', 'Confirm brand profile', 'Review the official domain, allowlists, and brand context before generating candidates.', 'A bounded Brand Profile with the official domain and any reviewed allowlists.', 'Local-only. Opening Brands makes no analysis request.', 'Know the official domain and the brand boundary you intend to assess.', 'The relevant profile is reviewed, or this stage is explicitly skipped with the limitation understood.', ['Open the pre-filled profile form.', 'Add the brand name and any known trusted domains or registrars.', 'Save the profile, then mark this step reviewed.'], false),
66
+ stage('discover', 'discover', 'Discover candidates', 'Generate bounded permutations and optionally query separately attributed Certificate Transparency observations.', 'A reviewed candidate set with mutation and discovery provenance.', 'Candidate generation is local. Certificate Transparency search is an explicit network action with its own bounded request budget.', 'Confirm the profile boundary and choose the candidate sources you intend to use.', 'Useful candidates are shortlisted, or the result is marked partial or skipped without implying absence.', ['Generate a bounded candidate set from the pre-filled registrable domain.', 'Remove irrelevant candidates and keep only domains worth checking.', 'Send the selected candidates to Bulk. The handoff records the reviewed set and completes this step.'], true),
67
+ stage('bulk', 'bulk', 'Triage candidates', 'Scan only the candidate set you deliberately hand off and compare explainable domain signals.', 'Bounded fast or deep results with availability, source health, Risk factors, and relationship evidence.', 'Bulk collection makes one or more bounded analysis requests according to the selected depth and candidate count.', 'Review the handoff count, depth, and request implications before starting the scan.', 'Priority candidates are identified and incomplete sources remain visible, or the stage is marked partial.', ['Confirm the candidate queue came from Discover.', 'Choose Fast for registration triage or Deep for compact web and mail evidence.', 'Run the scan, review source limitations, then use Inspect on one priority row.'], true),
68
+ stage('lookup', 'lookup', 'Inspect priority domain', 'Open a priority candidate for separately attributed deep evidence and source comparison.', 'Registry, registrar, DNS, certificate, HTTP, page-identity, and threat-source observations where supported.', 'Deep Lookup can contact several public services within explicit deadlines and response caps.', 'Choose one candidate based on the triage evidence rather than the score alone.', 'Material evidence and limitations are reviewed; a case may be created only by an explicit analyst action.', ['Choose Inspect in Lookup on a priority Bulk row.', 'Run a Deep lookup for that candidate.', 'Review the source states and evidence, then mark this step reviewed or partial.'], true),
69
+ stage('monitor', 'monitor', 'Retain reviewed work', 'Create or update an analyst case and choose whether the domain belongs on a watchlist.', 'A bounded case timeline, analyst disposition, and optional compact monitoring baseline.', 'Local case work makes no request. A rescan or hosted-monitor change remains a separate explicit action.', 'Review evidence provenance and avoid converting a heuristic score into a verdict.', 'The analyst records the intended disposition or explicitly skips retention.', ['Open or create the pre-filled case for the inspected candidate.', 'Record an analyst disposition, notes, or follow-up only when supported by the evidence.', 'Mark this step reviewed when the retained record is useful.'], false),
70
+ ]),
71
+ }),
72
+ Object.freeze({
73
+ id: 'infrastructure_pivot',
74
+ label: 'Infrastructure pivot',
75
+ summary: 'Collect one domain, inspect explainable relationships, review connected evidence, and retain only defensible pivots.',
76
+ targetLabel: 'Starting domain',
77
+ stages: Object.freeze([
78
+ stage('lookup', 'lookup', 'Collect starting evidence', 'Run a deliberate deep lookup for the starting domain before evaluating infrastructure links.', 'Separately attributed registry, DNS, certificate, HTTP, and page evidence for the starting domain.', 'Deep Lookup can contact several public services within explicit deadlines and response caps.', 'Confirm the starting domain is in scope and review the deep collection implications.', 'The lookup settles with success, partial, unsupported, or error states preserved.', ['Run the pre-filled lookup in Deep mode.', 'Review DNS, certificate, network, redirect, and page-identity evidence.', 'Mark the step reviewed, or partial if important sources did not settle.'], true),
79
+ stage('bulk', 'bulk', 'Compare relationships', 'Use a bounded candidate set to inspect nameserver, IP, origin, favicon, tracker, and certificate relationships.', 'Explainable relationship rows with source observations, method, completeness, and truncation limits.', 'Bulk collection makes bounded analysis requests according to the selected candidate count and depth.', 'Prepare a focused candidate set; shared infrastructure alone is not proof of common control.', 'Useful pivots are reviewed with their limitations, or the stage is marked partial when sources are incomplete.', ['The starting domain is pre-filled. Add only domains that are plausible comparison peers.', 'Run a bounded scan and review the Relationships section below the table.', 'Treat shared infrastructure as a pivot, not attribution, then mark the step reviewed.'], true),
80
+ stage('monitor', 'monitor', 'Retain defensible pivots', 'Review the bounded domain set carried from Bulk and attach only defensible pivots to cases or campaigns without asserting ownership.', 'A bounded review queue plus analyst cases or campaign membership linked to retained source evidence.', 'Local case and campaign edits make no request. Any rescan is a separate explicit action.', 'Keep directly observed relationships separate from analyst conclusions.', 'Only defensible pivots are retained, or retention is explicitly skipped.', ['Review the domains carried from Bulk and open only the cases you intend to retain.', 'Record only relationships you reviewed, including their limitations.', 'Mark this step reviewed, or skip it if nothing is defensible enough to retain.'], false),
81
+ ]),
82
+ }),
83
+ Object.freeze({
84
+ id: 'new_domain_triage',
85
+ label: 'New-domain triage',
86
+ summary: 'Collect a domain, compare it with a focused peer set when useful, and record a reviewable disposition.',
87
+ targetLabel: 'Domain',
88
+ stages: Object.freeze([
89
+ stage('lookup', 'lookup', 'Collect domain evidence', 'Start with separately attributed registry and network evidence for the domain under review.', 'Authority-aware availability plus supported registry, DNS, certificate, HTTP, page, and threat-source observations.', 'Fast and deep Lookup have different request budgets. Collection starts only from the tool action you choose.', 'Confirm the domain is in scope and select the appropriate lookup depth.', 'Available evidence and explicit source failures are reviewed without treating a miss as safety.', ['Confirm the pre-filled domain and choose Fast or Deep.', 'Run the lookup and review source states before interpreting the result.', 'Mark the step reviewed, or partial if important evidence did not settle.'], true),
90
+ stage('bulk', 'bulk', 'Compare focused peers', 'Optionally compare the domain with a small candidate set to expose relative signals and shared infrastructure.', 'Bounded peer results and relationship evidence with explainable factors.', 'Bulk makes bounded analysis requests according to candidate count and selected depth.', 'Use only a focused, relevant peer set; this stage may be skipped when comparison adds no value.', 'Relevant differences are reviewed, or the stage is marked skipped or partial with the reason retained outside this compact record.', ['The investigated domain is pre-filled. Add only relevant comparison domains.', 'Run a bounded scan and compare registration, activity, Risk factors, and relationships.', 'Mark the step reviewed, partial, or skip it when peer comparison adds no value.'], true),
91
+ stage('monitor', 'monitor', 'Record disposition', 'Review the bounded domain set carried from Bulk and create or update only the cases that need an analyst decision or follow-up plan.', 'A bounded review queue plus case records with dispositions, notes, evidence history, and optional monitoring intent.', 'Local case editing makes no request. Rescans and hosted-monitor changes remain separate explicit actions.', 'Review source provenance, limitations, and any scoring explanation before deciding.', 'The analyst records the required dispositions or explicitly skips retention; the recipe never decides automatically.', ['Review the domains carried from Bulk and open only the cases that need retention.', 'Record a disposition and any concise evidence-based follow-up for each retained case.', 'Mark the step reviewed when the retained cases reflect your decision.'], false),
92
+ ]),
93
+ }),
94
+ ]);
95
+ function record(value) {
96
+ return value && typeof value === 'object' && !Array.isArray(value) ? value : null;
97
+ }
98
+ function timestamp(value) {
99
+ if (typeof value !== 'string' || value.length > MAX_INVESTIGATION_GUIDE_TIMESTAMP_LENGTH || CONTROL_RE.test(value))
100
+ return '';
101
+ const parsed = Date.parse(value);
102
+ return Number.isFinite(parsed) ? new Date(parsed).toISOString() : '';
103
+ }
104
+ function nullableTimestamp(value) {
105
+ if (value === null || value === undefined || value === '')
106
+ return null;
107
+ return timestamp(value) || null;
108
+ }
109
+ function boundedTemplateText(value, maximum, fallback = '') {
110
+ if (typeof value !== 'string' || value.length > maximum * 4 || CONTROL_RE.test(value))
111
+ return fallback;
112
+ return value.replace(/\s+/gu, ' ').trim().slice(0, maximum).trim() || fallback;
113
+ }
114
+ function boundedReviewNote(value) {
115
+ if (typeof value !== 'string' || value.length > MAX_INVESTIGATION_GUIDE_REVIEW_NOTE_LENGTH * 4)
116
+ return null;
117
+ const normalized = value.replace(/[\u0000-\u001f\u007f]+/gu, ' ').replace(/\s+/gu, ' ').trim();
118
+ return normalized ? normalized.slice(0, MAX_INVESTIGATION_GUIDE_REVIEW_NOTE_LENGTH).trim() || null : null;
119
+ }
120
+ function templateInstructions(value, fallback) {
121
+ if (!Array.isArray(value))
122
+ return fallback;
123
+ const instructions = value
124
+ .slice(0, 6)
125
+ .map((item) => boundedTemplateText(item, 240))
126
+ .filter(Boolean);
127
+ return instructions.length ? instructions : fallback;
128
+ }
129
+ export function normalizeInvestigationGuideTemplateSnapshot(value, requiredRecipeId = null) {
130
+ const input = record(value);
131
+ const id = typeof input?.id === 'string' && SAFE_TEMPLATE_ID_RE.test(input.id) ? input.id : '';
132
+ const recipe = investigationGuideRecipe(input?.recipeId);
133
+ const label = boundedTemplateText(input?.label, 80);
134
+ if (!id || !recipe || !label || (requiredRecipeId && recipe.id !== requiredRecipeId))
135
+ return null;
136
+ const supplied = new Map();
137
+ for (const candidate of (Array.isArray(input?.stages) ? input.stages : []).slice(0, recipe.stages.length * 2)) {
138
+ const item = record(candidate);
139
+ if (item && typeof item.id === 'string' && !supplied.has(item.id))
140
+ supplied.set(item.id, item);
141
+ }
142
+ const stages = recipe.stages.flatMap((base) => {
143
+ const item = supplied.get(base.id);
144
+ if (!item || item.enabled === false)
145
+ return [];
146
+ return [{
147
+ ...base,
148
+ label: boundedTemplateText(item.label, 100, base.label),
149
+ detail: boundedTemplateText(item.detail, 400, base.detail),
150
+ expectedEvidence: boundedTemplateText(item.expectedEvidence, 500, base.expectedEvidence),
151
+ completionCriteria: boundedTemplateText(item.completionCriteria, 500, base.completionCriteria),
152
+ instructions: templateInstructions(item.instructions, base.instructions),
153
+ requiresApproval: base.requiresApproval || item.requiresApproval === true,
154
+ }];
155
+ });
156
+ if (!stages.length)
157
+ return null;
158
+ return {
159
+ id,
160
+ label,
161
+ summary: boundedTemplateText(input?.summary, 400, recipe.summary),
162
+ recipeId: recipe.id,
163
+ stages,
164
+ };
165
+ }
166
+ export function investigationGuideStagesForGuide(value) {
167
+ const guide = record(value);
168
+ const recipe = investigationGuideRecipe(guide?.recipeId);
169
+ if (!recipe)
170
+ return [];
171
+ return normalizeInvestigationGuideTemplateSnapshot(guide?.template, recipe.id)?.stages || recipe.stages;
172
+ }
173
+ function investigationGuideStageForGuide(value, stageId) {
174
+ return typeof stageId === 'string'
175
+ ? investigationGuideStagesForGuide(value).find((candidate) => candidate.id === stageId) || null
176
+ : null;
177
+ }
178
+ export function investigationGuideStageForGuidePath(value, pathname) {
179
+ if (typeof pathname !== 'string')
180
+ return null;
181
+ return investigationGuideStagesForGuide(value)
182
+ .find((candidate) => pathname === candidate.path || pathname.startsWith(`${candidate.path}/`)) || null;
183
+ }
184
+ function guideStatus(value) {
185
+ return typeof value === 'string' && GUIDE_STATUSES.has(value)
186
+ ? value
187
+ : 'active';
188
+ }
189
+ function guideOutcome(value) {
190
+ return typeof value === 'string' && GUIDE_OUTCOMES.has(value)
191
+ ? value
192
+ : 'pending';
193
+ }
194
+ /**
195
+ * The launcher accepts one bare DNS hostname only. Unlike evidence-import
196
+ * normalization, it does not silently strip a URL, path, port, or userinfo.
197
+ */
198
+ export function normalizeInvestigationGuideDomain(value) {
199
+ if (typeof value !== 'string' || value.length > MAX_INVESTIGATION_GUIDE_DOMAIN_LENGTH
200
+ || /[\x00-\x1f\x7f\s\\/%@:?#]/u.test(value))
201
+ return '';
202
+ const trimmed = value.trim();
203
+ if (!trimmed)
204
+ return '';
205
+ return normalizeDomain(trimmed);
206
+ }
207
+ export function investigationGuideRecipe(value) {
208
+ return typeof value === 'string'
209
+ ? INVESTIGATION_RECIPES.find((recipe) => recipe.id === value) || null
210
+ : null;
211
+ }
212
+ export function investigationGuideStagesForRecipe(value) {
213
+ return investigationGuideRecipe(value)?.stages || [];
214
+ }
215
+ export function investigationGuideStage(value, recipeId = 'new_domain_triage') {
216
+ return typeof value === 'string'
217
+ ? investigationGuideStagesForRecipe(recipeId).find((candidate) => candidate.id === value) || null
218
+ : null;
219
+ }
220
+ export function investigationGuideStageForPath(pathname, recipeId = 'new_domain_triage') {
221
+ if (typeof pathname !== 'string')
222
+ return null;
223
+ return investigationGuideStagesForRecipe(recipeId)
224
+ .find((candidate) => pathname === candidate.path || pathname.startsWith(`${candidate.path}/`)) || null;
225
+ }
226
+ export function investigationGuideHref(stageId, domain, recipeId = 'new_domain_triage', focusDomain = null) {
227
+ const stageDefinition = investigationGuideStage(stageId, recipeId);
228
+ const normalized = normalizeInvestigationGuideDomain(domain);
229
+ const normalizedFocus = normalizeInvestigationGuideDomain(focusDomain);
230
+ if (!stageDefinition || !normalized)
231
+ return '/dashboard';
232
+ const workingDomain = normalizedFocus || normalized;
233
+ if (stageDefinition.workspace === 'lookup') {
234
+ if (recipeId === 'brand_sweep' && !normalizedFocus)
235
+ return '/bulk#results';
236
+ return `/lookup?q=${encodeURIComponent(workingDomain)}&depth=deep#query`;
237
+ }
238
+ if (stageDefinition.workspace === 'discover')
239
+ return `/discover?q=${encodeURIComponent(registrableGuideDomain(normalized))}#discovery-seed`;
240
+ if (stageDefinition.workspace === 'bulk')
241
+ return `/bulk?investigation=${encodeURIComponent(normalized)}#domains`;
242
+ if (stageDefinition.workspace === 'monitor')
243
+ return `/monitor?view=cases&investigation=1&domain=${encodeURIComponent(workingDomain)}#case-review-queue`;
244
+ if (stageDefinition.workspace === 'brands')
245
+ return `/brands?new=1&domain=${encodeURIComponent(normalized)}#official-domains`;
246
+ return stageDefinition.path;
247
+ }
248
+ function createStageProgress(stageDefinition, now) {
249
+ return { id: stageDefinition.id, outcome: 'pending', approvedAt: null, openedAt: null, reviewNote: null, updatedAt: now };
250
+ }
251
+ export function createInvestigationGuide(domain, recipeId = 'new_domain_triage', now = new Date().toISOString(), templateValue = null) {
252
+ const normalized = normalizeInvestigationGuideDomain(domain);
253
+ const recipe = investigationGuideRecipe(recipeId);
254
+ const createdAt = timestamp(now);
255
+ if (!normalized || !recipe || !createdAt)
256
+ return null;
257
+ const template = templateValue === null || templateValue === undefined
258
+ ? null
259
+ : normalizeInvestigationGuideTemplateSnapshot(templateValue, recipe.id);
260
+ if (templateValue !== null && templateValue !== undefined && !template)
261
+ return null;
262
+ const stages = template?.stages || recipe.stages;
263
+ return {
264
+ version: INVESTIGATION_GUIDE_VERSION,
265
+ recipeId: recipe.id,
266
+ template,
267
+ domain: normalized,
268
+ focusDomain: null,
269
+ reviewDomains: recipe.id === 'brand_sweep' ? [] : [normalized],
270
+ reviewDomainsTruncated: false,
271
+ status: 'active',
272
+ createdAt,
273
+ updatedAt: createdAt,
274
+ stages: stages.map((stageDefinition) => createStageProgress(stageDefinition, createdAt)),
275
+ };
276
+ }
277
+ function parseLegacyGuide(input) {
278
+ const domain = normalizeInvestigationGuideDomain(input.domain);
279
+ const createdAt = timestamp(input.createdAt);
280
+ const updatedAt = timestamp(input.updatedAt);
281
+ const recipe = investigationGuideRecipe('new_domain_triage');
282
+ if (!domain || !createdAt || !updatedAt || !recipe)
283
+ return null;
284
+ const opened = new Set((Array.isArray(input.visitedStages) ? input.visitedStages : [])
285
+ .slice(0, recipe.stages.length * 2)
286
+ .filter((value) => typeof value === 'string'));
287
+ return {
288
+ version: INVESTIGATION_GUIDE_VERSION,
289
+ recipeId: recipe.id,
290
+ template: null,
291
+ domain,
292
+ focusDomain: null,
293
+ reviewDomains: [domain],
294
+ reviewDomainsTruncated: false,
295
+ status: 'active',
296
+ createdAt,
297
+ updatedAt,
298
+ stages: recipe.stages.map((stageDefinition) => ({
299
+ ...createStageProgress(stageDefinition, updatedAt),
300
+ openedAt: opened.has(stageDefinition.id) ? updatedAt : null,
301
+ })),
302
+ };
303
+ }
304
+ export function parseInvestigationGuide(value) {
305
+ const input = record(value);
306
+ if (!input)
307
+ return null;
308
+ if (input.version === INVESTIGATION_GUIDE_LEGACY_VERSION)
309
+ return parseLegacyGuide(input);
310
+ if (!INVESTIGATION_GUIDE_SUPPORTED_VERSIONS.includes(input.version))
311
+ return null;
312
+ const recipe = investigationGuideRecipe(input.recipeId);
313
+ const domain = normalizeInvestigationGuideDomain(input.domain);
314
+ const createdAt = timestamp(input.createdAt);
315
+ const updatedAt = timestamp(input.updatedAt);
316
+ if (!recipe || !domain || !createdAt || !updatedAt)
317
+ return null;
318
+ const supportsTemplate = input.version === 3 || input.version === INVESTIGATION_GUIDE_VERSION;
319
+ const template = supportsTemplate && input.template !== null && input.template !== undefined
320
+ ? normalizeInvestigationGuideTemplateSnapshot(input.template, recipe.id)
321
+ : null;
322
+ if (supportsTemplate && input.template !== null && input.template !== undefined && !template)
323
+ return null;
324
+ const stageDefinitions = template?.stages || recipe.stages;
325
+ const normalizedReview = normalizeReviewDomains(input.reviewDomains);
326
+ const reviewDomains = normalizedReview.domains.length
327
+ ? normalizedReview.domains
328
+ : recipe.id === 'brand_sweep' ? [] : [domain];
329
+ const supplied = new Map();
330
+ for (const candidate of (Array.isArray(input.stages) ? input.stages : []).slice(0, stageDefinitions.length * 2)) {
331
+ const item = record(candidate);
332
+ if (item && typeof item.id === 'string' && !supplied.has(item.id))
333
+ supplied.set(item.id, item);
334
+ }
335
+ return {
336
+ version: INVESTIGATION_GUIDE_VERSION,
337
+ recipeId: recipe.id,
338
+ template,
339
+ domain,
340
+ focusDomain: recipe.id === 'brand_sweep' ? normalizeInvestigationGuideDomain(input.focusDomain) || null : null,
341
+ reviewDomains,
342
+ reviewDomainsTruncated: normalizedReview.truncated || input.reviewDomainsTruncated === true,
343
+ status: guideStatus(input.status),
344
+ createdAt,
345
+ updatedAt,
346
+ stages: stageDefinitions.map((stageDefinition) => {
347
+ const item = supplied.get(stageDefinition.id);
348
+ return {
349
+ id: stageDefinition.id,
350
+ outcome: guideOutcome(item?.outcome),
351
+ approvedAt: nullableTimestamp(item?.approvedAt),
352
+ openedAt: nullableTimestamp(item?.openedAt),
353
+ reviewNote: input.version === INVESTIGATION_GUIDE_VERSION ? boundedReviewNote(item?.reviewNote) : null,
354
+ updatedAt: timestamp(item?.updatedAt) || updatedAt,
355
+ };
356
+ }),
357
+ };
358
+ }
359
+ export function setInvestigationGuideFocusDomain(value, domain, now = new Date().toISOString()) {
360
+ const guide = parseInvestigationGuide(value);
361
+ const focusDomain = normalizeInvestigationGuideDomain(domain);
362
+ const updatedAt = timestamp(now);
363
+ if (!guide || guide.recipeId !== 'brand_sweep' || !focusDomain || !updatedAt || guide.status === 'paused')
364
+ return guide;
365
+ if (guide.focusDomain === focusDomain)
366
+ return guide;
367
+ return { ...guide, focusDomain, updatedAt };
368
+ }
369
+ export function setInvestigationGuideReviewDomains(value, domains, now = new Date().toISOString()) {
370
+ const guide = parseInvestigationGuide(value);
371
+ const updatedAt = timestamp(now);
372
+ if (!guide || !updatedAt || guide.status === 'paused')
373
+ return guide;
374
+ const reviewDomains = Array.isArray(domains) ? domains : [];
375
+ const normalized = normalizeReviewDomains(guide.recipeId === 'brand_sweep'
376
+ ? reviewDomains
377
+ : [guide.domain, ...reviewDomains]);
378
+ const reviewDomainsTruncated = normalized.truncated;
379
+ if (guide.reviewDomainsTruncated === reviewDomainsTruncated
380
+ && guide.reviewDomains.length === normalized.domains.length
381
+ && guide.reviewDomains.every((domain, index) => domain === normalized.domains[index]))
382
+ return guide;
383
+ return { ...guide, reviewDomains: normalized.domains, reviewDomainsTruncated, updatedAt };
384
+ }
385
+ function updateStage(value, stageId, now, updater) {
386
+ const guide = parseInvestigationGuide(value);
387
+ const updatedAt = timestamp(now);
388
+ const stageDefinition = investigationGuideStageForGuide(guide, stageId);
389
+ if (!guide || !updatedAt || !stageDefinition)
390
+ return guide;
391
+ return {
392
+ ...guide,
393
+ updatedAt,
394
+ stages: guide.stages.map((progress) => progress.id === stageDefinition.id ? updater(progress, updatedAt) : progress),
395
+ };
396
+ }
397
+ export function visitInvestigationGuide(value, pathname, now = new Date().toISOString()) {
398
+ const guide = parseInvestigationGuide(value);
399
+ const stageDefinition = investigationGuideStageForGuidePath(guide, pathname);
400
+ if (!guide || guide.status === 'paused' || !stageDefinition)
401
+ return guide;
402
+ const progress = guide.stages.find((item) => item.id === stageDefinition.id);
403
+ if (stageDefinition.requiresApproval && !progress?.approvedAt)
404
+ return guide;
405
+ if (progress?.openedAt)
406
+ return guide;
407
+ return updateStage(guide, stageDefinition.id, now, (current, updatedAt) => ({ ...current, openedAt: updatedAt, updatedAt }));
408
+ }
409
+ export function approveInvestigationGuideStage(value, stageId, now = new Date().toISOString()) {
410
+ const guide = parseInvestigationGuide(value);
411
+ const stageDefinition = investigationGuideStageForGuide(guide, stageId);
412
+ if (!guide || guide.status === 'paused' || !stageDefinition?.requiresApproval)
413
+ return guide;
414
+ const progress = guide.stages.find((item) => item.id === stageDefinition.id);
415
+ if (progress?.approvedAt)
416
+ return guide;
417
+ return updateStage(guide, stageDefinition.id, now, (current, updatedAt) => ({ ...current, approvedAt: updatedAt, updatedAt }));
418
+ }
419
+ export function setInvestigationGuideStageOutcome(value, stageId, outcome, now = new Date().toISOString(), reviewNote = null) {
420
+ const guide = parseInvestigationGuide(value);
421
+ const normalizedOutcome = guideOutcome(outcome);
422
+ const normalizedReviewNote = boundedReviewNote(reviewNote);
423
+ const stageDefinition = investigationGuideStageForGuide(guide, stageId);
424
+ if (!guide || guide.status === 'paused' || !stageDefinition)
425
+ return guide;
426
+ const progress = guide.stages.find((item) => item.id === stageDefinition.id);
427
+ if (!progress || ((normalizedOutcome === 'complete' || normalizedOutcome === 'partial') && !progress.openedAt))
428
+ return guide;
429
+ if ((normalizedOutcome === 'partial' || normalizedOutcome === 'skipped') && !normalizedReviewNote)
430
+ return guide;
431
+ const nextReviewNote = normalizedOutcome === 'pending' ? null : normalizedReviewNote;
432
+ if (progress.outcome === normalizedOutcome && progress.reviewNote === nextReviewNote)
433
+ return guide;
434
+ return updateStage(guide, stageDefinition.id, now, (current, updatedAt) => ({
435
+ ...current,
436
+ outcome: normalizedOutcome,
437
+ reviewNote: nextReviewNote,
438
+ updatedAt,
439
+ }));
440
+ }
441
+ export function setInvestigationGuideStatus(value, status, now = new Date().toISOString()) {
442
+ const guide = parseInvestigationGuide(value);
443
+ const updatedAt = timestamp(now);
444
+ if (!guide || !updatedAt || typeof status !== 'string' || !GUIDE_STATUSES.has(status))
445
+ return guide;
446
+ if (guide.status === status)
447
+ return guide;
448
+ return { ...guide, status: status, updatedAt };
449
+ }
450
+ export function restartInvestigationGuide(value, now = new Date().toISOString()) {
451
+ const guide = parseInvestigationGuide(value);
452
+ return guide ? createInvestigationGuide(guide.domain, guide.recipeId, now, guide.template) : null;
453
+ }
454
+ export function buildInvestigationGuideSummary(value, generatedAt = new Date().toISOString()) {
455
+ const guide = parseInvestigationGuide(value);
456
+ const recipe = investigationGuideRecipe(guide?.recipeId);
457
+ const normalizedGeneratedAt = timestamp(generatedAt);
458
+ if (!guide || !recipe || !normalizedGeneratedAt)
459
+ return null;
460
+ const stages = [];
461
+ for (const stageDefinition of investigationGuideStagesForGuide(guide)) {
462
+ const progress = guide.stages.find((candidate) => candidate.id === stageDefinition.id);
463
+ if (!progress)
464
+ return null;
465
+ stages.push({
466
+ id: stageDefinition.id,
467
+ workspace: stageDefinition.workspace,
468
+ outcome: progress.outcome,
469
+ approved: progress.approvedAt !== null,
470
+ opened: progress.openedAt !== null,
471
+ reviewNote: progress.reviewNote,
472
+ updatedAt: progress.updatedAt,
473
+ });
474
+ }
475
+ return {
476
+ schema: INVESTIGATION_GUIDE_EXPORT_SCHEMA,
477
+ version: INVESTIGATION_GUIDE_EXPORT_VERSION,
478
+ generatedAt: normalizedGeneratedAt,
479
+ recipe: { id: recipe.id, label: recipe.label },
480
+ template: guide.template ? { id: guide.template.id, label: guide.template.label } : null,
481
+ target: { type: 'domain', value: guide.domain },
482
+ status: guide.status,
483
+ createdAt: guide.createdAt,
484
+ updatedAt: guide.updatedAt,
485
+ stages,
486
+ limitations: [
487
+ 'This compact summary records analyst-controlled recipe progress and bounded stage-review notes only. It contains no raw evidence, case notes, credentials, provider responses, or scan results.',
488
+ 'Opened, approved, complete, partial, and skipped states are analyst workflow markers, not findings or claims about the target.',
489
+ 'The recipe never starts collection, submits a target, exports evidence, or changes a case disposition automatically.',
490
+ ],
491
+ };
492
+ }
493
+ export function investigationGuideSummaryFilename(value, generatedAt = new Date().toISOString()) {
494
+ const guide = parseInvestigationGuide(value);
495
+ const normalizedGeneratedAt = timestamp(generatedAt);
496
+ if (!guide || !normalizedGeneratedAt)
497
+ return 'whoisleuth-investigation-recipe.json';
498
+ const safeDomain = guide.domain.replace(/[^a-z0-9.-]+/giu, '-').replace(/\.{2,}/gu, '.').slice(0, 120) || 'domain';
499
+ return `whoisleuth-recipe-${safeDomain}-${normalizedGeneratedAt.replace(/[:.]/gu, '-')}.json`;
500
+ }
@@ -0,0 +1,144 @@
1
+ import { INVESTIGATION_RECIPES, normalizeInvestigationGuideTemplateSnapshot, } from "./investigation-guide.js";
2
+ export const INVESTIGATION_TEMPLATE_SCHEMA = 'whoisleuth.investigation-templates';
3
+ export const INVESTIGATION_TEMPLATE_VERSION = 1;
4
+ export const MAX_INVESTIGATION_TEMPLATES = 20;
5
+ export const MAX_INVESTIGATION_TEMPLATE_STORE_BYTES = 256 * 1024;
6
+ export const MAX_INVESTIGATION_TEMPLATE_IMPORT_BYTES = 384 * 1024;
7
+ const CONTROL_RE = /[\x00-\x1f\x7f]/u;
8
+ function record(value) {
9
+ return value && typeof value === 'object' && !Array.isArray(value) ? value : null;
10
+ }
11
+ function timestamp(value) {
12
+ if (typeof value !== 'string' || value.length > 64 || CONTROL_RE.test(value))
13
+ return '';
14
+ const parsed = Date.parse(value);
15
+ return Number.isFinite(parsed) ? new Date(parsed).toISOString() : '';
16
+ }
17
+ function byteLength(value) {
18
+ return new TextEncoder().encode(value).byteLength;
19
+ }
20
+ function template(raw) {
21
+ const value = record(raw);
22
+ const snapshot = normalizeInvestigationGuideTemplateSnapshot(value);
23
+ const createdAt = timestamp(value?.createdAt);
24
+ const updatedAt = timestamp(value?.updatedAt);
25
+ return snapshot && createdAt && updatedAt
26
+ ? { ...snapshot, createdAt, updatedAt }
27
+ : null;
28
+ }
29
+ export function normalizeInvestigationTemplate(raw) {
30
+ return template(raw);
31
+ }
32
+ export function normalizeInvestigationTemplateStore(raw) {
33
+ const value = record(raw);
34
+ if (value?.schema === INVESTIGATION_TEMPLATE_SCHEMA
35
+ && Number(value.version) > INVESTIGATION_TEMPLATE_VERSION) {
36
+ throw new Error('This investigation-template collection uses a newer schema and cannot be read safely.');
37
+ }
38
+ const source = Array.isArray(raw) ? raw : Array.isArray(value?.templates) ? value.templates : [];
39
+ const templates = [];
40
+ const seen = new Set();
41
+ for (const candidate of source.slice(0, MAX_INVESTIGATION_TEMPLATES * 2)) {
42
+ const normalized = template(candidate);
43
+ if (!normalized || seen.has(normalized.id))
44
+ continue;
45
+ seen.add(normalized.id);
46
+ templates.push(normalized);
47
+ }
48
+ templates.sort((left, right) => right.updatedAt.localeCompare(left.updatedAt));
49
+ return {
50
+ schema: INVESTIGATION_TEMPLATE_SCHEMA,
51
+ version: INVESTIGATION_TEMPLATE_VERSION,
52
+ templates: templates.slice(0, MAX_INVESTIGATION_TEMPLATES),
53
+ };
54
+ }
55
+ export function investigationTemplateStoreVersion(raw) {
56
+ const value = record(raw);
57
+ return Number.isSafeInteger(value?.version) ? Number(value?.version) : INVESTIGATION_TEMPLATE_VERSION;
58
+ }
59
+ export function serializeInvestigationTemplateStore(raw) {
60
+ const serialized = JSON.stringify(normalizeInvestigationTemplateStore(raw));
61
+ if (byteLength(serialized) > MAX_INVESTIGATION_TEMPLATE_STORE_BYTES) {
62
+ throw new Error('Investigation templates exceed the 256 KiB browser-local limit.');
63
+ }
64
+ return serialized;
65
+ }
66
+ export function createInvestigationTemplate(raw, options = {}) {
67
+ const value = record(raw);
68
+ const now = timestamp(options.now ?? new Date().toISOString());
69
+ const recipeId = value?.recipeId;
70
+ const recipe = INVESTIGATION_RECIPES.find((candidate) => candidate.id === recipeId);
71
+ const id = typeof value?.id === 'string' && value.id
72
+ ? value.id
73
+ : (options.makeId ?? (() => crypto.randomUUID()))();
74
+ const createdAt = timestamp(value?.createdAt) || now;
75
+ const candidate = normalizeInvestigationTemplate({
76
+ ...value,
77
+ id,
78
+ recipeId: recipe?.id,
79
+ createdAt,
80
+ updatedAt: now,
81
+ });
82
+ if (!candidate)
83
+ throw new Error('The investigation template is incomplete or invalid.');
84
+ return candidate;
85
+ }
86
+ export function saveInvestigationTemplate(localRaw, candidateRaw) {
87
+ const candidate = normalizeInvestigationTemplate(candidateRaw);
88
+ if (!candidate)
89
+ throw new Error('The investigation template is incomplete or invalid.');
90
+ const local = normalizeInvestigationTemplateStore(localRaw).templates;
91
+ return normalizeInvestigationTemplateStore([
92
+ candidate,
93
+ ...local.filter((item) => item.id !== candidate.id),
94
+ ]).templates;
95
+ }
96
+ export function deleteInvestigationTemplate(localRaw, id) {
97
+ return normalizeInvestigationTemplateStore(localRaw).templates.filter((item) => item.id !== id);
98
+ }
99
+ export function buildInvestigationTemplateExport(raw, generatedAt = new Date().toISOString()) {
100
+ return {
101
+ ...normalizeInvestigationTemplateStore(raw),
102
+ generatedAt: timestamp(generatedAt) || new Date().toISOString(),
103
+ limitations: [
104
+ 'Templates contain allowlisted WHOISleuth tool steps and analyst-authored guidance only.',
105
+ 'A template cannot run code, start collection, submit evidence, change a case, or bypass a required request approval gate.',
106
+ 'Workflow outcomes are analyst progress markers and are not findings about a target.',
107
+ ],
108
+ };
109
+ }
110
+ function strictImport(raw) {
111
+ const value = record(raw);
112
+ if (value?.schema !== INVESTIGATION_TEMPLATE_SCHEMA || !Number.isSafeInteger(value.version)) {
113
+ throw new Error('Expected a versioned WHOISleuth investigation-template export.');
114
+ }
115
+ if (Number(value.version) > INVESTIGATION_TEMPLATE_VERSION) {
116
+ throw new Error('This investigation-template export uses a newer schema and cannot be imported safely.');
117
+ }
118
+ if (value.version !== INVESTIGATION_TEMPLATE_VERSION) {
119
+ throw new Error(`Expected investigation-template schema ${INVESTIGATION_TEMPLATE_VERSION}.`);
120
+ }
121
+ return normalizeInvestigationTemplateStore(value).templates;
122
+ }
123
+ export function mergeInvestigationTemplates(localRaw, incomingRaw) {
124
+ const local = normalizeInvestigationTemplateStore(localRaw).templates;
125
+ const incoming = strictImport(incomingRaw);
126
+ const byId = new Map(local.map((item) => [item.id, item]));
127
+ let added = 0;
128
+ let updated = 0;
129
+ for (const item of incoming) {
130
+ if (byId.has(item.id))
131
+ updated += 1;
132
+ else
133
+ added += 1;
134
+ byId.set(item.id, item);
135
+ }
136
+ const templates = normalizeInvestigationTemplateStore([...byId.values()]).templates;
137
+ return {
138
+ templates,
139
+ added,
140
+ updated,
141
+ skipped: Math.max(0, incoming.length - added - updated),
142
+ pruned: Math.max(0, byId.size - templates.length),
143
+ };
144
+ }