@slicedearth/whoisleuth-cli 2.3.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 +19 -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 +68 -6
- package/cli/artifact-validation/case-response.mjs +47 -41
- 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 +83 -61
- 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-command-runner.mjs +2 -0
- package/cli/bulk-output.mjs +29 -3
- package/cli/case-command.mjs +195 -0
- package/cli/case-pack.mjs +39 -31
- package/cli/command-argument-grammar.mjs +4 -0
- package/cli/command-reference.mjs +145 -75
- package/cli/completion.mjs +298 -79
- 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 +44 -16
- package/cli/discovery-scan-command-runner.mjs +2 -0
- package/cli/discovery-scan.mjs +7 -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 +1 -0
- package/cli/interactive-launcher.mjs +2 -160
- package/cli/interchange-report.mjs +4 -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/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-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 +45 -0
- package/cli/runner.mjs +55 -6
- 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 +76 -30
- package/docs/cli.md +289 -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 +43 -3
- 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-delegation-health.mjs +24 -14
- package/lib/dns-inheritance-review.mjs +217 -0
- package/lib/dns-intelligence.mjs +36 -23
- package/lib/dns-mx.mjs +1 -1
- package/lib/dnssec-evidence-validation.mjs +11 -3
- package/lib/domain-change-packet.mjs +7 -2
- package/lib/domain-change-review.mjs +14 -8
- package/lib/domain-control-flight-recorder.mjs +219 -118
- package/lib/domain-control-manifest.mjs +117 -57
- package/lib/domain-posture-analysis.mjs +20 -7
- package/lib/domain-posture-parsers.mjs +88 -6
- package/lib/domain-posture.mjs +113 -26
- package/lib/evidence-export-privacy.mjs +561 -0
- package/lib/evidence-export.mjs +11 -592
- package/lib/evidence-report.mjs +3 -1
- 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 +82 -85
- package/lib/http-evidence-bounds.mjs +2 -1
- package/lib/http-intelligence.mjs +39 -26
- 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 +7 -10
- package/lib/lookup-source-progress.mjs +17 -14
- package/lib/lookup.mjs +48 -27
- 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-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 +56 -18
- package/lib/response-policy.mjs +86 -48
- package/lib/risk-calibration-summary.mjs +17 -3
- package/lib/rpki-evidence.mjs +8 -5
- package/lib/safe-fetch.mjs +54 -47
- package/lib/security-txt.mjs +10 -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 +117 -146
- 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 +111 -71
- 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/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 +59 -25
- package/packages/cases/case-model.mjs +1 -0
- package/packages/cases/case-recheck-model.mjs +91 -0
- package/packages/cases/case-record-core.mjs +3 -2
- package/packages/cases/case-record-operations.mjs +89 -27
- package/packages/cases/case-record-projection.mjs +23 -20
- package/packages/cases/case-report.mjs +63 -52
- package/packages/cases/case-response-actions.mjs +654 -0
- package/packages/cases/case-response-model.mjs +41 -1129
- 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 +38 -587
- 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 +25 -0
- package/packages/cases/response-route-freshness.mjs +16 -0
- package/packages/comparison/capture-context.mjs +61 -0
- package/packages/comparison/comparison-ledger-bulk.mjs +4 -1
- package/packages/comparison/page-similarity.mjs +2 -1
- 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 +279 -116
- package/packages/contracts/case-views-contract.mjs +12 -0
- package/packages/contracts/case-views.mjs +26 -0
- package/packages/contracts/cli-command-semantics.mjs +1 -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 +0 -1
- 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 +160 -225
- 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 +155 -27
- 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 +89 -49
- 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 +161 -141
- 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 +158 -20
- package/packages/workspace/bulk-columns.mjs +17 -0
- package/packages/workspace/bulk-review-model.mjs +11 -5
- package/packages/workspace/bulk-session-model.mjs +95 -20
- package/packages/workspace/bulk-sort.mjs +1 -0
- package/packages/workspace/case-views.mjs +102 -0
- 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 -26
- package/packages/workspace/watchlist-history.mjs +2 -2
- package/packages/workspace/watchlist-store.mjs +8 -2
- package/packages/workspace/website-snapshot-model.mjs +70 -30
- package/packages/workspace/workspace-archive-crypto.mjs +41 -61
- package/packages/workspace/workspace-archive.mjs +85 -21
- package/third-party-notices.txt +29 -2
- package/packages/investigation/bulk-review-export.mjs +0 -63
|
@@ -0,0 +1,654 @@
|
|
|
1
|
+
// Append-only response actions: transitions, reconciliation and bounded history.
|
|
2
|
+
import { CASE_SCHEMA_VERSION, MAX_CASE_ACTIONS, MAX_CASE_ACTION_BYTES, MAX_CASE_ACTION_EVENTS_PER_ACTION, MAX_CASE_ACTION_EVENTS_PER_CASE, MAX_CASE_ACTION_HISTORY_BYTES_PER_CASE, MAX_RESPONSE_LABEL_LENGTH, MAX_RESPONSE_RATIONALE_LENGTH, MAX_RESPONSE_RECIPIENT_LENGTH, MAX_RESPONSE_REFERENCE_LENGTH, } from "../contracts/case-portability.mjs";
|
|
3
|
+
import { normalizeExplicitIsoTimestamp, } from "../evidence/observation.mjs";
|
|
4
|
+
import { CASE_ACTION_EVENT_SOURCE_CLASSES, CASE_ACTION_STATES, CASE_ACTION_TYPES, CASE_PROVIDER_OUTCOMES, } from "./case-response-records.mjs";
|
|
5
|
+
import { SAFE_ID_RE, boundedCounter, compareCodeUnits, deterministicId, freshId, iso, lifecycleLimitations, limitations, optionalIso, record, safeId, text, } from "./case-response-values.mjs";
|
|
6
|
+
const ACTION_TYPES = new Set(CASE_ACTION_TYPES);
|
|
7
|
+
const ACTION_STATES = new Set(CASE_ACTION_STATES);
|
|
8
|
+
const PROVIDER_OUTCOMES = new Set(CASE_PROVIDER_OUTCOMES);
|
|
9
|
+
const ACTION_EVENT_SOURCE_CLASSES = new Set(CASE_ACTION_EVENT_SOURCE_CLASSES);
|
|
10
|
+
function currentActionNormalizationOptions(validEvidencePinIds) {
|
|
11
|
+
return validEvidencePinIds
|
|
12
|
+
? { sourceVersion: CASE_SCHEMA_VERSION, validEvidencePinIds }
|
|
13
|
+
: { sourceVersion: CASE_SCHEMA_VERSION };
|
|
14
|
+
}
|
|
15
|
+
const LEGACY_ACTION_STATE_MAP = Object.freeze({
|
|
16
|
+
planned: 'drafting',
|
|
17
|
+
ready_for_review: 'ready_for_review',
|
|
18
|
+
submitted: 'submitted',
|
|
19
|
+
acknowledged: 'acknowledged',
|
|
20
|
+
resolved: 'terminal',
|
|
21
|
+
closed: 'terminal',
|
|
22
|
+
});
|
|
23
|
+
const ACTION_TRANSITIONS = Object.freeze({
|
|
24
|
+
drafting: new Set(['ready_for_review', 'terminal']),
|
|
25
|
+
ready_for_review: new Set(['drafting', 'reviewed', 'terminal']),
|
|
26
|
+
reviewed: new Set(['drafting', 'authorised', 'terminal']),
|
|
27
|
+
authorised: new Set(['drafting', 'submitted', 'terminal']),
|
|
28
|
+
submitted: new Set(['submitted', 'acknowledged', 'terminal']),
|
|
29
|
+
acknowledged: new Set(['acknowledged', 'terminal']),
|
|
30
|
+
terminal: new Set(),
|
|
31
|
+
});
|
|
32
|
+
function bytes(value) {
|
|
33
|
+
try {
|
|
34
|
+
return new TextEncoder().encode(JSON.stringify(value)).byteLength;
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
return Number.POSITIVE_INFINITY;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
export function isLegalCaseActionTransition(previousState, nextState, sourceClass = 'analyst') {
|
|
41
|
+
if (previousState === null) {
|
|
42
|
+
return sourceClass === 'migration'
|
|
43
|
+
|| nextState === 'drafting' && (sourceClass === 'analyst' || sourceClass === 'browser_local');
|
|
44
|
+
}
|
|
45
|
+
if (!ACTION_TRANSITIONS[previousState].has(nextState))
|
|
46
|
+
return false;
|
|
47
|
+
if (sourceClass === 'provider' || sourceClass === 'import') {
|
|
48
|
+
return (previousState === 'submitted' || previousState === 'acknowledged')
|
|
49
|
+
&& (nextState === 'submitted' || nextState === 'acknowledged' || nextState === 'terminal');
|
|
50
|
+
}
|
|
51
|
+
if (sourceClass === 'browser_local') {
|
|
52
|
+
return nextState === 'drafting' && ['ready_for_review', 'reviewed', 'authorised'].includes(previousState);
|
|
53
|
+
}
|
|
54
|
+
return sourceClass === 'analyst';
|
|
55
|
+
}
|
|
56
|
+
function normalizeActionEvent(raw, actionId, options = {}) {
|
|
57
|
+
const item = record(raw);
|
|
58
|
+
const previousState = item.previousState === null
|
|
59
|
+
? null
|
|
60
|
+
: typeof item.previousState === 'string' && ACTION_STATES.has(item.previousState)
|
|
61
|
+
? item.previousState
|
|
62
|
+
: undefined;
|
|
63
|
+
const nextState = typeof item.nextState === 'string' && ACTION_STATES.has(item.nextState)
|
|
64
|
+
? item.nextState
|
|
65
|
+
: null;
|
|
66
|
+
const sourceClass = typeof item.sourceClass === 'string' && ACTION_EVENT_SOURCE_CLASSES.has(item.sourceClass)
|
|
67
|
+
? item.sourceClass
|
|
68
|
+
: null;
|
|
69
|
+
const occurredAt = optionalIso(item.occurredAt, options);
|
|
70
|
+
if (previousState === undefined || !nextState || !sourceClass || !occurredAt
|
|
71
|
+
|| !isLegalCaseActionTransition(previousState, nextState, sourceClass)) {
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
const migrationSnapshot = sourceClass === 'migration' && previousState === null;
|
|
75
|
+
if (['ready_for_review', 'reviewed', 'authorised', 'submitted'].includes(nextState)
|
|
76
|
+
&& sourceClass !== 'analyst' && !migrationSnapshot)
|
|
77
|
+
return null;
|
|
78
|
+
const provenance = text(item.provenance, MAX_RESPONSE_LABEL_LENGTH) || `${sourceClass}_record`;
|
|
79
|
+
const providerOutcome = typeof item.providerOutcome === 'string' && PROVIDER_OUTCOMES.has(item.providerOutcome)
|
|
80
|
+
? item.providerOutcome
|
|
81
|
+
: null;
|
|
82
|
+
if (providerOutcome && !['submitted', 'acknowledged', 'terminal'].includes(nextState) && sourceClass !== 'migration') {
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
if (providerOutcome && previousState === 'authorised' && nextState === 'submitted' && !migrationSnapshot) {
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
if (providerOutcome === 'no_response' && sourceClass === 'provider')
|
|
89
|
+
return null;
|
|
90
|
+
if (nextState === 'terminal' && previousState !== null
|
|
91
|
+
&& ['drafting', 'ready_for_review', 'reviewed', 'authorised'].includes(previousState)
|
|
92
|
+
&& (sourceClass !== 'analyst' || providerOutcome !== 'withdrawn')) {
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
if (providerOutcome === 'withdrawn'
|
|
96
|
+
&& (nextState !== 'terminal' || (sourceClass !== 'analyst' && !migrationSnapshot)))
|
|
97
|
+
return null;
|
|
98
|
+
const candidateEvidencePinId = typeof item.evidencePinId === 'string' && SAFE_ID_RE.test(item.evidencePinId)
|
|
99
|
+
? item.evidencePinId
|
|
100
|
+
: null;
|
|
101
|
+
const evidencePinId = candidateEvidencePinId
|
|
102
|
+
&& (!options.validEvidencePinIds || options.validEvidencePinIds.has(candidateEvidencePinId))
|
|
103
|
+
? candidateEvidencePinId
|
|
104
|
+
: null;
|
|
105
|
+
const eventMaterial = {
|
|
106
|
+
previousState,
|
|
107
|
+
nextState,
|
|
108
|
+
occurredAt,
|
|
109
|
+
sourceClass,
|
|
110
|
+
provenance,
|
|
111
|
+
reference: text(item.reference, MAX_RESPONSE_REFERENCE_LENGTH) || null,
|
|
112
|
+
evidencePinId,
|
|
113
|
+
limitations: limitations([
|
|
114
|
+
...(item.evidencePinId != null && !evidencePinId ? ['A malformed or dangling evidence-pin reference was omitted from this transition.'] : []),
|
|
115
|
+
...limitations(item.limitations),
|
|
116
|
+
]),
|
|
117
|
+
providerOutcome,
|
|
118
|
+
outcomeDetail: text(item.outcomeDetail, MAX_RESPONSE_RATIONALE_LENGTH) || null,
|
|
119
|
+
originActionId: typeof item.originActionId === 'string' && SAFE_ID_RE.test(item.originActionId) && item.originActionId !== actionId
|
|
120
|
+
? item.originActionId
|
|
121
|
+
: null,
|
|
122
|
+
};
|
|
123
|
+
return {
|
|
124
|
+
id: safeId(item.id, 'action-event', { actionId, ...eventMaterial }),
|
|
125
|
+
...eventMaterial,
|
|
126
|
+
applied: false,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
function legacyActionState(value) {
|
|
130
|
+
if (typeof value === 'string' && ACTION_STATES.has(value))
|
|
131
|
+
return value;
|
|
132
|
+
if (typeof value === 'string' && LEGACY_ACTION_STATE_MAP[value])
|
|
133
|
+
return LEGACY_ACTION_STATE_MAP[value];
|
|
134
|
+
return 'drafting';
|
|
135
|
+
}
|
|
136
|
+
function legacyActionEvent(item, actionId, createdAt, fallback, options) {
|
|
137
|
+
const nextState = legacyActionState(item.state);
|
|
138
|
+
const occurredAt = iso(item.updatedAt, createdAt || fallback, options);
|
|
139
|
+
const sourceVersion = options.sourceVersion;
|
|
140
|
+
const provenance = sourceVersion !== null && sourceVersion !== undefined && sourceVersion <= 12
|
|
141
|
+
? 'case_v12_legacy_snapshot'
|
|
142
|
+
: 'legacy_action_snapshot';
|
|
143
|
+
const legacyState = typeof item.state === 'string' ? item.state : 'planned';
|
|
144
|
+
const migrationLimitation = sourceVersion !== null && sourceVersion !== undefined && sourceVersion <= 12
|
|
145
|
+
? `Migrated from the Case v12 current state "${text(legacyState, 40)}"; pre-v13 transition history is unavailable.`
|
|
146
|
+
: 'Recovered a legacy current-state action; earlier transition history is unavailable.';
|
|
147
|
+
const eventMaterial = {
|
|
148
|
+
previousState: null,
|
|
149
|
+
nextState,
|
|
150
|
+
occurredAt,
|
|
151
|
+
sourceClass: 'migration',
|
|
152
|
+
provenance,
|
|
153
|
+
reference: text(item.reference, MAX_RESPONSE_REFERENCE_LENGTH) || null,
|
|
154
|
+
evidencePinId: null,
|
|
155
|
+
limitations: limitations([migrationLimitation, ...limitations(item.contactLimitations)]),
|
|
156
|
+
providerOutcome: !(sourceVersion !== null && sourceVersion !== undefined && sourceVersion <= 12)
|
|
157
|
+
&& typeof item.providerOutcome === 'string' && PROVIDER_OUTCOMES.has(item.providerOutcome)
|
|
158
|
+
? item.providerOutcome
|
|
159
|
+
: null,
|
|
160
|
+
outcomeDetail: text(item.outcome, MAX_RESPONSE_RATIONALE_LENGTH) || null,
|
|
161
|
+
originActionId: typeof item.originActionId === 'string' && SAFE_ID_RE.test(item.originActionId) && item.originActionId !== actionId
|
|
162
|
+
? item.originActionId
|
|
163
|
+
: null,
|
|
164
|
+
};
|
|
165
|
+
return {
|
|
166
|
+
id: deterministicId('action-event', { actionId, ...eventMaterial }),
|
|
167
|
+
...eventMaterial,
|
|
168
|
+
applied: true,
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
function currentActionRecoveryEvent(actionId, createdAt) {
|
|
172
|
+
const eventMaterial = {
|
|
173
|
+
previousState: null,
|
|
174
|
+
nextState: 'drafting',
|
|
175
|
+
occurredAt: createdAt,
|
|
176
|
+
sourceClass: 'browser_local',
|
|
177
|
+
provenance: 'case_v13_history_recovery',
|
|
178
|
+
reference: null,
|
|
179
|
+
evidencePinId: null,
|
|
180
|
+
limitations: ['The v13 action had no valid transition history. Mutable state, reference, and outcome projections were ignored.'],
|
|
181
|
+
providerOutcome: null,
|
|
182
|
+
outcomeDetail: null,
|
|
183
|
+
originActionId: null,
|
|
184
|
+
};
|
|
185
|
+
return {
|
|
186
|
+
id: deterministicId('action-event', { actionId, ...eventMaterial }),
|
|
187
|
+
...eventMaterial,
|
|
188
|
+
applied: true,
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
function actionEventContent(event) {
|
|
192
|
+
const { applied: _applied, ...material } = event;
|
|
193
|
+
return JSON.stringify(material);
|
|
194
|
+
}
|
|
195
|
+
function projectActionHistory(source, omitted) {
|
|
196
|
+
let state = null;
|
|
197
|
+
let conflicts = 0;
|
|
198
|
+
const history = [];
|
|
199
|
+
for (let start = 0; start < source.length;) {
|
|
200
|
+
let end = start + 1;
|
|
201
|
+
while (end < source.length && source[end].occurredAt === source[start].occurredAt)
|
|
202
|
+
end += 1;
|
|
203
|
+
const cohort = source.slice(start, end);
|
|
204
|
+
if (state === null && omitted > 0 && start === 0 && cohort[0].previousState !== null)
|
|
205
|
+
state = cohort[0].previousState;
|
|
206
|
+
while (cohort.length) {
|
|
207
|
+
// State prerequisites order equal-time transitions. Competing successors
|
|
208
|
+
// retain the existing deterministic conflict order; times are unchanged.
|
|
209
|
+
const eligible = cohort.findIndex((event) => event.previousState === state);
|
|
210
|
+
const [event] = cohort.splice(eligible < 0 ? 0 : eligible, 1);
|
|
211
|
+
const applied = event.previousState === state
|
|
212
|
+
&& isLegalCaseActionTransition(event.previousState, event.nextState, event.sourceClass);
|
|
213
|
+
if (applied)
|
|
214
|
+
state = event.nextState;
|
|
215
|
+
else
|
|
216
|
+
conflicts += 1;
|
|
217
|
+
history.push({ ...event, applied });
|
|
218
|
+
}
|
|
219
|
+
start = end;
|
|
220
|
+
}
|
|
221
|
+
return { history, state: state ?? 'drafting', conflicts };
|
|
222
|
+
}
|
|
223
|
+
function normalizeActionHistory(raw, item, actionId, createdAt, fallback, options) {
|
|
224
|
+
const source = Array.isArray(raw) ? raw : [];
|
|
225
|
+
let omitted = boundedCounter(item.historyOmitted);
|
|
226
|
+
let invalid = 0;
|
|
227
|
+
let duplicateConflict = 0;
|
|
228
|
+
const byId = new Map();
|
|
229
|
+
for (const candidate of source.slice(0, MAX_CASE_ACTION_EVENTS_PER_ACTION * 4)) {
|
|
230
|
+
const event = normalizeActionEvent(candidate, actionId, options);
|
|
231
|
+
if (!event) {
|
|
232
|
+
invalid += 1;
|
|
233
|
+
continue;
|
|
234
|
+
}
|
|
235
|
+
const existing = byId.get(event.id);
|
|
236
|
+
if (!existing)
|
|
237
|
+
byId.set(event.id, event);
|
|
238
|
+
else if (actionEventContent(existing) !== actionEventContent(event)) {
|
|
239
|
+
duplicateConflict += 1;
|
|
240
|
+
if (actionEventContent(event) < actionEventContent(existing))
|
|
241
|
+
byId.set(event.id, event);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
invalid += Math.max(0, source.length - MAX_CASE_ACTION_EVENTS_PER_ACTION * 4);
|
|
245
|
+
if (!byId.size) {
|
|
246
|
+
const recovered = options.sourceVersion != null && options.sourceVersion <= 12
|
|
247
|
+
? legacyActionEvent(item, actionId, createdAt, fallback, options)
|
|
248
|
+
: currentActionRecoveryEvent(actionId, createdAt);
|
|
249
|
+
byId.set(recovered.id, recovered);
|
|
250
|
+
}
|
|
251
|
+
let history = [...byId.values()].sort((left, right) => Date.parse(left.occurredAt) - Date.parse(right.occurredAt) || compareCodeUnits(left.id, right.id));
|
|
252
|
+
if (history.length > MAX_CASE_ACTION_EVENTS_PER_ACTION) {
|
|
253
|
+
omitted += history.length - MAX_CASE_ACTION_EVENTS_PER_ACTION;
|
|
254
|
+
history = history.slice(-MAX_CASE_ACTION_EVENTS_PER_ACTION);
|
|
255
|
+
}
|
|
256
|
+
while (history.length > 1 && bytes(history) > MAX_CASE_ACTION_BYTES) {
|
|
257
|
+
history.shift();
|
|
258
|
+
omitted += 1;
|
|
259
|
+
}
|
|
260
|
+
const totalOmitted = omitted + invalid + duplicateConflict;
|
|
261
|
+
const projected = projectActionHistory(history, totalOmitted);
|
|
262
|
+
const retainedLimitations = limitations(item.historyLimitations).filter((item) => !/^\d+ earlier action transition events? omitted by bounded retention\.$/u.test(item)
|
|
263
|
+
&& !/^\d+ retained concurrent transition(?: is|s are) not applied to the current-state projection\.$/u.test(item));
|
|
264
|
+
const historyLimitations = lifecycleLimitations([
|
|
265
|
+
...(totalOmitted ? [`${totalOmitted} earlier action transition event${totalOmitted === 1 ? '' : 's'} omitted by bounded retention.`] : []),
|
|
266
|
+
...(invalid ? [`${invalid} malformed or illegal action transition event${invalid === 1 ? '' : 's'} omitted during normalisation.`] : []),
|
|
267
|
+
...(duplicateConflict ? [`${duplicateConflict} conflicting duplicate event identit${duplicateConflict === 1 ? 'y was' : 'ies were'} reconciled deterministically.`] : []),
|
|
268
|
+
...(projected.conflicts ? [`${projected.conflicts} retained concurrent transition${projected.conflicts === 1 ? ' is' : 's are'} not applied to the current-state projection.`] : []),
|
|
269
|
+
...retainedLimitations,
|
|
270
|
+
]);
|
|
271
|
+
return {
|
|
272
|
+
history: projected.history,
|
|
273
|
+
state: projected.state,
|
|
274
|
+
omitted: totalOmitted,
|
|
275
|
+
historyLimitations,
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
function normalizeAction(raw, fallback, options = {}) {
|
|
279
|
+
const item = record(raw);
|
|
280
|
+
const recipient = text(item.recipient, MAX_RESPONSE_RECIPIENT_LENGTH);
|
|
281
|
+
if (!recipient)
|
|
282
|
+
return null;
|
|
283
|
+
const createdAt = iso(item.createdAt, fallback, options);
|
|
284
|
+
const actionId = safeId(item.id, 'action', { recipient, createdAt });
|
|
285
|
+
const history = normalizeActionHistory(item.history, item, actionId, createdAt, fallback, options);
|
|
286
|
+
const applied = history.history.filter((event) => event.applied);
|
|
287
|
+
const latestReference = [...applied].reverse().find((event) => event.reference)?.reference ?? null;
|
|
288
|
+
const latestProviderOutcome = [...applied].reverse().find((event) => event.providerOutcome) ?? null;
|
|
289
|
+
const latestOutcomeDetail = [...applied].reverse().find((event) => event.outcomeDetail) ?? null;
|
|
290
|
+
const latestEventAt = applied.at(-1)?.occurredAt ?? createdAt;
|
|
291
|
+
const metadataUpdatedAt = iso(item.metadataUpdatedAt ?? item.updatedAt, createdAt, options);
|
|
292
|
+
const updatedAt = Date.parse(latestEventAt) > Date.parse(metadataUpdatedAt) ? latestEventAt : metadataUpdatedAt;
|
|
293
|
+
const contactSource = text(item.contactSource, MAX_RESPONSE_LABEL_LENGTH) || 'analyst_supplied';
|
|
294
|
+
const legacyPlatformReview = options.sourceVersion != null && options.sourceVersion < 15
|
|
295
|
+
&& item.type === 'security_contact_report'
|
|
296
|
+
? /^Official .+, reviewed (\d{4}-\d{2}-\d{2})$/u.exec(contactSource)
|
|
297
|
+
: null;
|
|
298
|
+
return {
|
|
299
|
+
id: actionId,
|
|
300
|
+
type: legacyPlatformReview
|
|
301
|
+
? 'platform_report'
|
|
302
|
+
: typeof item.type === 'string' && ACTION_TYPES.has(item.type)
|
|
303
|
+
? item.type
|
|
304
|
+
: 'internal_review',
|
|
305
|
+
recipient,
|
|
306
|
+
contactSource,
|
|
307
|
+
routeObservedAt: legacyPlatformReview
|
|
308
|
+
? normalizeExplicitIsoTimestamp(`${legacyPlatformReview[1]}T00:00:00.000Z`)
|
|
309
|
+
: options.sourceVersion != null && options.sourceVersion < 14
|
|
310
|
+
? null
|
|
311
|
+
: optionalIso(item.routeObservedAt, options),
|
|
312
|
+
routeReviewAfter: options.sourceVersion != null && options.sourceVersion < 15
|
|
313
|
+
? null
|
|
314
|
+
: optionalIso(item.routeReviewAfter, options),
|
|
315
|
+
contactLimitations: limitations(item.contactLimitations),
|
|
316
|
+
dueAt: optionalIso(item.dueAt, options),
|
|
317
|
+
state: history.state,
|
|
318
|
+
reference: latestReference,
|
|
319
|
+
followUpAt: optionalIso(item.followUpAt, options),
|
|
320
|
+
providerOutcome: latestProviderOutcome?.providerOutcome ?? null,
|
|
321
|
+
outcome: latestProviderOutcome?.outcomeDetail ?? latestOutcomeDetail?.outcomeDetail ?? null,
|
|
322
|
+
originActionId: typeof item.originActionId === 'string' && SAFE_ID_RE.test(item.originActionId) && item.originActionId !== actionId
|
|
323
|
+
? item.originActionId
|
|
324
|
+
: null,
|
|
325
|
+
history: history.history,
|
|
326
|
+
historyOmitted: history.omitted,
|
|
327
|
+
historyLimitations: history.historyLimitations,
|
|
328
|
+
createdAt,
|
|
329
|
+
metadataUpdatedAt,
|
|
330
|
+
updatedAt,
|
|
331
|
+
};
|
|
332
|
+
}
|
|
333
|
+
function mergeNormalizedActions(left, right, fallback) {
|
|
334
|
+
const metadataWinner = Date.parse(left.metadataUpdatedAt) > Date.parse(right.metadataUpdatedAt)
|
|
335
|
+
? left
|
|
336
|
+
: Date.parse(right.metadataUpdatedAt) > Date.parse(left.metadataUpdatedAt)
|
|
337
|
+
? right
|
|
338
|
+
: JSON.stringify(left) <= JSON.stringify(right) ? left : right;
|
|
339
|
+
return normalizeAction({
|
|
340
|
+
...metadataWinner,
|
|
341
|
+
id: left.id,
|
|
342
|
+
createdAt: Date.parse(left.createdAt) <= Date.parse(right.createdAt) ? left.createdAt : right.createdAt,
|
|
343
|
+
history: [...left.history, ...right.history],
|
|
344
|
+
historyOmitted: Math.max(left.historyOmitted, right.historyOmitted),
|
|
345
|
+
historyLimitations: [...left.historyLimitations, ...right.historyLimitations],
|
|
346
|
+
}, fallback, { sourceVersion: CASE_SCHEMA_VERSION });
|
|
347
|
+
}
|
|
348
|
+
function boundActionCollection(actions, fallback) {
|
|
349
|
+
const keep = new Set();
|
|
350
|
+
for (const action of actions) {
|
|
351
|
+
const latest = action.history.at(-1);
|
|
352
|
+
if (latest)
|
|
353
|
+
keep.add(`${action.id}\u0000${latest.id}`);
|
|
354
|
+
}
|
|
355
|
+
const all = actions.flatMap((action) => action.history.map((event) => ({ action, event })))
|
|
356
|
+
.sort((left, right) => Date.parse(right.event.occurredAt) - Date.parse(left.event.occurredAt)
|
|
357
|
+
|| compareCodeUnits(right.event.id, left.event.id));
|
|
358
|
+
for (const item of all) {
|
|
359
|
+
if (keep.size >= MAX_CASE_ACTION_EVENTS_PER_CASE)
|
|
360
|
+
break;
|
|
361
|
+
keep.add(`${item.action.id}\u0000${item.event.id}`);
|
|
362
|
+
}
|
|
363
|
+
let bounded = actions.map((action) => {
|
|
364
|
+
const history = action.history.filter((event) => keep.has(`${action.id}\u0000${event.id}`));
|
|
365
|
+
const omitted = action.historyOmitted + action.history.length - history.length;
|
|
366
|
+
return normalizeAction({ ...action, history, historyOmitted: omitted }, fallback, { sourceVersion: CASE_SCHEMA_VERSION });
|
|
367
|
+
});
|
|
368
|
+
while (bytes(bounded) > MAX_CASE_ACTION_HISTORY_BYTES_PER_CASE) {
|
|
369
|
+
const candidate = bounded
|
|
370
|
+
.flatMap((action) => action.history.slice(0, -1).map((event) => ({ action, event })))
|
|
371
|
+
.sort((left, right) => Date.parse(left.event.occurredAt) - Date.parse(right.event.occurredAt)
|
|
372
|
+
|| compareCodeUnits(left.event.id, right.event.id))[0];
|
|
373
|
+
if (!candidate)
|
|
374
|
+
break;
|
|
375
|
+
bounded = bounded.map((action) => action.id !== candidate.action.id
|
|
376
|
+
? action
|
|
377
|
+
: normalizeAction({
|
|
378
|
+
...action,
|
|
379
|
+
history: action.history.filter((event) => event.id !== candidate.event.id),
|
|
380
|
+
historyOmitted: action.historyOmitted + 1,
|
|
381
|
+
}, fallback, { sourceVersion: CASE_SCHEMA_VERSION }));
|
|
382
|
+
}
|
|
383
|
+
if (bytes(bounded) > MAX_CASE_ACTION_HISTORY_BYTES_PER_CASE && bounded.length > 1) {
|
|
384
|
+
const byteOmissionPattern = /^(\d+) earlier response actions? omitted by the per-Case response-history byte bound\.$/u;
|
|
385
|
+
const previouslyOmitted = bounded.reduce((maximum, action) => {
|
|
386
|
+
const retainedCount = action.historyLimitations.reduce((count, item) => {
|
|
387
|
+
const match = byteOmissionPattern.exec(item);
|
|
388
|
+
return match ? Math.max(count, Number(match[1])) : count;
|
|
389
|
+
}, 0);
|
|
390
|
+
return Math.max(maximum, retainedCount);
|
|
391
|
+
}, 0);
|
|
392
|
+
bounded = bounded.map((action) => ({
|
|
393
|
+
...action,
|
|
394
|
+
historyLimitations: action.historyLimitations.filter((item) => !byteOmissionPattern.test(item)),
|
|
395
|
+
}));
|
|
396
|
+
let omittedActions = 0;
|
|
397
|
+
while (bounded.length > 1) {
|
|
398
|
+
bounded = bounded.slice(1);
|
|
399
|
+
omittedActions += 1;
|
|
400
|
+
const first = bounded[0];
|
|
401
|
+
const totalOmitted = previouslyOmitted + omittedActions;
|
|
402
|
+
bounded = [{
|
|
403
|
+
...first,
|
|
404
|
+
historyLimitations: lifecycleLimitations([
|
|
405
|
+
`${totalOmitted} earlier response action${totalOmitted === 1 ? '' : 's'} omitted by the per-Case response-history byte bound.`,
|
|
406
|
+
...first.historyLimitations,
|
|
407
|
+
]),
|
|
408
|
+
}, ...bounded.slice(1)];
|
|
409
|
+
if (bytes(bounded) <= MAX_CASE_ACTION_HISTORY_BYTES_PER_CASE)
|
|
410
|
+
return bounded;
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
return bounded;
|
|
414
|
+
}
|
|
415
|
+
export function normalizeCaseActions(raw, fallback, options = {}) {
|
|
416
|
+
if (!Array.isArray(raw))
|
|
417
|
+
return [];
|
|
418
|
+
const byId = new Map();
|
|
419
|
+
const inspected = raw.slice(0, MAX_CASE_ACTIONS * 2);
|
|
420
|
+
let invalid = 0;
|
|
421
|
+
for (const item of inspected) {
|
|
422
|
+
const normalized = normalizeAction(item, fallback, options);
|
|
423
|
+
if (!normalized) {
|
|
424
|
+
invalid += 1;
|
|
425
|
+
continue;
|
|
426
|
+
}
|
|
427
|
+
const existing = byId.get(normalized.id);
|
|
428
|
+
byId.set(normalized.id, existing ? mergeNormalizedActions(existing, normalized, fallback) : normalized);
|
|
429
|
+
}
|
|
430
|
+
const normalizedActions = [...byId.values()]
|
|
431
|
+
.sort((left, right) => Date.parse(left.createdAt) - Date.parse(right.createdAt) || compareCodeUnits(left.id, right.id));
|
|
432
|
+
const uninspected = Math.max(0, raw.length - inspected.length);
|
|
433
|
+
const omitted = Math.max(0, normalizedActions.length - MAX_CASE_ACTIONS);
|
|
434
|
+
const actions = normalizedActions.slice(-MAX_CASE_ACTIONS);
|
|
435
|
+
if (actions.length && (invalid || uninspected || omitted)) {
|
|
436
|
+
const first = actions[0];
|
|
437
|
+
actions[0] = {
|
|
438
|
+
...first,
|
|
439
|
+
historyLimitations: lifecycleLimitations([
|
|
440
|
+
...(invalid ? [`${invalid} malformed response-action candidate${invalid === 1 ? '' : 's'} omitted during normalisation.`] : []),
|
|
441
|
+
...(uninspected ? [`${uninspected} response-action candidate${uninspected === 1 ? '' : 's'} beyond the bounded inspection window were not traversed.`] : []),
|
|
442
|
+
...(omitted ? [`${omitted} earlier response action${omitted === 1 ? '' : 's'} omitted by the per-Case action bound.`] : []),
|
|
443
|
+
...first.historyLimitations,
|
|
444
|
+
]),
|
|
445
|
+
};
|
|
446
|
+
}
|
|
447
|
+
const actionIds = new Set(actions.map((action) => action.id));
|
|
448
|
+
const linked = actions.map((action) => {
|
|
449
|
+
const originActionId = action.originActionId && actionIds.has(action.originActionId) && action.originActionId !== action.id
|
|
450
|
+
? action.originActionId
|
|
451
|
+
: null;
|
|
452
|
+
const invalidOrigins = action.history.filter((event) => event.originActionId
|
|
453
|
+
&& (!actionIds.has(event.originActionId) || event.originActionId === action.id)).length;
|
|
454
|
+
return {
|
|
455
|
+
...action,
|
|
456
|
+
originActionId,
|
|
457
|
+
history: action.history.map((event) => ({
|
|
458
|
+
...event,
|
|
459
|
+
originActionId: event.originActionId && actionIds.has(event.originActionId) && event.originActionId !== action.id
|
|
460
|
+
? event.originActionId
|
|
461
|
+
: null,
|
|
462
|
+
})),
|
|
463
|
+
historyLimitations: lifecycleLimitations([
|
|
464
|
+
...action.historyLimitations,
|
|
465
|
+
...(invalidOrigins || (action.originActionId && !originActionId)
|
|
466
|
+
? ['One or more dangling or self-referential origin-action links were omitted.']
|
|
467
|
+
: []),
|
|
468
|
+
]),
|
|
469
|
+
};
|
|
470
|
+
});
|
|
471
|
+
return boundActionCollection(linked, fallback);
|
|
472
|
+
}
|
|
473
|
+
export function appendCaseAction(current, raw, now) {
|
|
474
|
+
if (current.length >= MAX_CASE_ACTIONS) {
|
|
475
|
+
throw new Error(`A Case can retain at most ${MAX_CASE_ACTIONS} response actions. No additional action was retained.`);
|
|
476
|
+
}
|
|
477
|
+
const item = record(raw);
|
|
478
|
+
const id = freshId('action');
|
|
479
|
+
const originActionId = typeof item.originActionId === 'string' && current.some((action) => action.id === item.originActionId)
|
|
480
|
+
? item.originActionId
|
|
481
|
+
: null;
|
|
482
|
+
if (item.originActionId != null && !originActionId)
|
|
483
|
+
throw new Error('A follow-on action requires an existing originating action.');
|
|
484
|
+
const history = [{
|
|
485
|
+
id: freshId('action-event'),
|
|
486
|
+
previousState: null,
|
|
487
|
+
nextState: 'drafting',
|
|
488
|
+
occurredAt: now,
|
|
489
|
+
sourceClass: 'analyst',
|
|
490
|
+
provenance: 'browser_local_action_creation',
|
|
491
|
+
reference: null,
|
|
492
|
+
evidencePinId: null,
|
|
493
|
+
limitations: [],
|
|
494
|
+
providerOutcome: null,
|
|
495
|
+
outcomeDetail: null,
|
|
496
|
+
originActionId,
|
|
497
|
+
}];
|
|
498
|
+
const created = normalizeAction({
|
|
499
|
+
...item,
|
|
500
|
+
id,
|
|
501
|
+
originActionId,
|
|
502
|
+
history,
|
|
503
|
+
historyOmitted: 0,
|
|
504
|
+
createdAt: now,
|
|
505
|
+
metadataUpdatedAt: now,
|
|
506
|
+
}, now, { sourceVersion: CASE_SCHEMA_VERSION });
|
|
507
|
+
if (!created)
|
|
508
|
+
throw new Error('An action requires a recipient or internal owner.');
|
|
509
|
+
return normalizeCaseActions([...current, created], now, { sourceVersion: CASE_SCHEMA_VERSION });
|
|
510
|
+
}
|
|
511
|
+
export function appendCaseActionTransition(current, actionId, raw, now, validPinIds) {
|
|
512
|
+
const action = current.find((item) => item.id === actionId);
|
|
513
|
+
if (!action)
|
|
514
|
+
throw new Error('That case action no longer exists.');
|
|
515
|
+
const item = record(raw);
|
|
516
|
+
const nextState = typeof item.nextState === 'string' && ACTION_STATES.has(item.nextState)
|
|
517
|
+
? item.nextState
|
|
518
|
+
: null;
|
|
519
|
+
const sourceClass = typeof item.sourceClass === 'string' && ACTION_EVENT_SOURCE_CLASSES.has(item.sourceClass)
|
|
520
|
+
? item.sourceClass
|
|
521
|
+
: 'analyst';
|
|
522
|
+
if (!nextState || !isLegalCaseActionTransition(action.state, nextState, sourceClass)) {
|
|
523
|
+
throw new Error(`The transition from ${action.state.replaceAll('_', ' ')} to ${String(item.nextState || 'that state').replaceAll('_', ' ')} is not permitted.`);
|
|
524
|
+
}
|
|
525
|
+
if (['ready_for_review', 'reviewed', 'authorised', 'submitted'].includes(nextState) && sourceClass !== 'analyst') {
|
|
526
|
+
throw new Error('Readiness, review, authorisation, and submission require an explicit analyst transition.');
|
|
527
|
+
}
|
|
528
|
+
const originActionId = typeof item.originActionId === 'string'
|
|
529
|
+
&& item.originActionId !== action.id
|
|
530
|
+
&& current.some((candidate) => candidate.id === item.originActionId)
|
|
531
|
+
? item.originActionId
|
|
532
|
+
: action.originActionId;
|
|
533
|
+
if (item.originActionId != null && originActionId !== item.originActionId) {
|
|
534
|
+
throw new Error('A referral or follow-on transition requires a distinct existing originating action.');
|
|
535
|
+
}
|
|
536
|
+
if (item.evidencePinId != null && (typeof item.evidencePinId !== 'string'
|
|
537
|
+
|| !SAFE_ID_RE.test(item.evidencePinId) || (validPinIds && !validPinIds.has(item.evidencePinId)))) {
|
|
538
|
+
throw new Error('An action transition evidence pin must reference a retained Case evidence pin.');
|
|
539
|
+
}
|
|
540
|
+
const occurredAt = optionalIso(item.occurredAt) ?? now;
|
|
541
|
+
const event = normalizeActionEvent({
|
|
542
|
+
...item,
|
|
543
|
+
id: freshId('action-event'),
|
|
544
|
+
previousState: action.state,
|
|
545
|
+
nextState,
|
|
546
|
+
occurredAt,
|
|
547
|
+
sourceClass,
|
|
548
|
+
provenance: text(item.provenance, MAX_RESPONSE_LABEL_LENGTH) || 'browser_local_explicit_transition',
|
|
549
|
+
originActionId,
|
|
550
|
+
}, action.id, currentActionNormalizationOptions(validPinIds));
|
|
551
|
+
if (!event)
|
|
552
|
+
throw new Error('The action transition contains an invalid time, provider outcome, or provenance field.');
|
|
553
|
+
const updated = normalizeAction({
|
|
554
|
+
...action,
|
|
555
|
+
history: [...action.history, event],
|
|
556
|
+
historyOmitted: action.historyOmitted,
|
|
557
|
+
}, now, currentActionNormalizationOptions(validPinIds));
|
|
558
|
+
return normalizeCaseActions(current.map((candidate) => candidate.id === actionId ? updated : candidate), now, currentActionNormalizationOptions(validPinIds));
|
|
559
|
+
}
|
|
560
|
+
const ACTION_REVIEW_MATERIAL_FIELDS = [
|
|
561
|
+
'type', 'recipient', 'contactSource', 'routeObservedAt', 'routeReviewAfter', 'contactLimitations', 'originActionId',
|
|
562
|
+
];
|
|
563
|
+
export function updateCaseAction(current, raw, now, validPinIds) {
|
|
564
|
+
const patch = record(raw);
|
|
565
|
+
const id = typeof patch.id === 'string' && SAFE_ID_RE.test(patch.id) ? patch.id : '';
|
|
566
|
+
const existing = current.find((item) => item.id === id);
|
|
567
|
+
if (!existing)
|
|
568
|
+
throw new Error('That case action no longer exists.');
|
|
569
|
+
if (Object.hasOwn(patch, 'state') || Object.hasOwn(patch, 'outcome') || Object.hasOwn(patch, 'providerOutcome') || Object.hasOwn(patch, 'reference')) {
|
|
570
|
+
throw new Error('State, reference, and provider outcomes must be recorded as an append-only action transition.');
|
|
571
|
+
}
|
|
572
|
+
const metadata = {
|
|
573
|
+
...existing,
|
|
574
|
+
...Object.fromEntries(ACTION_REVIEW_MATERIAL_FIELDS
|
|
575
|
+
.filter((field) => Object.hasOwn(patch, field))
|
|
576
|
+
.map((field) => [field, patch[field]])),
|
|
577
|
+
dueAt: Object.hasOwn(patch, 'dueAt') ? patch.dueAt : existing.dueAt,
|
|
578
|
+
followUpAt: Object.hasOwn(patch, 'followUpAt') ? patch.followUpAt : existing.followUpAt,
|
|
579
|
+
metadataUpdatedAt: now,
|
|
580
|
+
};
|
|
581
|
+
if (Object.hasOwn(patch, 'originActionId') && patch.originActionId !== null
|
|
582
|
+
&& (typeof patch.originActionId !== 'string' || patch.originActionId === id || !current.some((item) => item.id === patch.originActionId))) {
|
|
583
|
+
throw new Error('A follow-on action requires a distinct existing originating action.');
|
|
584
|
+
}
|
|
585
|
+
let updated = normalizeAction(metadata, now, currentActionNormalizationOptions(validPinIds));
|
|
586
|
+
if (!updated)
|
|
587
|
+
throw new Error('An action requires a recipient or internal owner.');
|
|
588
|
+
const materialChanged = ACTION_REVIEW_MATERIAL_FIELDS
|
|
589
|
+
.some((key) => Object.hasOwn(patch, key) && JSON.stringify(record(existing)[key]) !== JSON.stringify(record(updated)[key]));
|
|
590
|
+
if (materialChanged && ['submitted', 'acknowledged', 'terminal'].includes(existing.state)) {
|
|
591
|
+
throw new Error('Submitted or terminal action identity and recipient metadata cannot be rewritten; create a linked follow-on action instead.');
|
|
592
|
+
}
|
|
593
|
+
if (materialChanged && ['ready_for_review', 'reviewed', 'authorised'].includes(existing.state)) {
|
|
594
|
+
const invalidated = normalizeAction({
|
|
595
|
+
...updated,
|
|
596
|
+
history: [...updated.history, {
|
|
597
|
+
id: freshId('action-event'),
|
|
598
|
+
previousState: existing.state,
|
|
599
|
+
nextState: 'drafting',
|
|
600
|
+
occurredAt: now,
|
|
601
|
+
sourceClass: 'browser_local',
|
|
602
|
+
provenance: 'material_action_change',
|
|
603
|
+
reference: null,
|
|
604
|
+
evidencePinId: null,
|
|
605
|
+
limitations: ['Material action inputs changed after review; prior readiness, review, or authorisation no longer applies.'],
|
|
606
|
+
providerOutcome: null,
|
|
607
|
+
outcomeDetail: null,
|
|
608
|
+
originActionId: updated.originActionId,
|
|
609
|
+
}],
|
|
610
|
+
}, now, currentActionNormalizationOptions(validPinIds));
|
|
611
|
+
if (!invalidated)
|
|
612
|
+
throw new Error('The material action change could not be retained safely.');
|
|
613
|
+
updated = invalidated;
|
|
614
|
+
}
|
|
615
|
+
const interim = current.map((item) => item.id === id ? updated : item);
|
|
616
|
+
return patch.transition !== undefined
|
|
617
|
+
? appendCaseActionTransition(interim, id, patch.transition, now, validPinIds)
|
|
618
|
+
: normalizeCaseActions(interim, now, currentActionNormalizationOptions(validPinIds));
|
|
619
|
+
}
|
|
620
|
+
export function buildCaseActionOutcomeSummary(actions, nowRaw = new Date().toISOString()) {
|
|
621
|
+
const now = optionalIso(nowRaw) ?? new Date().toISOString();
|
|
622
|
+
const nowTime = Date.parse(now);
|
|
623
|
+
const active = actions.filter((item) => item.state !== 'terminal');
|
|
624
|
+
const latestOutcomes = actions
|
|
625
|
+
.flatMap((action) => action.history.filter((event) => event.applied && event.providerOutcome).map((event) => ({ action, event })))
|
|
626
|
+
.sort((left, right) => Date.parse(right.event.occurredAt) - Date.parse(left.event.occurredAt) || compareCodeUnits(right.event.id, left.event.id))
|
|
627
|
+
.slice(0, 5)
|
|
628
|
+
.map(({ action, event }) => ({
|
|
629
|
+
actionId: action.id,
|
|
630
|
+
recipient: action.recipient,
|
|
631
|
+
state: action.state,
|
|
632
|
+
providerOutcome: event.providerOutcome,
|
|
633
|
+
outcomeDetail: event.outcomeDetail,
|
|
634
|
+
occurredAt: event.occurredAt,
|
|
635
|
+
}));
|
|
636
|
+
return {
|
|
637
|
+
total: actions.length,
|
|
638
|
+
active: active.length,
|
|
639
|
+
drafting: actions.filter((item) => item.state === 'drafting').length,
|
|
640
|
+
readyForReview: actions.filter((item) => item.state === 'ready_for_review').length,
|
|
641
|
+
reviewed: actions.filter((item) => item.state === 'reviewed').length,
|
|
642
|
+
authorised: actions.filter((item) => item.state === 'authorised').length,
|
|
643
|
+
submitted: actions.filter((item) => item.state === 'submitted').length,
|
|
644
|
+
acknowledged: actions.filter((item) => item.state === 'acknowledged').length,
|
|
645
|
+
terminal: actions.filter((item) => item.state === 'terminal').length,
|
|
646
|
+
overdue: active.filter((item) => item.dueAt && Date.parse(item.dueAt) < nowTime).length,
|
|
647
|
+
followUpDue: active.filter((item) => item.followUpAt && Date.parse(item.followUpAt) <= nowTime).length,
|
|
648
|
+
withProviderOutcome: actions.filter((item) => item.providerOutcome !== null).length,
|
|
649
|
+
latestOutcomes,
|
|
650
|
+
};
|
|
651
|
+
}
|
|
652
|
+
export function mergeCaseActions(local, imported, fallback, validPinIds) {
|
|
653
|
+
return normalizeCaseActions([...local, ...imported], fallback, currentActionNormalizationOptions(validPinIds));
|
|
654
|
+
}
|