@slicedearth/whoisleuth-cli 2.2.0 → 2.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/NOTICE +6 -0
- package/README.md +20 -15
- package/bin/whoisleuth.mjs +8 -1
- package/cli/archive-content-digest.mjs +14 -0
- package/cli/archive-inspect.mjs +20 -53
- package/cli/arguments.mjs +454 -1748
- package/cli/artifact-validation/case-response.mjs +66 -35
- package/cli/artifact-validation/investigation-capsule.mjs +52 -31
- package/cli/artifact-validation/lookup-evidence.mjs +12 -6
- package/cli/artifact-validation/signed-review.mjs +112 -72
- package/cli/artifact-validation/structure-primitives.mjs +1 -105
- package/cli/artifact-verify.mjs +90 -24
- package/cli/assurance-command-runner.mjs +39 -4
- package/cli/bagit-folder.mjs +57 -0
- package/cli/bagit-review.mjs +23 -0
- package/cli/bounded-json.mjs +1 -1
- package/cli/bulk-checkpoint.mjs +34 -3
- package/cli/bulk-command-runner.mjs +2 -0
- package/cli/bulk-output.mjs +29 -3
- package/cli/case-command.mjs +195 -0
- package/cli/case-pack.mjs +66 -111
- package/cli/command-argument-grammar.mjs +215 -0
- package/cli/command-reference.mjs +342 -285
- package/cli/completion.mjs +303 -80
- package/cli/config-profile.mjs +7 -5
- package/cli/ct-event-intake.mjs +12 -12
- package/cli/discover.mjs +4 -3
- package/cli/discovery-observation-snapshot.mjs +59 -18
- package/cli/discovery-scan-command-runner.mjs +21 -0
- package/cli/discovery-scan.mjs +13 -4
- package/cli/discovery-workflow.mjs +1 -1
- package/cli/domain-control-monitor.mjs +50 -20
- package/cli/domain-control-observations.mjs +149 -102
- package/cli/errors.mjs +33 -1
- package/cli/evidence-command-runner.mjs +16 -5
- package/cli/evidence-signing.mjs +2 -2
- package/cli/external-observation-mapping.mjs +7 -8
- package/cli/formatters/terminal-lookup.mjs +18 -46
- package/cli/formatters/terminal-shared.mjs +5 -3
- package/cli/formatters/terminal-technology.mjs +114 -0
- package/cli/inline-command-families.mjs +2 -0
- package/cli/input.mjs +116 -0
- package/cli/interactive-launcher.mjs +2 -160
- package/cli/interchange-report.mjs +18 -8
- package/cli/investigation-artifacts.mjs +176 -0
- package/cli/investigation-checkpoint.mjs +10 -0
- package/cli/investigation-folder.mjs +114 -0
- package/cli/investigation-manifest.mjs +1 -112
- package/cli/investigation-package-review.mjs +103 -0
- package/cli/investigation-plan.mjs +32 -19
- package/cli/investigation-run.mjs +188 -33
- package/cli/local-document-checkpoint.mjs +170 -0
- package/cli/lookalike-calibration.mjs +7 -2
- package/cli/lookup-brief.mjs +4 -2
- package/cli/lookup-browser-view.mjs +1 -1
- package/cli/lookup-browser.mjs +5 -12
- package/cli/lookup-command-runner.mjs +17 -6
- package/cli/lookup-diff.mjs +37 -3
- package/cli/lookup-plan.mjs +11 -5
- package/cli/lookup-reconcile.mjs +10 -8
- package/cli/mail-header-review.mjs +467 -0
- package/cli/mail-review.mjs +66 -14
- package/cli/manual.mjs +1 -0
- package/cli/network-command-runner.mjs +2 -0
- package/cli/output-file.mjs +31 -5
- package/cli/registry-doctor.mjs +3 -1
- package/cli/registry-support.mjs +1 -1
- package/cli/retained-artifact-diff.mjs +2 -2
- package/cli/review-command-runner.mjs +70 -0
- package/cli/risk-calibration.mjs +19 -17
- package/cli/runner.mjs +62 -7
- package/cli/sharing-review.mjs +21 -34
- package/cli/signer-trust.mjs +104 -0
- package/cli/source-reliability.mjs +74 -83
- package/cli/terminal-input.mjs +168 -0
- package/cli/workflow-command-runner.mjs +68 -13
- package/docs/cli-reference.md +77 -24
- package/docs/cli.md +300 -25
- package/lib/abort.mjs +4 -1
- package/lib/availability.mjs +161 -112
- package/lib/bounded-file.mjs +3 -1
- package/lib/bounded-html-document.mjs +125 -0
- package/lib/bounded-json.mjs +51 -8
- package/lib/browser-library-profile.mjs +42 -51
- package/lib/browser-library-worker.mjs +112 -0
- package/lib/client-behavior-profile.mjs +3 -3
- package/lib/ct-search.mjs +11 -4
- package/lib/dns-convergence-review.mjs +1 -0
- package/lib/dns-delegation-health.mjs +24 -14
- package/lib/dns-inheritance-review.mjs +217 -0
- package/lib/dns-intelligence.mjs +73 -26
- package/lib/dns-mx.mjs +1 -1
- package/lib/dnssec-chain-validation.mjs +6 -0
- package/lib/dnssec-evidence-validation.mjs +11 -3
- package/lib/domain-change-packet.mjs +49 -16
- package/lib/domain-change-review.mjs +14 -8
- package/lib/domain-control-flight-recorder.mjs +221 -117
- package/lib/domain-control-manifest.mjs +129 -69
- package/lib/domain-posture-analysis.mjs +20 -7
- package/lib/domain-posture-parsers.mjs +201 -8
- package/lib/domain-posture.mjs +147 -29
- package/lib/evidence-export-privacy.mjs +561 -0
- package/lib/evidence-export.mjs +11 -543
- package/lib/evidence-report.mjs +5 -3
- package/lib/favicon.mjs +117 -41
- package/lib/generated/cisa-kev-catalog.mjs +12 -4
- package/lib/generated/registrar-standing-catalogue.mjs +7 -7
- package/lib/generated/retire-browser-catalog.mjs +2335 -940
- package/lib/hostname.mjs +2 -38
- package/lib/html-signals.mjs +111 -108
- package/lib/http-evidence-bounds.mjs +2 -1
- package/lib/http-intelligence.mjs +39 -26
- package/lib/local-geoip-evidence.mjs +19 -3
- package/lib/lookup-cache.mjs +34 -11
- package/lib/lookup-child-profile-contract.mjs +24 -15
- package/lib/lookup-diagnostics.mjs +24 -1
- package/lib/lookup-progress.mjs +30 -13
- package/lib/lookup-response-contract.mjs +1048 -0
- package/lib/lookup-response.mjs +8 -10
- package/lib/lookup-source-progress.mjs +40 -14
- package/lib/lookup.mjs +48 -27
- package/lib/nameserver-preflight-review.mjs +1 -0
- package/lib/observed-network-context.mjs +43 -27
- package/lib/outbound-request-bounds.mjs +4 -1
- package/lib/page-fingerprints.mjs +69 -133
- package/lib/page-role-profile.mjs +12 -8
- package/lib/perceptual-hash.mjs +43 -14
- package/lib/rdap-bootstrap.mjs +72 -22
- package/lib/rdap-capabilities.mjs +1 -1
- package/lib/rdap-client.mjs +15 -7
- package/lib/rdap-normalization.mjs +33 -18
- package/lib/rdap-search-workbench.mjs +1 -1
- package/lib/rdap-transport.mjs +47 -20
- package/lib/rdap-types.mjs +2 -1
- package/lib/rdap.mjs +18 -3
- package/lib/registrable-domain.mjs +21 -1
- package/lib/registrar-standing-contract.mjs +1 -0
- package/lib/registry-capabilities.mjs +5 -1
- package/lib/registry-capability-catalogue.mjs +2 -1
- package/lib/registry-dates.mjs +2 -190
- package/lib/registry-insights.mjs +85 -34
- package/lib/response-policy.mjs +86 -48
- package/lib/risk-calibration-summary.mjs +18 -6
- package/lib/rpki-evidence.mjs +8 -5
- package/lib/safe-fetch.mjs +54 -47
- package/lib/security-txt.mjs +42 -7
- package/lib/service-binding-dns.mjs +8 -0
- package/lib/smtp-transport-review.mjs +27 -15
- package/lib/sslbl-certificates.generated.mjs +136 -135
- package/lib/static-html-analysis.mjs +178 -151
- package/lib/structured-data-identity.mjs +1 -0
- package/lib/technology-markup.mjs +100 -0
- package/lib/threat-intelligence-manifests.mjs +11 -139
- package/lib/threat-intelligence-runtime.mjs +24 -52
- package/lib/threat-intelligence-targets.mjs +6 -34
- package/lib/threat-intelligence-types.mjs +39 -0
- package/lib/typosquat-generator.mjs +19 -15
- package/lib/urlhaus-intelligence.mjs +8 -3
- package/lib/urlscan-intelligence.mjs +1 -1
- package/lib/website-security-posture.mjs +30 -6
- package/lib/website-technology.mjs +153 -45
- package/lib/whois-authority.mjs +14 -2
- package/lib/whois-chain.mjs +9 -2
- package/lib/whois-common-formats.mjs +5 -5
- package/lib/whois-contracts.mjs +2 -1
- package/lib/whois-dialects.mjs +2 -2
- package/lib/whois-parser-support.mjs +8 -1
- package/lib/whois-transport.mjs +23 -7
- package/lib/zone-intent-review.mjs +16 -6
- package/package.json +2 -1
- package/packages/cases/case-attachment-model.mjs +83 -0
- package/packages/cases/case-evidence-model.mjs +156 -62
- package/packages/cases/case-export-input.mjs +53 -0
- package/packages/cases/case-migration-model.mjs +127 -44
- package/packages/cases/case-model.mjs +1 -0
- package/packages/cases/case-recheck-model.mjs +91 -0
- package/packages/cases/case-record-contracts.mjs +5 -20
- package/packages/cases/case-record-core.mjs +12 -22
- package/packages/cases/case-record-decisions.mjs +72 -0
- package/packages/cases/case-record-model.mjs +1 -0
- package/packages/cases/case-record-operations.mjs +95 -31
- package/packages/cases/case-record-projection.mjs +221 -0
- package/packages/cases/case-report.mjs +70 -54
- package/packages/cases/case-response-actions.mjs +654 -0
- package/packages/cases/case-response-model.mjs +64 -1115
- package/packages/cases/case-response-outcomes.mjs +317 -0
- package/packages/cases/case-response-packet-render.mjs +153 -0
- package/packages/cases/case-response-packet-types.mjs +1 -0
- package/packages/cases/case-response-packet-vocabulary.mjs +48 -0
- package/packages/cases/case-response-packet.mjs +74 -572
- package/packages/cases/case-response-records.mjs +94 -0
- package/packages/cases/case-response-review-inputs.mjs +371 -0
- package/packages/cases/case-response-values.mjs +85 -0
- package/packages/cases/case-review-package.mjs +45 -0
- package/packages/cases/case-selection.mjs +46 -0
- package/packages/cases/case-storage-model.mjs +31 -2
- package/packages/cases/response-route-freshness.mjs +16 -0
- package/packages/comparison/capture-context.mjs +61 -0
- package/packages/comparison/comparison-ledger-bulk.mjs +13 -11
- package/packages/comparison/page-similarity.mjs +5 -2
- package/packages/comparison/relationship-evidence.mjs +85 -8
- package/packages/comparison/relationship-provenance.mjs +93 -0
- package/packages/contracts/analyst-interchange.mjs +216 -0
- package/packages/contracts/analyst-review-state-contract.mjs +11 -0
- package/packages/contracts/analyst-review-state.mjs +5 -4
- package/packages/contracts/case-portability.mjs +366 -83
- package/packages/contracts/case-views-contract.mjs +12 -0
- package/packages/contracts/case-views.mjs +26 -0
- package/packages/contracts/cli-command-semantics.mjs +94 -0
- package/packages/contracts/cli-lookup.mjs +0 -1
- package/packages/contracts/domain-control-flight-recorder.mjs +80 -42
- package/packages/contracts/domain-control-manifest.mjs +127 -73
- package/packages/contracts/domain-control-monitor.mjs +48 -45
- package/packages/contracts/domain-control-review.mjs +179 -121
- package/packages/contracts/domain-name.mjs +21 -0
- package/packages/contracts/external-observation-interchange.mjs +2 -1
- package/packages/contracts/extracted-domain-lifecycle.mjs +11 -32
- package/packages/contracts/http-url.mjs +2 -0
- package/packages/contracts/investigation-package-limits.mjs +24 -0
- package/packages/contracts/investigation-portability.mjs +56 -27
- package/packages/contracts/investigation-run.mjs +16 -0
- package/packages/contracts/ip-address.mjs +34 -0
- package/packages/contracts/lookup-evidence.mjs +35 -0
- package/packages/contracts/offline-comparison.mjs +1 -2
- package/packages/contracts/page-fingerprints.mjs +15 -0
- package/packages/contracts/risk-calibration.mjs +15 -11
- package/packages/contracts/schema-compatibility.mjs +1 -1
- package/packages/contracts/schema-lifecycle.mjs +83 -153
- package/packages/contracts/selected-file-limits.mjs +6 -0
- package/packages/contracts/vulnerability-identifiers.mjs +5 -0
- package/packages/contracts/web-capture.mjs +10 -0
- package/packages/contracts/workspace-portability.mjs +183 -211
- package/packages/evidence/artifact-integrity.mjs +8 -2
- package/packages/evidence/artifact-structure.mjs +105 -0
- package/packages/evidence/decision-fact.mjs +4 -1
- package/packages/evidence/domain-control-runtime.mjs +201 -33
- package/packages/evidence/domain-posture-context.mjs +79 -0
- package/packages/evidence/image-regions.mjs +56 -0
- package/packages/evidence/latest-observations.mjs +28 -0
- package/packages/evidence/lookup-target.mjs +104 -0
- package/packages/evidence/observation.mjs +16 -1
- package/packages/evidence/passphrase-encryption.mjs +44 -0
- package/packages/evidence/record-identity.mjs +72 -0
- package/packages/evidence/registry-dates.mjs +190 -0
- package/packages/evidence/retained-file.mjs +69 -0
- package/packages/evidence/review-age.mjs +14 -0
- package/packages/interchange/bagit.mjs +342 -0
- package/packages/interchange/bounded-zip-extraction.mjs +278 -0
- package/packages/interchange/crc32.mjs +17 -0
- package/packages/interchange/external-findings-import.mjs +92 -51
- package/packages/interchange/web-capture-import.mjs +382 -0
- package/packages/investigation/bulk-mail-exposure.mjs +2 -1
- package/packages/investigation/investigation-bagit.mjs +14 -0
- package/packages/investigation/investigation-capsule.mjs +33 -5
- package/packages/investigation/investigation-manifest.mjs +200 -0
- package/packages/investigation/investigation-package-crypto.mjs +98 -0
- package/packages/investigation/investigation-package.mjs +200 -0
- package/packages/investigation/lookup-asset-graph-inputs.mjs +38 -0
- package/packages/investigation/lookup-asset-graph.mjs +193 -165
- package/packages/investigation/lookup-claim-readiness.mjs +18 -0
- package/packages/investigation/lookup-investigation-brief.mjs +3 -3
- package/packages/investigation/lookup-task-guidance.mjs +58 -0
- package/packages/monitoring/analyst-review-state.mjs +38 -90
- package/packages/workspace/brand-profile-model.mjs +276 -22
- package/packages/workspace/bulk-columns.mjs +17 -0
- package/packages/workspace/bulk-review-model.mjs +23 -11
- package/packages/workspace/bulk-session-model.mjs +100 -24
- package/packages/workspace/bulk-sort.mjs +1 -0
- package/packages/workspace/case-views.mjs +102 -0
- package/packages/workspace/detection-rule-model.mjs +70 -22
- package/packages/workspace/hostile-input.mjs +29 -8
- package/packages/workspace/investigation-template-model.mjs +19 -4
- package/packages/workspace/page-baseline.mjs +18 -15
- package/packages/workspace/relationship-observation-model.mjs +41 -23
- package/packages/workspace/watchlist-history.mjs +2 -2
- package/packages/workspace/watchlist-store.mjs +8 -2
- package/packages/workspace/website-snapshot-model.mjs +164 -26
- package/packages/workspace/workspace-archive-crypto.mjs +41 -61
- package/packages/workspace/workspace-archive.mjs +87 -21
- package/third-party-notices.txt +29 -2
- package/cli/evidence-command-arguments.mjs +0 -156
- package/packages/investigation/bulk-review-export.mjs +0 -63
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
// Pure, framework-neutral analyst-case records, evidence histories, bounded
|
|
2
2
|
// record normalization, and analyst updates.
|
|
3
|
-
import {
|
|
3
|
+
import { CASE_REVIEW_REASONS, CASE_SOURCES, DEFAULT_DISPOSITION, DEFAULT_SOURCE, DEFAULT_STATUS, EVIDENCE_SOURCES, MAX_DOMAIN_LENGTH, MAX_NOTES_PER_CASE, MAX_NOTE_LENGTH, MAX_TAGS_PER_CASE, MAX_TAG_LENGTH, } from "./case-record-contracts.mjs";
|
|
4
|
+
import { dispositionLabel, isValidDisposition, isValidStatus, statusLabel, } from "./case-record-decisions.mjs";
|
|
4
5
|
import { normalizeExplicitIsoTimestamp, normalizeLegacyIsoTimestamp } from "../evidence/observation.mjs";
|
|
6
|
+
import { PUBLISHED_V2_3_CASE_SCHEMA_VERSION } from "../contracts/case-portability.mjs";
|
|
5
7
|
import { canonicalRegistrableDomain } from "../../lib/registrable-domain.mjs";
|
|
6
8
|
// Forward-version policy (two distinct guarantees):
|
|
7
9
|
// - A locally-stored envelope that declares a version greater than this is
|
|
@@ -26,13 +28,9 @@ export const EVIDENCE_SOURCE_RANK = {
|
|
|
26
28
|
import: 1,
|
|
27
29
|
unknown: 0,
|
|
28
30
|
};
|
|
29
|
-
const STATUS_VALUES = new Set(CASE_STATUSES.map((item) => item.value));
|
|
30
|
-
const DISPOSITION_VALUES = new Set(CASE_DISPOSITIONS.map((item) => item.value));
|
|
31
31
|
const REVIEW_REASON_VALUES = new Set(CASE_REVIEW_REASONS.map((item) => item.value).filter(Boolean));
|
|
32
32
|
const SOURCE_VALUES = new Set(CASE_SOURCES.map((item) => item.value));
|
|
33
|
-
const
|
|
34
|
-
const DISPOSITION_LABELS = Object.fromEntries(CASE_DISPOSITIONS.map((item) => [item.value, item.label]));
|
|
35
|
-
const SOURCE_LABELS = Object.fromEntries(CASE_SOURCES.map((item) => [item.value, item.label]));
|
|
33
|
+
const SOURCE_LABELS = new Map(CASE_SOURCES.map((item) => [item.value, item.label]));
|
|
36
34
|
// Availability tokens that actually assert something about the domain. Anything
|
|
37
35
|
// else ('unknown', 'error', empty) is not, on its own, material evidence.
|
|
38
36
|
export const CONCLUSIVE_AVAILABILITY = new Set([
|
|
@@ -59,21 +57,13 @@ export function objectRecord(value) {
|
|
|
59
57
|
? value
|
|
60
58
|
: {};
|
|
61
59
|
}
|
|
62
|
-
export function statusLabel(value) {
|
|
63
|
-
return (typeof value === 'string' ? STATUS_LABELS[value] : '') || String(value || '');
|
|
64
|
-
}
|
|
65
|
-
export function dispositionLabel(value) {
|
|
66
|
-
return (typeof value === 'string' ? DISPOSITION_LABELS[value] : '') || String(value || '');
|
|
67
|
-
}
|
|
68
60
|
export function sourceLabel(value) {
|
|
69
|
-
return (
|
|
70
|
-
}
|
|
71
|
-
export function isValidStatus(value) {
|
|
72
|
-
return typeof value === 'string' && STATUS_VALUES.has(value);
|
|
61
|
+
return isValidSource(value) ? SOURCE_LABELS.get(value) : String(value || '');
|
|
73
62
|
}
|
|
74
|
-
export function
|
|
75
|
-
return typeof value === 'string' &&
|
|
63
|
+
export function isValidSource(value) {
|
|
64
|
+
return typeof value === 'string' && SOURCE_VALUES.has(value);
|
|
76
65
|
}
|
|
66
|
+
export { dispositionLabel, isValidDisposition, isValidStatus, statusLabel, };
|
|
77
67
|
/** Fresh, safe, effectively-unique id for a brand-new local record. */
|
|
78
68
|
export function makeId() {
|
|
79
69
|
if (typeof globalThis.crypto?.randomUUID === 'function')
|
|
@@ -147,16 +137,16 @@ export function normalizeEvidenceHostnameForCase(value, caseDomain) {
|
|
|
147
137
|
return canonicalRegistrableDomain(hostname) === canonicalCase ? hostname : null;
|
|
148
138
|
}
|
|
149
139
|
export function normalizeStatus(value) {
|
|
150
|
-
return
|
|
140
|
+
return isValidStatus(value) ? value : DEFAULT_STATUS;
|
|
151
141
|
}
|
|
152
142
|
export function normalizeDisposition(value) {
|
|
153
|
-
return
|
|
143
|
+
return isValidDisposition(value) ? value : DEFAULT_DISPOSITION;
|
|
154
144
|
}
|
|
155
145
|
export function normalizeReviewReasonCode(value) {
|
|
156
146
|
return typeof value === 'string' && REVIEW_REASON_VALUES.has(value) ? value : null;
|
|
157
147
|
}
|
|
158
148
|
export function normalizeSource(value) {
|
|
159
|
-
return
|
|
149
|
+
return isValidSource(value) ? value : DEFAULT_SOURCE;
|
|
160
150
|
}
|
|
161
151
|
/** Parsed ISO string, or null when missing/invalid (used for import ordering). */
|
|
162
152
|
export function isoOrNull(value) {
|
|
@@ -174,7 +164,7 @@ export function caseTimestampOrNull(value, sourceVersion) {
|
|
|
174
164
|
return explicit;
|
|
175
165
|
return sourceVersion !== undefined
|
|
176
166
|
&& sourceVersion !== null
|
|
177
|
-
&& sourceVersion <
|
|
167
|
+
&& sourceVersion < PUBLISHED_V2_3_CASE_SCHEMA_VERSION
|
|
178
168
|
? normalizeLegacyIsoTimestamp(value)
|
|
179
169
|
: null;
|
|
180
170
|
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// Lightweight canonical Case status and disposition definitions. Persistence,
|
|
2
|
+
// portability and presentation consumers derive these identities and policies
|
|
3
|
+
// without importing the wider Case record or serialisation graph.
|
|
4
|
+
export const CASE_STATUSES = Object.freeze([
|
|
5
|
+
{ value: 'new', label: 'New' },
|
|
6
|
+
{ value: 'reviewing', label: 'Reviewing' },
|
|
7
|
+
{ value: 'monitoring', label: 'Monitoring' },
|
|
8
|
+
{ value: 'escalated', label: 'Escalated' },
|
|
9
|
+
{ value: 'resolved', label: 'Resolved' },
|
|
10
|
+
]);
|
|
11
|
+
export const CASE_DISPOSITIONS = Object.freeze([
|
|
12
|
+
{ value: 'unreviewed', label: 'Unreviewed' },
|
|
13
|
+
{ value: 'suspicious', label: 'Suspicious' },
|
|
14
|
+
{ value: 'confirmed_abuse', label: 'Confirmed abuse' },
|
|
15
|
+
{ value: 'false_positive', label: 'False positive' },
|
|
16
|
+
{ value: 'expected', label: 'Expected' },
|
|
17
|
+
{ value: 'closed_no_action', label: 'Closed without action' },
|
|
18
|
+
]);
|
|
19
|
+
export const DEFAULT_STATUS = 'new';
|
|
20
|
+
export const DEFAULT_DISPOSITION = 'unreviewed';
|
|
21
|
+
const CASE_STATUS_OPERATION_POLICIES = Object.freeze({
|
|
22
|
+
new: { closed: false, closureRequired: false, directEdit: 'allowed' },
|
|
23
|
+
reviewing: { closed: false, closureRequired: false, directEdit: 'allowed' },
|
|
24
|
+
monitoring: { closed: false, closureRequired: false, directEdit: 'allowed' },
|
|
25
|
+
escalated: { closed: false, closureRequired: false, directEdit: 'allowed' },
|
|
26
|
+
resolved: { closed: true, closureRequired: true, directEdit: 'existing_only' },
|
|
27
|
+
});
|
|
28
|
+
const CASE_DISPOSITION_DECISION_POLICIES = Object.freeze({
|
|
29
|
+
unreviewed: { reviewed: false, supportsDefensiveResponse: false },
|
|
30
|
+
suspicious: { reviewed: true, supportsDefensiveResponse: true },
|
|
31
|
+
confirmed_abuse: { reviewed: true, supportsDefensiveResponse: true },
|
|
32
|
+
false_positive: { reviewed: true, supportsDefensiveResponse: false },
|
|
33
|
+
expected: { reviewed: true, supportsDefensiveResponse: false },
|
|
34
|
+
closed_no_action: { reviewed: true, supportsDefensiveResponse: false },
|
|
35
|
+
});
|
|
36
|
+
function statusOption(value) {
|
|
37
|
+
return CASE_STATUSES.find((option) => option.value === value);
|
|
38
|
+
}
|
|
39
|
+
function dispositionOption(value) {
|
|
40
|
+
return CASE_DISPOSITIONS.find((option) => option.value === value);
|
|
41
|
+
}
|
|
42
|
+
export function isValidStatus(value) {
|
|
43
|
+
return Boolean(statusOption(value));
|
|
44
|
+
}
|
|
45
|
+
export function isValidDisposition(value) {
|
|
46
|
+
return Boolean(dispositionOption(value));
|
|
47
|
+
}
|
|
48
|
+
export function statusLabel(value) {
|
|
49
|
+
return statusOption(value)?.label ?? String(value || '');
|
|
50
|
+
}
|
|
51
|
+
export function dispositionLabel(value) {
|
|
52
|
+
return dispositionOption(value)?.label ?? String(value || '');
|
|
53
|
+
}
|
|
54
|
+
export function caseStatusRequiresClosure(value) {
|
|
55
|
+
const option = statusOption(value);
|
|
56
|
+
return option ? CASE_STATUS_OPERATION_POLICIES[option.value].closureRequired : false;
|
|
57
|
+
}
|
|
58
|
+
export function caseStatusIsClosed(value) {
|
|
59
|
+
const option = statusOption(value);
|
|
60
|
+
return option ? CASE_STATUS_OPERATION_POLICIES[option.value].closed : false;
|
|
61
|
+
}
|
|
62
|
+
export function isReviewedCaseDisposition(value) {
|
|
63
|
+
const option = dispositionOption(value);
|
|
64
|
+
return option ? CASE_DISPOSITION_DECISION_POLICIES[option.value].reviewed : false;
|
|
65
|
+
}
|
|
66
|
+
export function caseDispositionSupportsDefensiveResponse(value) {
|
|
67
|
+
const option = dispositionOption(value);
|
|
68
|
+
return option ? CASE_DISPOSITION_DECISION_POLICIES[option.value].supportsDefensiveResponse : false;
|
|
69
|
+
}
|
|
70
|
+
export function caseStatusOptionsForDirectEdit(currentStatus) {
|
|
71
|
+
return CASE_STATUSES.filter((option) => (CASE_STATUS_OPERATION_POLICIES[option.value].directEdit === 'allowed' || option.value === currentStatus));
|
|
72
|
+
}
|
|
@@ -6,4 +6,5 @@ export * from "./case-record-core.mjs";
|
|
|
6
6
|
export * from "./case-brand-profile-references.mjs";
|
|
7
7
|
export * from "./case-evidence-model.mjs";
|
|
8
8
|
export * from "./case-record-operations.mjs";
|
|
9
|
+
export * from "./case-record-projection.mjs";
|
|
9
10
|
export * from "./case-investigation-branch-model.mjs";
|
|
@@ -1,13 +1,20 @@
|
|
|
1
1
|
// Pure, framework-neutral analyst-case records, evidence histories, bounded
|
|
2
2
|
// record normalization, and analyst updates.
|
|
3
3
|
import { canonicalRegistrableDomain } from "../../lib/registrable-domain.mjs";
|
|
4
|
+
import { parseCredentialFreeHttpUrl } from "../evidence/lookup-target.mjs";
|
|
5
|
+
import { selectExistingCase } from "./case-selection.mjs";
|
|
4
6
|
import { appendCaseAction, appendCaseAssertion, appendCaseClosure, appendCaseDecision, appendCaseEvidencePin, appendCaseEvidencePins, appendCaseManualTrailEvent, appendCaseObservedEffectReview, appendCaseSighting, buildCaseClosureLinkContext, normalizeCaseActions, normalizeCaseAssertions, normalizeCaseClosureHistory, normalizeCaseDecisions, normalizeCaseEvidencePins, normalizeCaseManualTrail, normalizeCaseObservedEffectHistory, normalizeCaseSightings, updateCaseAction, updateCaseAssertion, } from "./case-response-model.mjs";
|
|
5
|
-
import {
|
|
7
|
+
import { MAX_CASES, MAX_NOTES_PER_CASE, } from "./case-record-contracts.mjs";
|
|
8
|
+
import { PUBLISHED_V2_3_CASE_SCHEMA_VERSION, INCIDENT_CASE_SCHEMA_VERSION, MAX_CASE_OBJECTIVE_LENGTH } from "../contracts/case-portability.mjs";
|
|
9
|
+
import { readCaseAttachments } from "./case-attachment-model.mjs";
|
|
10
|
+
import { assertCurrentRecheckQuestion, assertRecheckNonReproduction, readCaseRecheckAnswerContext } from "./case-recheck-model.mjs";
|
|
11
|
+
import { caseDispositionSupportsDefensiveResponse, caseStatusIsClosed, caseStatusOptionsForDirectEdit, caseStatusRequiresClosure, isReviewedCaseDisposition, } from "./case-record-decisions.mjs";
|
|
6
12
|
import { DEFAULT_EVIDENCE_SOURCE, EVIDENCE_SOURCE_SET, caseTimestampOrNull, deterministicId, makeId, normalizeDisposition, normalizeDomain, normalizeNoteBody, normalizeReviewReasonCode, normalizeNotes, normalizeSource, normalizeStatus, normalizeTags, objectRecord, safeId, } from "./case-record-core.mjs";
|
|
7
13
|
import { assertCaseBrandProfileIds, normalizeCaseBrandProfileIds, } from "./case-brand-profile-references.mjs";
|
|
8
14
|
import { normalizeEvidenceHistory, } from "./case-evidence-model.mjs";
|
|
9
15
|
import { appendCaseInvestigationBranch, caseInvestigationBranchReferences, normalizeCaseInvestigationBranches, updateCaseInvestigationBranch, } from "./case-investigation-branch-model.mjs";
|
|
10
|
-
export
|
|
16
|
+
export { caseDispositionSupportsDefensiveResponse, caseStatusIsClosed, caseStatusOptionsForDirectEdit, caseStatusRequiresClosure, isReviewedCaseDisposition, };
|
|
17
|
+
export { MAX_CASE_OBJECTIVE_LENGTH };
|
|
11
18
|
export const MAX_CASE_INCIDENT_URL_LENGTH = 1_850;
|
|
12
19
|
export const INCIDENT_CONTEXT_STATEMENT_PREFIX = 'Investigate incident URL: ';
|
|
13
20
|
const OBJECTIVE_PREFIX = 'Objective: ';
|
|
@@ -21,18 +28,8 @@ export function normalizeCaseObjective(value) {
|
|
|
21
28
|
return boundedContextText(value, MAX_CASE_OBJECTIVE_LENGTH);
|
|
22
29
|
}
|
|
23
30
|
export function parseIncidentUrlContext(value) {
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
if (/[\u0000-\u001f\u007f]/u.test(value) || value.trim() !== value)
|
|
27
|
-
return null;
|
|
28
|
-
let parsed;
|
|
29
|
-
try {
|
|
30
|
-
parsed = new URL(value);
|
|
31
|
-
}
|
|
32
|
-
catch {
|
|
33
|
-
return null;
|
|
34
|
-
}
|
|
35
|
-
if (!['http:', 'https:'].includes(parsed.protocol) || parsed.username || parsed.password || !parsed.hostname)
|
|
31
|
+
const parsed = parseCredentialFreeHttpUrl(value, MAX_CASE_INCIDENT_URL_LENGTH);
|
|
32
|
+
if (!parsed)
|
|
36
33
|
return null;
|
|
37
34
|
const hostname = parsed.hostname.toLowerCase().replace(/\.$/u, '');
|
|
38
35
|
const registrableDomain = canonicalRegistrableDomain(hostname);
|
|
@@ -140,7 +137,7 @@ export function normalizeCase(raw, existing, nowIso, sourceVersion) {
|
|
|
140
137
|
const createdAt = existing ? existing.createdAt : caseTimestampOrNull(record.createdAt, sourceVersion) || now;
|
|
141
138
|
const updatedAt = caseTimestampOrNull(record.updatedAt, sourceVersion) || createdAt;
|
|
142
139
|
const timestampOptions = {
|
|
143
|
-
legacyTimestamps: sourceVersion != null && sourceVersion <
|
|
140
|
+
legacyTimestamps: sourceVersion != null && sourceVersion < PUBLISHED_V2_3_CASE_SCHEMA_VERSION,
|
|
144
141
|
...(sourceVersion === undefined ? {} : { sourceVersion }),
|
|
145
142
|
};
|
|
146
143
|
const evidencePins = normalizeCaseEvidencePins(record.evidencePins, updatedAt, timestampOptions);
|
|
@@ -153,10 +150,12 @@ export function normalizeCase(raw, existing, nowIso, sourceVersion) {
|
|
|
153
150
|
const closures = normalizeCaseClosureHistory(sourceVersion != null && sourceVersion < 13 ? undefined : record.closures, updatedAt, new Set(observedEffects.reviews.map((item) => item.id)), new Set(actions.map((item) => item.id)), timestampOptions, buildCaseClosureLinkContext(observedEffects, actions));
|
|
154
151
|
const normalizedStatus = normalizeStatus(record.status);
|
|
155
152
|
const branchReferences = caseInvestigationBranchReferences({ evidencePins, actions, assertions });
|
|
153
|
+
const attachments = readCaseAttachments(record.attachments);
|
|
156
154
|
return {
|
|
157
155
|
id: existing ? existing.id : safeId(record.id) || deterministicId(domain),
|
|
158
156
|
domain,
|
|
159
|
-
|
|
157
|
+
title: sourceVersion != null && sourceVersion < INCIDENT_CASE_SCHEMA_VERSION ? '' : normalizeCaseObjective(record.title),
|
|
158
|
+
status: caseStatusRequiresClosure(normalizedStatus)
|
|
160
159
|
&& closures.records.length === 0 && !closures.preV13HistoryUnavailable
|
|
161
160
|
? 'reviewing'
|
|
162
161
|
: normalizedStatus,
|
|
@@ -176,6 +175,7 @@ export function normalizeCase(raw, existing, nowIso, sourceVersion) {
|
|
|
176
175
|
observedEffects,
|
|
177
176
|
closures,
|
|
178
177
|
branches: normalizeCaseInvestigationBranches(record.branches, updatedAt, branchReferences, timestampOptions),
|
|
178
|
+
...(attachments === undefined ? {} : { attachments }),
|
|
179
179
|
createdAt,
|
|
180
180
|
updatedAt,
|
|
181
181
|
};
|
|
@@ -211,15 +211,22 @@ export function createCase(input, nowIso) {
|
|
|
211
211
|
const observedEffects = input.observedEffectReview !== undefined
|
|
212
212
|
? appendCaseObservedEffectReview(normalizeCaseObservedEffectHistory(undefined, now), input.observedEffectReview, now, pinIds, new Set(sightings.map((item) => item.id)))
|
|
213
213
|
: normalizeCaseObservedEffectHistory(undefined, now);
|
|
214
|
+
for (const review of observedEffects.reviews) {
|
|
215
|
+
if (!review.recheck)
|
|
216
|
+
continue;
|
|
217
|
+
assertCurrentRecheckQuestion(review.recheck, assertions);
|
|
218
|
+
assertRecheckNonReproduction(review.state, review.recheck, review.completeness, evidencePins, evidencePins.find(pin => pin.id === review.evidencePinId), review.observedAt);
|
|
219
|
+
}
|
|
214
220
|
const closures = input.closure !== undefined
|
|
215
221
|
? appendCaseClosure(normalizeCaseClosureHistory(undefined, now), input.closure, now, observedEffects, actions)
|
|
216
222
|
: normalizeCaseClosureHistory(undefined, now);
|
|
217
|
-
if (normalizeStatus(input.status)
|
|
223
|
+
if (caseStatusRequiresClosure(normalizeStatus(input.status)) && input.closure === undefined) {
|
|
218
224
|
throw new Error('Opening a resolved case requires a deliberate closure reason and its linked review context.');
|
|
219
225
|
}
|
|
220
226
|
return {
|
|
221
227
|
id: makeId(),
|
|
222
228
|
domain,
|
|
229
|
+
title: normalizeCaseObjective(input.title),
|
|
223
230
|
status: input.closure !== undefined ? 'resolved' : normalizeStatus(input.status),
|
|
224
231
|
disposition: normalizeDisposition(input.disposition),
|
|
225
232
|
reviewReasonCode: normalizeReviewReasonCode(input.reviewReasonCode),
|
|
@@ -252,25 +259,46 @@ export function createCase(input, nowIso) {
|
|
|
252
259
|
};
|
|
253
260
|
}
|
|
254
261
|
/**
|
|
255
|
-
* Opens
|
|
262
|
+
* Opens an explicitly selected or unambiguous Case, or creates one. Returns a new array
|
|
256
263
|
* (callers persist it) plus the resolved record and whether it was created.
|
|
257
264
|
* @param {CaseRecord[]} cases
|
|
258
265
|
* @param {{ domain: unknown, status?: unknown, disposition?: unknown, source?: unknown, tags?: unknown, evidence?: unknown, note?: unknown }} input
|
|
259
266
|
* @param {string} [nowIso]
|
|
260
267
|
* @returns {{ cases: CaseRecord[], record: CaseRecord, created: boolean }}
|
|
261
268
|
*/
|
|
262
|
-
export function openOrCreateCase(cases, input, nowIso) {
|
|
269
|
+
export function openOrCreateCase(cases, input, nowIso, selection = {}) {
|
|
263
270
|
const domain = normalizeDomain(input.domain);
|
|
264
271
|
if (!domain)
|
|
265
272
|
throw new Error('A valid domain is required to open a case.');
|
|
266
|
-
const existing = cases
|
|
273
|
+
const existing = selectExistingCase(cases, domain, selection);
|
|
267
274
|
if (existing)
|
|
268
275
|
return { cases, record: existing, created: false };
|
|
269
276
|
if (cases.length >= MAX_CASES)
|
|
270
277
|
throw new Error(`Cases are limited to ${MAX_CASES}. Delete or export some first.`);
|
|
271
278
|
const record = createCase({ ...input, domain }, nowIso);
|
|
279
|
+
if (cases.some(existingCase => existingCase.id === record.id))
|
|
280
|
+
throw new Error('A unique Case ID could not be allocated. No data was changed; try again.');
|
|
272
281
|
return { cases: [record, ...cases], record, created: true };
|
|
273
282
|
}
|
|
283
|
+
/** Start a separate incident; reuse only the deliberately selected observation. */
|
|
284
|
+
export function createCaseIncident(cases, input, nowIso) {
|
|
285
|
+
const domain = normalizeDomain(input.domain);
|
|
286
|
+
const title = normalizeCaseObjective(input.title);
|
|
287
|
+
if (!domain)
|
|
288
|
+
throw new Error('Enter a valid domain for the incident.');
|
|
289
|
+
if (!title)
|
|
290
|
+
throw new Error('Enter a title that distinguishes this incident.');
|
|
291
|
+
if (typeof input.title === 'string' && input.title.trim().length > MAX_CASE_OBJECTIVE_LENGTH)
|
|
292
|
+
throw new Error(`Incident titles are limited to ${MAX_CASE_OBJECTIVE_LENGTH} characters.`);
|
|
293
|
+
let evidence;
|
|
294
|
+
if (input.reuse) {
|
|
295
|
+
const source = cases.find(record => record.id === input.reuse?.caseId && record.domain === domain);
|
|
296
|
+
evidence = source?.evidenceHistory.find(snapshot => snapshot.id === input.reuse?.snapshotId);
|
|
297
|
+
if (!evidence)
|
|
298
|
+
throw new Error('The selected source observation is no longer available for this domain. No Case was created.');
|
|
299
|
+
}
|
|
300
|
+
return openOrCreateCase(cases, { domain, title, ...(evidence ? { evidence: structuredClone(evidence) } : {}) }, nowIso, { newIncident: true });
|
|
301
|
+
}
|
|
274
302
|
/**
|
|
275
303
|
* Applies a partial update to one case by id, bumping updatedAt. A `note` in
|
|
276
304
|
* the patch is appended (respecting the per-case note bound); a `tags` array
|
|
@@ -291,6 +319,9 @@ export function updateCase(cases, id, patch, nowIso) {
|
|
|
291
319
|
const current = cases[index];
|
|
292
320
|
if (!current)
|
|
293
321
|
throw new Error('That case no longer exists.');
|
|
322
|
+
if (patch.title !== undefined && patch.expectedTitle !== undefined && patch.expectedTitle !== (current.title ?? '')) {
|
|
323
|
+
throw new Error('The incident title changed after this draft was started. Reload and review the current title before saving; your draft has not been applied.');
|
|
324
|
+
}
|
|
294
325
|
let notes = current.notes;
|
|
295
326
|
if (patch.note !== undefined) {
|
|
296
327
|
const body = normalizeNoteBody(patch.note);
|
|
@@ -350,17 +381,24 @@ export function updateCase(cases, id, patch, nowIso) {
|
|
|
350
381
|
}
|
|
351
382
|
let observedEffects = normalizeCaseObservedEffectHistory(current.observedEffects, current.updatedAt, pinIds, new Set(sightings.map((item) => item.id)));
|
|
352
383
|
if (patch.observedEffectReview !== undefined) {
|
|
384
|
+
const review = objectRecord(patch.observedEffectReview);
|
|
385
|
+
const context = readCaseRecheckAnswerContext(review.recheck);
|
|
386
|
+
if (context) {
|
|
387
|
+
assertCurrentRecheckQuestion(context, assertions);
|
|
388
|
+
assertRecheckNonReproduction(review.state, context, String(review.completeness), evidencePins, evidencePins.find(pin => pin.id === review.evidencePinId), typeof review.observedAt === 'string' ? review.observedAt : null);
|
|
389
|
+
}
|
|
353
390
|
observedEffects = appendCaseObservedEffectReview(observedEffects, patch.observedEffectReview, now, pinIds, new Set(sightings.map((item) => item.id)));
|
|
354
391
|
}
|
|
355
392
|
let closures = normalizeCaseClosureHistory(current.closures, current.updatedAt, new Set(observedEffects.reviews.map((item) => item.id)), new Set(actions.map((item) => item.id)), {}, buildCaseClosureLinkContext(observedEffects, actions));
|
|
356
393
|
if (patch.closure !== undefined) {
|
|
357
394
|
closures = appendCaseClosure(closures, patch.closure, now, observedEffects, actions);
|
|
358
395
|
}
|
|
359
|
-
if (patch.status
|
|
396
|
+
if (caseStatusRequiresClosure(patch.status) && patch.closure === undefined) {
|
|
360
397
|
throw new Error('Resolve this case through the deliberate closure review so the reason and evidence state remain explicit.');
|
|
361
398
|
}
|
|
362
399
|
const record = {
|
|
363
400
|
...current,
|
|
401
|
+
title: patch.title === undefined ? current.title ?? '' : normalizeCaseObjective(patch.title),
|
|
364
402
|
status: patch.closure !== undefined
|
|
365
403
|
? 'resolved'
|
|
366
404
|
: patch.status !== undefined ? normalizeStatus(patch.status) : current.status,
|
|
@@ -392,8 +430,8 @@ export function updateCase(cases, id, patch, nowIso) {
|
|
|
392
430
|
}
|
|
393
431
|
/**
|
|
394
432
|
* Records one reviewed conclusion as a single pure Case mutation. The selected
|
|
395
|
-
* observations become bounded Case pins
|
|
396
|
-
* decision cannot point at absent evidence. Counterevidence is retained as a
|
|
433
|
+
* observations become bounded Case pins or reference existing retained pins,
|
|
434
|
+
* so the decision cannot point at absent evidence. Counterevidence is retained as a
|
|
397
435
|
* separate resolved contradiction assertion rather than being hidden inside a
|
|
398
436
|
* favourable disposition.
|
|
399
437
|
*/
|
|
@@ -401,7 +439,7 @@ export function recordCaseConclusion(cases, id, input, nowIso) {
|
|
|
401
439
|
const now = caseTimestampOrNull(nowIso) || new Date().toISOString();
|
|
402
440
|
const disposition = normalizeDisposition(input.disposition);
|
|
403
441
|
const reviewReasonCode = normalizeReviewReasonCode(input.reviewReasonCode);
|
|
404
|
-
if (disposition
|
|
442
|
+
if (!isReviewedCaseDisposition(disposition)) {
|
|
405
443
|
throw new Error('Select a reviewed disposition before recording a conclusion.');
|
|
406
444
|
}
|
|
407
445
|
if (!reviewReasonCode) {
|
|
@@ -420,14 +458,36 @@ export function recordCaseConclusion(cases, id, input, nowIso) {
|
|
|
420
458
|
if (!current)
|
|
421
459
|
throw new Error('That case no longer exists.');
|
|
422
460
|
const existingPinIds = new Set(current.evidencePins.map((pin) => pin.id));
|
|
423
|
-
const
|
|
424
|
-
|
|
425
|
-
|
|
461
|
+
const selectedExisting = new Set();
|
|
462
|
+
for (const item of input.evidence) {
|
|
463
|
+
if (Object.hasOwn(item, 'pin') === Object.hasOwn(item, 'pinId'))
|
|
464
|
+
throw new Error('Select one new pin or one retained pin ID for each conclusion fact.');
|
|
465
|
+
if (Object.hasOwn(item, 'pinId')) {
|
|
466
|
+
if (typeof item.pinId !== 'string' || !existingPinIds.has(item.pinId))
|
|
467
|
+
throw new Error('The selected conclusion pin is no longer retained in this Case.');
|
|
468
|
+
if (selectedExisting.has(item.pinId))
|
|
469
|
+
throw new Error('Select each retained conclusion pin once.');
|
|
470
|
+
selectedExisting.add(item.pinId);
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
const newEvidence = input.evidence.filter(item => Object.hasOwn(item, 'pin'));
|
|
474
|
+
const withPins = newEvidence.length
|
|
475
|
+
? updateCase(cases, id, { evidencePins: newEvidence.map(item => item.pin) }, now)
|
|
476
|
+
: { cases, record: current };
|
|
426
477
|
const addedPins = withPins.record.evidencePins.filter((pin) => !existingPinIds.has(pin.id));
|
|
427
|
-
if (addedPins.length !==
|
|
478
|
+
if (addedPins.length !== newEvidence.length) {
|
|
428
479
|
throw new Error('The selected conclusion evidence could not be retained completely.');
|
|
429
480
|
}
|
|
430
|
-
|
|
481
|
+
let nextAdded = 0;
|
|
482
|
+
const selectedPins = input.evidence.map(item => {
|
|
483
|
+
const pin = Object.hasOwn(item, 'pinId')
|
|
484
|
+
? withPins.record.evidencePins.find(pin => pin.id === item.pinId)
|
|
485
|
+
: addedPins[nextAdded++];
|
|
486
|
+
if (!pin)
|
|
487
|
+
throw new Error('The selected conclusion evidence could not be retained completely.');
|
|
488
|
+
return pin;
|
|
489
|
+
});
|
|
490
|
+
const supportingPinIds = selectedPins.flatMap((pin, index) => (input.evidence[index]?.stance === 'supports' ? [pin.id] : []));
|
|
431
491
|
let concluded = updateCase(withPins.cases, id, {
|
|
432
492
|
disposition,
|
|
433
493
|
reviewReasonCode,
|
|
@@ -437,7 +497,7 @@ export function recordCaseConclusion(cases, id, input, nowIso) {
|
|
|
437
497
|
evidencePinIds: supportingPinIds,
|
|
438
498
|
},
|
|
439
499
|
}, now);
|
|
440
|
-
const contradictionRelations =
|
|
500
|
+
const contradictionRelations = selectedPins.flatMap((pin, index) => (input.evidence[index]?.stance === 'contradicts'
|
|
441
501
|
? [{ evidencePinId: pin.id, stance: 'contradicts' }]
|
|
442
502
|
: []));
|
|
443
503
|
if (contradictionRelations.length) {
|
|
@@ -452,7 +512,7 @@ export function recordCaseConclusion(cases, id, input, nowIso) {
|
|
|
452
512
|
},
|
|
453
513
|
}, now);
|
|
454
514
|
}
|
|
455
|
-
const unresolvedRelations =
|
|
515
|
+
const unresolvedRelations = selectedPins.flatMap((pin, index) => (input.evidence[index]?.stance === 'unresolved'
|
|
456
516
|
? [{ evidencePinId: pin.id, stance: 'unresolved' }]
|
|
457
517
|
: []));
|
|
458
518
|
if (unresolvedRelations.length) {
|
|
@@ -503,6 +563,8 @@ export function recordCaseRecheckOutcome(cases, id, input, nowIso) {
|
|
|
503
563
|
const current = cases.find((item) => item.id === id);
|
|
504
564
|
if (!current)
|
|
505
565
|
throw new Error('That case no longer exists.');
|
|
566
|
+
if (input.recheck && input.observationHostname !== input.recheck.targetHostname)
|
|
567
|
+
throw new Error('This recheck concerns a different or unknown hostname. Recollect the saved question target before recording its answer.');
|
|
506
568
|
const beforePinIds = new Set(current.evidencePins.map((pin) => pin.id));
|
|
507
569
|
const withPin = updateCase(cases, id, {
|
|
508
570
|
evidencePin: {
|
|
@@ -514,6 +576,7 @@ export function recordCaseRecheckOutcome(cases, id, input, nowIso) {
|
|
|
514
576
|
sourceState: 'reviewed',
|
|
515
577
|
observedAt: input.observedAt,
|
|
516
578
|
collectionDepth: input.collectionDepth,
|
|
579
|
+
observationHostname: input.observationHostname,
|
|
517
580
|
completeness: input.completeness,
|
|
518
581
|
truncated: false,
|
|
519
582
|
limitations: input.limitations,
|
|
@@ -532,6 +595,7 @@ export function recordCaseRecheckOutcome(cases, id, input, nowIso) {
|
|
|
532
595
|
evidencePinId: comparisonPin.id,
|
|
533
596
|
followUpAt: input.followUpAt,
|
|
534
597
|
limitations: input.limitations,
|
|
598
|
+
...(input.recheck ? { recheck: input.recheck } : {}),
|
|
535
599
|
},
|
|
536
600
|
}, now);
|
|
537
601
|
}
|