@slicedearth/whoisleuth-cli 2.5.1 → 2.7.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/DISCLOSURE +9 -6
- package/NOTICE +3 -2
- package/README.md +60 -67
- package/cli/artifact-validation/case-response.mjs +6 -814
- package/cli/artifact-validation/lookup-evidence.mjs +30 -3
- package/cli/artifact-validation/signed-review.mjs +2 -51
- package/cli/artifact-verify.mjs +41 -3
- package/cli/bulk-command-runner.mjs +4 -6
- package/cli/case-command.mjs +92 -16
- package/cli/case-pack.mjs +14 -3
- package/cli/ci-report.mjs +17 -5
- package/cli/command-definition.mjs +7 -6
- package/cli/discovery-scan-command-runner.mjs +4 -6
- package/cli/doctor.mjs +37 -3
- package/cli/domain-feed.mjs +61 -0
- package/cli/formatters/html.mjs +3 -0
- package/cli/formatters/json.mjs +9 -14
- package/cli/formatters/terminal-lookup.mjs +24 -1
- package/cli/history-command-definitions.mjs +1 -1
- package/cli/indicator-set-command.mjs +1 -2
- package/cli/intake-command.mjs +43 -3
- package/cli/investigation-artifacts.mjs +2 -3
- package/cli/investigation-package-review.mjs +2 -0
- package/cli/investigation-plan.mjs +15 -0
- package/cli/local-mmdb-review.mjs +27 -4
- package/cli/lookup-brief.mjs +11 -5
- package/cli/lookup-command-runner.mjs +21 -13
- package/cli/offline-evidence-review.mjs +51 -5
- package/cli/review-arguments.mjs +3 -2
- package/cli/review-command-definitions.mjs +23 -17
- package/cli/review-command-runner.mjs +6 -3
- package/cli/risk-calibration.mjs +6 -4
- package/cli/saved-lookup.mjs +22 -7
- package/cli/selected-input-worker.mjs +12 -2
- package/cli/source-reliability.mjs +12 -10
- package/cli/support-arguments.mjs +1 -1
- package/cli/support-command-definitions.mjs +5 -4
- package/cli/support-command-runner.mjs +9 -7
- package/cli/watchlist-review.mjs +43 -0
- package/cli/workflow-arguments.mjs +14 -0
- package/cli/workflow-command-definitions.mjs +27 -1
- package/cli/workflow-command-runner.mjs +32 -0
- package/docs/cli-reference.md +49 -11
- package/docs/cli.md +200 -26
- package/docs/contextual-reviews.md +38 -5
- package/docs/infrastructure-observations.md +135 -0
- package/docs/offline-intake-context.md +213 -0
- package/lib/credential-form-attribution.mjs +136 -0
- package/lib/credential-surface-profile.mjs +6 -2
- package/lib/dmarc-discovery.mjs +4 -2
- package/lib/dns-delegation-health.mjs +23 -7
- package/lib/dns-inheritance-review.mjs +14 -1
- package/lib/dns-intelligence.mjs +18 -4
- package/lib/domain-posture-analysis.mjs +53 -2
- package/lib/domain-posture-parsers.mjs +9 -0
- package/lib/domain-posture.mjs +29 -10
- package/lib/evidence-export-privacy.mjs +5 -2
- package/lib/evidence-export.mjs +10 -4
- package/lib/evidence-report.mjs +3 -1
- package/lib/favicon.mjs +13 -4
- package/lib/generated/cisa-kev-catalog.mjs +17 -4
- package/lib/generated/registrar-standing-catalogue.mjs +7 -7
- package/lib/generated/retire-browser-catalog.mjs +178 -5
- package/lib/html-signals.mjs +6 -0
- package/lib/idn-confusables.mjs +5 -1
- package/lib/lookup-child-profile-contract.mjs +28 -11
- package/lib/lookup-collection-receipt.mjs +52 -0
- package/lib/lookup-diagnostics.mjs +2 -12
- package/lib/lookup-progress.mjs +29 -31
- package/lib/lookup-response-contract.mjs +10 -0
- package/lib/lookup-response.mjs +15 -14
- package/lib/lookup-source-progress.mjs +4 -2
- package/lib/lookup.mjs +33 -2
- package/lib/operation-budget.mjs +4 -0
- package/lib/rdap-capabilities.mjs +43 -9
- package/lib/registry-insights.mjs +17 -6
- package/lib/service-binding-dns.mjs +19 -2
- package/lib/sslbl-certificates.generated.mjs +142 -140
- package/lib/static-html-analysis.mjs +32 -4
- package/lib/technology-evidence-role.mjs +1 -51
- package/lib/tls-intelligence.mjs +30 -5
- package/lib/website-enrichment.mjs +39 -23
- package/lib/whois-authority.mjs +56 -1
- package/lib/whois-chain.mjs +14 -4
- package/lib/whois-common-formats.mjs +3 -1
- package/lib/whois-contacts.mjs +13 -2
- package/lib/whois-contracts.mjs +2 -0
- package/lib/whois-normalization.mjs +4 -4
- package/package.json +9 -7
- package/packages/analysis/bounded-json.mjs +12 -2
- package/packages/analysis/risk-scoring.mjs +12 -9
- package/packages/cases/case-attachment-model.mjs +36 -1
- package/packages/cases/case-checkpoint-receipts.mjs +68 -0
- package/packages/cases/case-containment-handoff.mjs +195 -0
- package/packages/cases/case-evidence-links.mjs +44 -0
- package/packages/cases/case-evidence-model.mjs +51 -7
- package/packages/cases/case-migration-model.mjs +9 -1
- package/packages/cases/case-object-coverage.mjs +119 -0
- package/packages/cases/case-packet-correction.mjs +106 -0
- package/packages/cases/case-recheck-model.mjs +35 -5
- package/packages/cases/case-record-operations.mjs +87 -9
- package/packages/cases/case-record-projection.mjs +43 -5
- package/packages/cases/case-record-version-input.mjs +23 -1
- package/packages/cases/case-report.mjs +35 -4
- package/packages/cases/case-requested-evidence.mjs +1 -1
- package/packages/cases/case-response-actions.mjs +57 -1
- package/packages/cases/case-response-model.mjs +7 -0
- package/packages/cases/case-response-object.mjs +119 -0
- package/packages/cases/case-response-outcomes.mjs +81 -14
- package/packages/cases/case-response-packet-render.mjs +22 -3
- package/packages/cases/case-response-packet-validation.mjs +880 -0
- package/packages/cases/case-response-packet-vocabulary.mjs +12 -1
- package/packages/cases/case-response-packet.mjs +32 -17
- package/packages/cases/case-response-review-inputs.mjs +66 -8
- package/packages/cases/case-type-presentation.mjs +10 -0
- package/packages/cases/case-workflow-metadata.mjs +1 -17
- package/packages/comparison/capture-context.mjs +3 -2
- package/packages/contracts/analyst-interchange.mjs +7 -5
- package/packages/contracts/analyst-review-state-contract.mjs +1 -1
- package/packages/contracts/candidate-watch-review.mjs +54 -0
- package/packages/contracts/case-portability.mjs +292 -43
- package/packages/contracts/cli-command-semantics.mjs +3 -0
- package/packages/contracts/cli-doctor.mjs +4 -0
- package/packages/contracts/cli-lookup.mjs +57 -46
- package/packages/contracts/context-review.mjs +2 -1
- package/packages/contracts/external-observation-interchange.mjs +96 -28
- package/packages/contracts/lookup-evidence.mjs +13 -2
- package/packages/contracts/message-intake.mjs +7 -1
- package/packages/contracts/registry-insights.mjs +6 -0
- package/packages/contracts/risk-calibration.mjs +44 -29
- package/packages/contracts/schema-lifecycle.mjs +3 -1
- package/packages/contracts/web-capture.mjs +1 -1
- package/packages/contracts/workspace-portability.mjs +86 -16
- package/packages/evidence/collection-quality.mjs +1 -1
- package/packages/evidence/credential-form-attribution.mjs +60 -0
- package/packages/evidence/dmarc-policy-context.mjs +80 -0
- package/packages/evidence/image-regions.mjs +15 -0
- package/packages/evidence/lookup-collection-receipt.mjs +140 -0
- package/packages/evidence/lookup-collection-review.mjs +74 -0
- package/packages/evidence/record-identity.mjs +60 -16
- package/packages/evidence/technology-evidence-role.mjs +51 -0
- package/packages/interchange/external-findings-import.mjs +40 -21
- package/packages/interchange/json-output.mjs +14 -0
- package/packages/interchange/managed-indicator-export.mjs +3 -2
- package/packages/interchange/managed-indicator-set.mjs +13 -0
- package/packages/investigation/acquisition-decision-packet.mjs +78 -0
- package/packages/investigation/bulk-mail-exposure.mjs +2 -2
- package/packages/investigation/context-review.mjs +3 -3
- package/packages/investigation/document-intake.mjs +9 -3
- package/packages/investigation/domain-history-review.mjs +50 -12
- package/packages/investigation/domain-transition-review.mjs +2 -0
- package/packages/investigation/infrastructure-observation.mjs +234 -0
- package/packages/investigation/intake-context.mjs +284 -0
- package/packages/investigation/intake-indicators.mjs +116 -0
- package/packages/investigation/intake-phones.mjs +155 -0
- package/packages/investigation/intake-report.mjs +2 -1
- package/packages/investigation/investigation-manifest.mjs +33 -7
- package/packages/investigation/link-intake.mjs +38 -8
- package/packages/investigation/message-intake.mjs +55 -15
- package/packages/monitoring/domain-feed.mjs +321 -0
- package/packages/workspace/brand-candidate-workflow.mjs +351 -0
- package/packages/workspace/brand-keyword-campaign.mjs +241 -0
- package/packages/workspace/brand-profile-model.mjs +46 -2
- package/packages/workspace/bulk-session-model.mjs +12 -0
- package/packages/workspace/candidate-watch-handoff.mjs +195 -0
- package/packages/workspace/detection-rule-model.mjs +10 -2
- package/packages/workspace/shortlist-model.mjs +10 -2
- package/packages/workspace/watchlist-history.mjs +31 -4
- package/packages/workspace/watchlist-store.mjs +136 -7
- package/third-party-notices.txt +30 -30
|
@@ -6,6 +6,8 @@ import { normalizeCase } from "./case-record-operations.mjs";
|
|
|
6
6
|
import { normalizeCaseObjective } from "./case-incident-context.mjs";
|
|
7
7
|
import { normalizeDomain } from "../evidence/domain-name.mjs";
|
|
8
8
|
import { mergeImportedEvidenceHistory, normalizeEvidenceHistory } from "./case-evidence-model.mjs";
|
|
9
|
+
import { assertCaseCollectionReceiptAudience } from "./case-record-projection.mjs";
|
|
10
|
+
import { readCaseCheckpointReceipts, mergeCaseCheckpointReceipts } from "./case-checkpoint-receipts.mjs";
|
|
9
11
|
import { CASE_REPORT_SCHEMA, PUBLIC_CASE_SCHEMA_VERSION, PUBLISHED_V2_3_CASE_SCHEMA_VERSION, INCIDENT_CASE_SCHEMA_VERSION, caseReportVersionMatchesCase, CLI_CASE_PACK_CURRENT_REDACTION_KEYS, CLI_CASE_PACK_INTEGRITY_KEYS, CLI_CASE_PACK_LIMITATIONS, CLI_CASE_PACK_PACKET_KEYS, CLI_CASE_PACK_PUBLIC_REPORT_KEYS, CLI_CASE_PACK_REPORT_KEYS, CLI_CASE_PACK_ROOT_KEYS, CLI_CASE_PACK_SCHEMA, CLI_CASE_PACK_VERSION, } from "../contracts/case-portability.mjs";
|
|
10
12
|
import { canonicalArtifactJsonV2 } from "../evidence/artifact-integrity.mjs";
|
|
11
13
|
import { readCaseAttachments, mergeCaseAttachments } from "./case-attachment-model.mjs";
|
|
@@ -242,6 +244,7 @@ function extractImportPatch(raw, importedVersion) {
|
|
|
242
244
|
caseDomain: domain,
|
|
243
245
|
}),
|
|
244
246
|
evidencePins,
|
|
247
|
+
checkpointCollectionReceipts: readCaseCheckpointReceipts(record.checkpointCollectionReceipts, evidencePins, importedVersion),
|
|
245
248
|
decisions,
|
|
246
249
|
actions,
|
|
247
250
|
assertions,
|
|
@@ -297,6 +300,7 @@ function caseFromPatch(patch, now) {
|
|
|
297
300
|
source: patch.source ?? DEFAULT_SOURCE,
|
|
298
301
|
evidenceHistory: patch.evidenceHistory,
|
|
299
302
|
evidencePins: patch.evidencePins,
|
|
303
|
+
...(patch.checkpointCollectionReceipts === undefined ? {} : { checkpointCollectionReceipts: patch.checkpointCollectionReceipts }),
|
|
300
304
|
decisions: patch.decisions,
|
|
301
305
|
actions: patch.actions,
|
|
302
306
|
assertions: patch.assertions,
|
|
@@ -327,6 +331,7 @@ function applyImportPatch(local, patch) {
|
|
|
327
331
|
const historySelection = mergeImportedEvidenceHistory(local.evidenceHistory, patch.evidenceHistory, local.domain);
|
|
328
332
|
const pinSelection = retainLocalAuthoredRecords(local.evidencePins, patch.evidencePins, MAX_CASE_EVIDENCE_PINS);
|
|
329
333
|
const evidencePins = normalizeCaseEvidencePins(pinSelection.records, fallback);
|
|
334
|
+
const checkpointCollectionReceipts = mergeCaseCheckpointReceipts(local, patch, evidencePins);
|
|
330
335
|
const pinIds = new Set(evidencePins.map((item) => item.id));
|
|
331
336
|
const actions = mergeCaseActions(local.actions, patch.actions, fallback, pinIds);
|
|
332
337
|
const assertionSelection = retainLocalAuthoredRecords(local.assertions, patch.assertions, MAX_CASE_ASSERTIONS);
|
|
@@ -351,8 +356,9 @@ function applyImportPatch(local, patch) {
|
|
|
351
356
|
+ assertionSelection.omitted
|
|
352
357
|
+ trailSelection.omitted
|
|
353
358
|
+ sightingSelection.omitted;
|
|
359
|
+
const { checkpointCollectionReceipts: _previousReceipts, ...localFields } = local;
|
|
354
360
|
return { record: {
|
|
355
|
-
...
|
|
361
|
+
...localFields,
|
|
356
362
|
title: patch.title !== undefined && importNewer ? patch.title : local.title ?? '',
|
|
357
363
|
status: patch.status !== undefined && importNewer ? patch.status : local.status,
|
|
358
364
|
disposition: patch.disposition !== undefined && importNewer ? patch.disposition : local.disposition,
|
|
@@ -361,6 +367,7 @@ function applyImportPatch(local, patch) {
|
|
|
361
367
|
source: patch.source !== undefined && importNewer ? patch.source : local.source,
|
|
362
368
|
evidenceHistory: historySelection.records,
|
|
363
369
|
evidencePins,
|
|
370
|
+
...(checkpointCollectionReceipts === undefined ? {} : { checkpointCollectionReceipts }),
|
|
364
371
|
decisions,
|
|
365
372
|
actions,
|
|
366
373
|
assertions,
|
|
@@ -465,6 +472,7 @@ function caseCollectionImportEnvelope(importedRaw) {
|
|
|
465
472
|
|| caseTimestampOrNull(root.exportedAt) !== root.exportedAt) {
|
|
466
473
|
throw new Error('The WHOISleuth CLI Case-pack envelope is invalid.');
|
|
467
474
|
}
|
|
475
|
+
assertCaseCollectionReceiptAudience(root, audience);
|
|
468
476
|
for (const reportValue of reports) {
|
|
469
477
|
const report = objectRecord(reportValue);
|
|
470
478
|
assertOnlyEnvelopeKeys(report, rootVersion === PUBLIC_CASE_SCHEMA_VERSION ? CLI_CASE_PACK_PUBLIC_REPORT_KEYS : CLI_CASE_PACK_REPORT_KEYS, 'CLI Case-pack report');
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { retainedCaseResponseObjects, sameCaseResponseObject, } from "./case-response-object.mjs";
|
|
2
|
+
import { correctionDelivery } from "./case-packet-correction.mjs";
|
|
3
|
+
export function caseIncidentObjects(record) {
|
|
4
|
+
const objects = new Map();
|
|
5
|
+
const explicit = retainedCaseResponseObjects(record);
|
|
6
|
+
for (const target of record.workflowMetadata?.incidentTargets ?? []) {
|
|
7
|
+
const matching = explicit.filter((object) => object.incidentTargetId === target.id && object.identifier === target.url);
|
|
8
|
+
for (const object of matching.length
|
|
9
|
+
? matching
|
|
10
|
+
: [{ kind: 'other', identifier: target.url, incidentTargetId: target.id }])
|
|
11
|
+
objects.set(JSON.stringify(object), { target, object, targetRetained: true });
|
|
12
|
+
}
|
|
13
|
+
for (const object of explicit)
|
|
14
|
+
if (!objects.has(JSON.stringify(object)))
|
|
15
|
+
objects.set(JSON.stringify(object), {
|
|
16
|
+
object,
|
|
17
|
+
targetRetained: false,
|
|
18
|
+
target: {
|
|
19
|
+
id: object.incidentTargetId ?? `${object.kind}-${object.identifier}`,
|
|
20
|
+
url: object.identifier,
|
|
21
|
+
state: 'open',
|
|
22
|
+
createdAt: record.createdAt ?? '',
|
|
23
|
+
updatedAt: record.updatedAt ?? '',
|
|
24
|
+
},
|
|
25
|
+
});
|
|
26
|
+
return [...objects.values()];
|
|
27
|
+
}
|
|
28
|
+
export function buildCaseIncidentCoverage(record) {
|
|
29
|
+
return caseIncidentObjects(record).map(({ target, object, targetRetained }) => {
|
|
30
|
+
const contexts = (record.actions ?? [])
|
|
31
|
+
.map((action) => ({
|
|
32
|
+
action,
|
|
33
|
+
currentlyBound: Boolean(action.responseObjects?.some((bound) => sameCaseResponseObject(bound, object))),
|
|
34
|
+
events: action.history.filter((event) => event.responseObjects?.some((bound) => sameCaseResponseObject(bound, object))),
|
|
35
|
+
}))
|
|
36
|
+
.filter((context) => context.currentlyBound || context.events.length);
|
|
37
|
+
const actions = contexts
|
|
38
|
+
.filter((context) => context.currentlyBound)
|
|
39
|
+
.map((context) => context.action);
|
|
40
|
+
const responseContexts = contexts.map(({ action, currentlyBound, events }) => {
|
|
41
|
+
let correction = null;
|
|
42
|
+
if (currentlyBound && action.correction) {
|
|
43
|
+
let linkAvailable = false;
|
|
44
|
+
try {
|
|
45
|
+
linkAvailable = Boolean(correctionDelivery(record.actions ?? [], action));
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
/* Historical linkage can be unavailable; the draft is not re-authorised here. */
|
|
49
|
+
}
|
|
50
|
+
correction = {
|
|
51
|
+
purpose: action.correction.purpose,
|
|
52
|
+
originActionId: action.originActionId,
|
|
53
|
+
deliveryEventId: action.correction.deliveryEventId,
|
|
54
|
+
packetDigestSha256: action.correction.packetDigestSha256,
|
|
55
|
+
packetVersion: action.correction.packetVersion,
|
|
56
|
+
linkAvailable,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
return {
|
|
60
|
+
actionId: action.id,
|
|
61
|
+
type: action.type,
|
|
62
|
+
recipient: action.recipient,
|
|
63
|
+
contactSource: action.contactSource,
|
|
64
|
+
routeObservedAt: action.routeObservedAt,
|
|
65
|
+
routeReviewAfter: action.routeReviewAfter,
|
|
66
|
+
contactLimitations: action.contactLimitations,
|
|
67
|
+
metadataUpdatedAt: action.metadataUpdatedAt,
|
|
68
|
+
currentState: action.state,
|
|
69
|
+
currentlyBound,
|
|
70
|
+
historyOmitted: action.historyOmitted,
|
|
71
|
+
historyLimitations: action.historyLimitations,
|
|
72
|
+
reviewEvents: events.filter((event) => event.sourceClass === 'analyst' &&
|
|
73
|
+
(event.nextState === 'reviewed' || event.nextState === 'authorised')),
|
|
74
|
+
// A repeated submitted-state update alone does not establish another delivery.
|
|
75
|
+
deliveryEvents: events.filter((event) => event.sourceClass === 'analyst' &&
|
|
76
|
+
event.nextState === 'submitted' &&
|
|
77
|
+
(event.previousState === 'authorised' || Boolean(event.packetReceipt))),
|
|
78
|
+
correction,
|
|
79
|
+
};
|
|
80
|
+
});
|
|
81
|
+
const responseEvents = contexts.flatMap(({ action, events }) => events
|
|
82
|
+
.filter((event) => event.providerOutcome || event.objectOutcome || event.nextState === 'acknowledged')
|
|
83
|
+
.map((event) => ({
|
|
84
|
+
actionId: action.id,
|
|
85
|
+
eventId: event.id,
|
|
86
|
+
state: event.nextState,
|
|
87
|
+
outcome: event.objectOutcome ?? event.providerOutcome,
|
|
88
|
+
occurredAt: event.occurredAt,
|
|
89
|
+
sourceClass: event.sourceClass,
|
|
90
|
+
applied: event.applied,
|
|
91
|
+
provenance: event.provenance,
|
|
92
|
+
reference: event.reference,
|
|
93
|
+
})));
|
|
94
|
+
const reviews = (record.observedEffects?.reviews ?? []).filter((review) => sameCaseResponseObject(review.responseObject, object));
|
|
95
|
+
const latestAt = Math.max(...reviews.map((review) => Date.parse(review.observedAt)));
|
|
96
|
+
const latest = reviews.filter((review) => Date.parse(review.observedAt) === latestAt);
|
|
97
|
+
return {
|
|
98
|
+
target,
|
|
99
|
+
targetRetained,
|
|
100
|
+
responseObject: object,
|
|
101
|
+
hostname: object.kind === 'domain' || object.kind === 'hostname'
|
|
102
|
+
? object.identifier
|
|
103
|
+
: new URL(object.identifier).hostname,
|
|
104
|
+
actionCoverage: actions.length ? 'bound' : 'unknown',
|
|
105
|
+
actionIds: actions.map((action) => action.id),
|
|
106
|
+
responseContexts,
|
|
107
|
+
providerEvents: responseEvents.filter((event) => event.applied),
|
|
108
|
+
unappliedProviderEvents: responseEvents.filter((event) => !event.applied),
|
|
109
|
+
observationCoverage: !reviews.length
|
|
110
|
+
? 'unknown'
|
|
111
|
+
: new Set(latest.map((review) => `${review.state}\u0000${review.objectOutcome ?? ''}`))
|
|
112
|
+
.size > 1
|
|
113
|
+
? 'ambiguous'
|
|
114
|
+
: 'available',
|
|
115
|
+
reviews,
|
|
116
|
+
closures: (record.closures?.records ?? []).filter((closure) => sameCaseResponseObject(closure.responseObject, object)),
|
|
117
|
+
};
|
|
118
|
+
});
|
|
119
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// Local delivery linkage, not authentication, external acceptance or nonrepudiation.
|
|
2
|
+
import { array, enumeration, exact, HEX_DIGEST_RE, iso, text } from "../evidence/artifact-structure.mjs";
|
|
3
|
+
import { assertWorkspaceInputGraph } from "../workspace/hostile-input.mjs";
|
|
4
|
+
import { MAX_DECISION_PIN_REFERENCES, MAX_RESPONSE_RATIONALE_LENGTH, MAX_RESPONSE_RECIPIENT_LENGTH, SUPPORTED_CASE_RESPONSE_PACKET_VERSIONS } from "../contracts/case-portability.mjs";
|
|
5
|
+
import { isValidAsciiHostname } from "../contracts/domain-name.mjs";
|
|
6
|
+
import { readCaseResponseObjects, sameCaseResponseObject } from "./case-response-object.mjs";
|
|
7
|
+
import { RESPONSE_PACKET_PROFILE_IDS } from "./case-response-packet-vocabulary.mjs";
|
|
8
|
+
export const CASE_DELIVERY_RECEIPT_LIMITATION = 'This retained local receipt identifies canonical packet JSON and analyst-recorded delivery, not delivered message or PDF bytes, external receipt, factual truth, authorship authentication or provider acceptance.';
|
|
9
|
+
const id = (value, label) => {
|
|
10
|
+
const result = text(value, label, 64);
|
|
11
|
+
if (!/^[A-Za-z0-9_-]{1,64}$/u.test(result))
|
|
12
|
+
throw new TypeError(`${label} is invalid.`);
|
|
13
|
+
return result;
|
|
14
|
+
};
|
|
15
|
+
const digest = (value) => {
|
|
16
|
+
if (typeof value !== 'string' || !HEX_DIGEST_RE.test(value))
|
|
17
|
+
throw new TypeError('Select an exact recorded packet digest.');
|
|
18
|
+
return value;
|
|
19
|
+
};
|
|
20
|
+
const packetVersion = (value) => {
|
|
21
|
+
if (!SUPPORTED_CASE_RESPONSE_PACKET_VERSIONS.some(version => version === value))
|
|
22
|
+
throw new TypeError('The recorded packet version is unsupported.');
|
|
23
|
+
return value;
|
|
24
|
+
};
|
|
25
|
+
const statement = (value, label, required = true) => {
|
|
26
|
+
const result = text(value, label, MAX_RESPONSE_RATIONALE_LENGTH, !required).trim();
|
|
27
|
+
if (required && !result)
|
|
28
|
+
throw new TypeError(`${label} is required.`);
|
|
29
|
+
return result;
|
|
30
|
+
};
|
|
31
|
+
export function readCaseDeliveryPacketReceipt(value) {
|
|
32
|
+
if (value === undefined)
|
|
33
|
+
return undefined;
|
|
34
|
+
assertWorkspaceInputGraph(value, 'Delivery packet receipt');
|
|
35
|
+
const row = exact(value, ['version', 'caseId', 'actionId', 'target', 'packetDigestSha256', 'packetVersion', 'packetGeneratedAt', 'recipient', 'profile', 'responseObjects'], 'Delivery packet receipt');
|
|
36
|
+
if (row.version !== 1)
|
|
37
|
+
throw new TypeError('The delivery receipt version is unsupported.');
|
|
38
|
+
const target = text(row.target, 'Receipt target', 253);
|
|
39
|
+
if (!isValidAsciiHostname(target) || target !== target.toLowerCase())
|
|
40
|
+
throw new TypeError('The delivery receipt target is invalid.');
|
|
41
|
+
iso(row.packetGeneratedAt, 'Packet generation time');
|
|
42
|
+
const recipient = text(row.recipient, 'Receipt recipient', MAX_RESPONSE_RECIPIENT_LENGTH).trim();
|
|
43
|
+
if (!recipient)
|
|
44
|
+
throw new TypeError('The receipt recipient is required.');
|
|
45
|
+
const responseObjects = readCaseResponseObjects(row.responseObjects);
|
|
46
|
+
if (!responseObjects?.length)
|
|
47
|
+
throw new TypeError('The receipt requires an explicit nonempty delivered object scope.');
|
|
48
|
+
return Object.freeze({ version: 1, caseId: id(row.caseId, 'Receipt Case'), actionId: id(row.actionId, 'Receipt action'), target,
|
|
49
|
+
packetDigestSha256: digest(row.packetDigestSha256), packetVersion: packetVersion(row.packetVersion), packetGeneratedAt: row.packetGeneratedAt,
|
|
50
|
+
recipient, profile: enumeration(row.profile, RESPONSE_PACKET_PROFILE_IDS, 'Receipt audience'), responseObjects });
|
|
51
|
+
}
|
|
52
|
+
export function readCasePacketCorrection(value) {
|
|
53
|
+
if (value === undefined)
|
|
54
|
+
return undefined;
|
|
55
|
+
assertWorkspaceInputGraph(value, 'Packet correction');
|
|
56
|
+
const row = exact(value, ['version', 'purpose', 'deliveryEventId', 'packetDigestSha256', 'packetVersion', 'reason', 'previousStatement', 'correctedStatement', 'evidencePinIds', 'profile'], 'Packet correction');
|
|
57
|
+
if (row.version !== 1)
|
|
58
|
+
throw new TypeError('The correction version is unsupported.');
|
|
59
|
+
const purpose = enumeration(row.purpose, ['correction', 'retraction_request'], 'Correction purpose');
|
|
60
|
+
const pins = array(row.evidencePinIds, 'Correction evidence', MAX_DECISION_PIN_REFERENCES).map(value => id(value, 'Correction pin'));
|
|
61
|
+
if (!pins.length || new Set(pins).size !== pins.length)
|
|
62
|
+
throw new TypeError('Select distinct retained correction evidence pins.');
|
|
63
|
+
return Object.freeze({ version: 1, purpose, deliveryEventId: id(row.deliveryEventId, 'Original delivery event'),
|
|
64
|
+
packetDigestSha256: digest(row.packetDigestSha256), packetVersion: packetVersion(row.packetVersion),
|
|
65
|
+
reason: statement(row.reason, 'Correction reason'), previousStatement: statement(row.previousStatement, 'Previous statement'),
|
|
66
|
+
correctedStatement: statement(row.correctedStatement, 'Corrected statement', purpose === 'correction'),
|
|
67
|
+
evidencePinIds: Object.freeze(pins), profile: enumeration(row.profile, RESPONSE_PACKET_PROFILE_IDS, 'Correction audience') });
|
|
68
|
+
}
|
|
69
|
+
export function assertDeliveryPacketReceipt(receipt, event, actionId) {
|
|
70
|
+
if (!receipt)
|
|
71
|
+
return;
|
|
72
|
+
if (receipt.actionId !== actionId || !['authorised', 'submitted'].includes(event.previousState ?? '') || event.nextState !== 'submitted' || event.sourceClass !== 'analyst'
|
|
73
|
+
|| event.reference !== `response-packet-sha256:${receipt.packetDigestSha256}`
|
|
74
|
+
|| JSON.stringify(event.responseObjects ?? []) !== JSON.stringify(receipt.responseObjects)
|
|
75
|
+
|| (event.occurredAt && Date.parse(event.occurredAt) < Date.parse(receipt.packetGeneratedAt)))
|
|
76
|
+
throw new TypeError('The packet receipt must match its exact analyst-recorded delivery and object scope.');
|
|
77
|
+
}
|
|
78
|
+
export function correctionDelivery(actions, action, context) {
|
|
79
|
+
const correction = action.correction;
|
|
80
|
+
if (!correction)
|
|
81
|
+
return null;
|
|
82
|
+
const original = actions.find(item => item.id === action.originActionId);
|
|
83
|
+
const event = original?.history.find(item => item.id === correction.deliveryEventId);
|
|
84
|
+
const receipt = event?.packetReceipt;
|
|
85
|
+
if (!original || !event?.applied || !receipt)
|
|
86
|
+
throw new TypeError('An exact-linked correction requires the retained receipt of the explicitly selected delivery. Legacy digest-only or omitted receipts cannot authorise it.');
|
|
87
|
+
assertDeliveryPacketReceipt(receipt, event, original.id);
|
|
88
|
+
if (receipt.recipient === '[redacted]' || action.recipient === '[redacted]' || receipt.recipient !== action.recipient
|
|
89
|
+
|| original.recipient !== receipt.recipient || correction.profile !== receipt.profile
|
|
90
|
+
|| correction.packetDigestSha256 !== receipt.packetDigestSha256 || correction.packetVersion !== receipt.packetVersion
|
|
91
|
+
|| !action.responseObjects?.length || action.responseObjects.some(object => !receipt.responseObjects.some(previous => sameCaseResponseObject(previous, object))))
|
|
92
|
+
throw new TypeError('Correction recipient, version, digest, audience and object subset must match the selected delivered packet.');
|
|
93
|
+
if (context && (receipt.caseId !== context.caseId || receipt.target !== context.target || (context.profile && context.profile !== correction.profile)
|
|
94
|
+
|| (context.selectedPinIds && correction.evidencePinIds.some(pin => !context.selectedPinIds.has(pin)))))
|
|
95
|
+
throw new TypeError('Review the current Case target, audience and every retained correction pin before preparation.');
|
|
96
|
+
return { original, event, receipt };
|
|
97
|
+
}
|
|
98
|
+
/** Called only with the exact completed packet; never uses a later editable draft. */
|
|
99
|
+
export function deliveryReceiptForPacket(packet) {
|
|
100
|
+
const action = packet.escalationHistory.find(action => action.actionId === packet.actionBinding.selectedActionId);
|
|
101
|
+
if (!action || !packet.recipientRoute || packet.authorisation.status !== 'authorised' || !action.responseObjects?.length)
|
|
102
|
+
throw new TypeError('A retained exact receipt requires an authorised action-bound packet with explicit object scope.');
|
|
103
|
+
return readCaseDeliveryPacketReceipt({ version: 1, caseId: packet.case.id, actionId: action.actionId, target: packet.case.domain,
|
|
104
|
+
packetDigestSha256: packet.integrity.digestSha256, packetVersion: packet.schemaVersion, packetGeneratedAt: packet.generatedAt,
|
|
105
|
+
recipient: packet.recipientRoute.contact, profile: packet.profile.id, responseObjects: action.responseObjects });
|
|
106
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { isValidAsciiHostname } from "../contracts/domain-name.mjs";
|
|
2
2
|
import { INCIDENT_CASE_SCHEMA_VERSION, MAX_RESPONSE_RATIONALE_LENGTH, } from "../contracts/case-portability.mjs";
|
|
3
|
-
import { enumeration,
|
|
3
|
+
import { enumeration, exactOptional, text } from "../evidence/artifact-structure.mjs";
|
|
4
|
+
import { readCaseResponseObject, sameCaseResponseObject } from "./case-response-object.mjs";
|
|
4
5
|
import { casePinHasCompleteObservation } from "./case-evidence-quality.mjs";
|
|
5
6
|
export const CASE_RECHECK_CONDITIONS = Object.freeze({
|
|
6
7
|
unknown: 'Not established',
|
|
@@ -13,7 +14,7 @@ function reference(value, label) {
|
|
|
13
14
|
throw new TypeError(`${label} is invalid.`);
|
|
14
15
|
return id;
|
|
15
16
|
}
|
|
16
|
-
function contextFields(item) {
|
|
17
|
+
function contextFields(item, sourceVersion) {
|
|
17
18
|
const targetHostname = text(item.targetHostname, 'Recheck target hostname', 253);
|
|
18
19
|
if (targetHostname !== targetHostname.toLowerCase() || !isValidAsciiHostname(targetHostname)) {
|
|
19
20
|
throw new TypeError('Use one normalised hostname for the recheck target, without a path or query.');
|
|
@@ -21,10 +22,16 @@ function contextFields(item) {
|
|
|
21
22
|
const conditions = text(item.conditions, 'Recheck comparison conditions', MAX_RESPONSE_RATIONALE_LENGTH);
|
|
22
23
|
if (!conditions.trim())
|
|
23
24
|
throw new TypeError('Describe the conditions needed to compare the observations.');
|
|
25
|
+
const responseObject = readCaseResponseObject(item.responseObject, sourceVersion);
|
|
26
|
+
if (responseObject && (responseObject.kind === 'domain' || responseObject.kind === 'hostname'
|
|
27
|
+
? responseObject.identifier : new URL(responseObject.identifier).hostname) !== targetHostname) {
|
|
28
|
+
throw new TypeError('The recheck target hostname must match the exact object snapshot.');
|
|
29
|
+
}
|
|
24
30
|
return Object.freeze({
|
|
25
31
|
targetHostname,
|
|
26
32
|
conditions,
|
|
27
33
|
baselinePinId: item.baselinePinId === null ? null : reference(item.baselinePinId, 'Recheck baseline pin'),
|
|
34
|
+
...(responseObject ? { responseObject } : {}),
|
|
28
35
|
});
|
|
29
36
|
}
|
|
30
37
|
/** Optional current-format context; historical records have no invented question. */
|
|
@@ -33,19 +40,19 @@ export function readCaseRecheckContext(value, sourceVersion) {
|
|
|
33
40
|
return undefined;
|
|
34
41
|
if (sourceVersion != null && sourceVersion < INCIDENT_CASE_SCHEMA_VERSION)
|
|
35
42
|
throw new TypeError('This Case format cannot contain structured recheck questions.');
|
|
36
|
-
return contextFields(
|
|
43
|
+
return contextFields(exactOptional(value, ['targetHostname', 'baselinePinId', 'conditions'], ['responseObject'], 'Recheck question context'), sourceVersion);
|
|
37
44
|
}
|
|
38
45
|
export function readCaseRecheckAnswerContext(value, sourceVersion) {
|
|
39
46
|
if (value === undefined)
|
|
40
47
|
return undefined;
|
|
41
48
|
if (sourceVersion != null && sourceVersion < INCIDENT_CASE_SCHEMA_VERSION)
|
|
42
49
|
throw new TypeError('This Case format cannot contain structured recheck answers.');
|
|
43
|
-
const item =
|
|
50
|
+
const item = exactOptional(value, ['targetHostname', 'baselinePinId', 'conditions', 'questionId', 'question', 'conditionsMatch'], ['responseObject'], 'Recheck answer context');
|
|
44
51
|
const question = text(item.question, 'Recheck question', MAX_RESPONSE_RATIONALE_LENGTH);
|
|
45
52
|
if (!question.trim())
|
|
46
53
|
throw new TypeError('A recheck answer requires its original question.');
|
|
47
54
|
return Object.freeze({
|
|
48
|
-
...contextFields(item),
|
|
55
|
+
...contextFields(item, sourceVersion),
|
|
49
56
|
questionId: reference(item.questionId, 'Recheck question ID'),
|
|
50
57
|
question,
|
|
51
58
|
conditionsMatch: enumeration(item.conditionsMatch, Object.keys(CASE_RECHECK_CONDITIONS), 'Recheck condition comparison'),
|
|
@@ -92,6 +99,10 @@ const RECHECK_COMPARISON_MESSAGES = {
|
|
|
92
99
|
later_observation_needed: 'A later source observation is needed; reviewing the baseline again is not a recheck.',
|
|
93
100
|
baseline_target_mismatch: 'The baseline concerns a different hostname.',
|
|
94
101
|
different_field_or_source: 'The observations use different fields or sources.',
|
|
102
|
+
missing_object_baseline: 'An object-specific baseline is required for this exact-object comparison.',
|
|
103
|
+
baseline_object_mismatch: 'The baseline does not explicitly concern this same object.',
|
|
104
|
+
current_object_mismatch: 'The current observation does not explicitly concern this same object.',
|
|
105
|
+
incomplete_object_baseline: 'The exact-object baseline must be a complete source observation.',
|
|
95
106
|
};
|
|
96
107
|
/** Admissibility depends on these reasons, not on presentation warnings. */
|
|
97
108
|
export function caseRecheckComparisonBlockers(context, pins, current) {
|
|
@@ -102,6 +113,16 @@ export function caseRecheckComparisonBlockers(context, pins, current) {
|
|
|
102
113
|
: 'unconfirmed_conditions');
|
|
103
114
|
}
|
|
104
115
|
const baseline = pins.find((pin) => pin.id === context.baselinePinId);
|
|
116
|
+
if (context.responseObject) {
|
|
117
|
+
if (!baseline)
|
|
118
|
+
blockers.push('missing_object_baseline');
|
|
119
|
+
else if (!sameCaseResponseObject(baseline.responseObject, context.responseObject))
|
|
120
|
+
blockers.push('baseline_object_mismatch');
|
|
121
|
+
if (baseline && !casePinHasCompleteObservation(baseline))
|
|
122
|
+
blockers.push('incomplete_object_baseline');
|
|
123
|
+
if (!current || !sameCaseResponseObject(current.responseObject, context.responseObject))
|
|
124
|
+
blockers.push('current_object_mismatch');
|
|
125
|
+
}
|
|
105
126
|
if (context.baselinePinId && !baseline)
|
|
106
127
|
blockers.push('missing_baseline');
|
|
107
128
|
if (current) {
|
|
@@ -147,3 +168,12 @@ export function assertRecheckNonReproduction(state, context, completeness, pins,
|
|
|
147
168
|
throw new Error('Not reproduced requires a complete observation under comparable conditions. Record unavailable or describe the limited observation instead.');
|
|
148
169
|
}
|
|
149
170
|
}
|
|
171
|
+
export const COMPARATIVE_CASE_OBJECT_OUTCOMES = Object.freeze(['removed', 'restricted', 'suspended', 'delisted', 'transferred', 'restored']);
|
|
172
|
+
/** Technical state changes need an exact-object baseline; procedural dispute is distinct. */
|
|
173
|
+
export function assertCaseObjectObservationOutcome(outcome, context, completeness, pins, current, observedAt) {
|
|
174
|
+
if (!outcome || !COMPARATIVE_CASE_OBJECT_OUTCOMES.includes(outcome))
|
|
175
|
+
return;
|
|
176
|
+
if (!context?.responseObject)
|
|
177
|
+
throw new TypeError('An independent object state change requires a saved exact-object baseline question and comparable evidence.');
|
|
178
|
+
assertRecheckNonReproduction('not_reproduced', context, completeness, pins, current, observedAt);
|
|
179
|
+
}
|
|
@@ -3,14 +3,17 @@
|
|
|
3
3
|
import { normalizeCaseObjective, parseIncidentUrlContext, } from "./case-incident-context.mjs";
|
|
4
4
|
import { selectExistingCase } from "./case-selection.mjs";
|
|
5
5
|
import { emptyCaseWorkflowMetadata, updateCaseWorkflowMetadata } from "./case-workflow-metadata.mjs";
|
|
6
|
+
import { assertCaseResponseObject, readCaseResponseObject, readCaseResponseObjects, sameCaseResponseObject } from "./case-response-object.mjs";
|
|
7
|
+
import { correctionDelivery, readCasePacketCorrection, readCaseDeliveryPacketReceipt } from "./case-packet-correction.mjs";
|
|
6
8
|
import { readCaseWorkflowFields } from "./case-workflow-migration.mjs";
|
|
7
9
|
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";
|
|
8
10
|
import { MAX_CASES, MAX_NOTES_PER_CASE, } from "./case-record-contracts.mjs";
|
|
9
11
|
import { MAX_CASE_OBJECTIVE_LENGTH, MAX_RESPONSE_VALUE_LENGTH } from "../contracts/case-portability.mjs";
|
|
10
12
|
import { caseRecordVersionInput } from "./case-record-version-input.mjs";
|
|
13
|
+
import { readCaseCheckpointReceipts, mergeCaseCheckpointReceipts } from "./case-checkpoint-receipts.mjs";
|
|
11
14
|
import { readCaseAttachments } from "./case-attachment-model.mjs";
|
|
12
15
|
import { readCaseEvidenceLinks, appendCaseEvidenceLink, withdrawCaseEvidenceLink } from "./case-evidence-links.mjs";
|
|
13
|
-
import { assertCurrentRecheckQuestion, assertRecheckNonReproduction, readCaseRecheckAnswerContext } from "./case-recheck-model.mjs";
|
|
16
|
+
import { assertCurrentRecheckQuestion, assertRecheckNonReproduction, assertCaseObjectObservationOutcome, readCaseRecheckAnswerContext } from "./case-recheck-model.mjs";
|
|
14
17
|
import { caseDispositionSupportsDefensiveResponse, caseStatusIsClosed, caseStatusOptionsForDirectEdit, caseStatusRequiresClosure, isReviewedCaseDisposition, } from "./case-record-decisions.mjs";
|
|
15
18
|
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";
|
|
16
19
|
import { assertCaseBrandProfileIds, normalizeCaseBrandProfileIds, } from "./case-brand-profile-references.mjs";
|
|
@@ -64,8 +67,14 @@ export function normalizeCase(raw, existing, nowIso, sourceVersion) {
|
|
|
64
67
|
const createdAt = existing ? existing.createdAt : caseTimestampOrNull(record.createdAt, sourceVersion) || now;
|
|
65
68
|
const updatedAt = caseTimestampOrNull(record.updatedAt, sourceVersion) || createdAt;
|
|
66
69
|
const evidencePins = normalizeCaseEvidencePins(record.evidencePins, updatedAt, timestampOptions);
|
|
70
|
+
const checkpointCollectionReceipts = readCaseCheckpointReceipts(record.checkpointCollectionReceipts, evidencePins, sourceVersion);
|
|
67
71
|
const pinIds = new Set(evidencePins.map((item) => item.id));
|
|
68
72
|
const actions = normalizeCaseActions(record.actions, updatedAt, { ...timestampOptions, validEvidencePinIds: pinIds });
|
|
73
|
+
for (const action of actions)
|
|
74
|
+
for (const event of action.history)
|
|
75
|
+
if (event.packetReceipt
|
|
76
|
+
&& (event.packetReceipt.caseId !== record.id || event.packetReceipt.target !== domain))
|
|
77
|
+
throw new TypeError('The packet receipt belongs to a different Case or target.');
|
|
69
78
|
const assertions = normalizeCaseAssertions(record.assertions, updatedAt, pinIds, timestampOptions);
|
|
70
79
|
const sightings = normalizeCaseSightings(record.sightings, updatedAt, pinIds, timestampOptions);
|
|
71
80
|
const sightingIds = new Set(sightings.map((item) => item.id));
|
|
@@ -80,7 +89,7 @@ export function normalizeCase(raw, existing, nowIso, sourceVersion) {
|
|
|
80
89
|
domain,
|
|
81
90
|
title: normalizeCaseObjective(record.title),
|
|
82
91
|
status: caseStatusRequiresClosure(normalizedStatus)
|
|
83
|
-
&& closures.records.
|
|
92
|
+
&& !closures.records.some(closure => closure.responseObject === undefined) && !closures.preV13HistoryUnavailable
|
|
84
93
|
? 'reviewing'
|
|
85
94
|
: normalizedStatus,
|
|
86
95
|
disposition: normalizeDisposition(record.disposition),
|
|
@@ -91,6 +100,7 @@ export function normalizeCase(raw, existing, nowIso, sourceVersion) {
|
|
|
91
100
|
source: normalizeSource(record.source),
|
|
92
101
|
evidenceHistory: normalizeCaseEvidence(record, domain, createdAt, updatedAt, now, sourceVersion),
|
|
93
102
|
evidencePins,
|
|
103
|
+
...(checkpointCollectionReceipts === undefined ? {} : { checkpointCollectionReceipts }),
|
|
94
104
|
decisions: normalizeCaseDecisions(record.decisions, updatedAt, pinIds, timestampOptions),
|
|
95
105
|
actions,
|
|
96
106
|
assertions,
|
|
@@ -117,6 +127,13 @@ export function createCase(input, nowIso) {
|
|
|
117
127
|
throw new Error('A valid domain is required to open a case.');
|
|
118
128
|
const noteBody = normalizeNoteBody(input.note);
|
|
119
129
|
const source = normalizeSource(input.source);
|
|
130
|
+
const workflowMetadata = updateCaseWorkflowMetadata(emptyCaseWorkflowMetadata(), input, domain, now);
|
|
131
|
+
const scopedCase = { domain, workflowMetadata };
|
|
132
|
+
for (const raw of [input.evidencePin, ...(Array.isArray(input.evidencePins) ? input.evidencePins : []), input.observedEffectReview, input.closure])
|
|
133
|
+
assertCaseResponseObject(readCaseResponseObject(objectRecord(raw).responseObject), scopedCase);
|
|
134
|
+
for (const object of readCaseResponseObjects(objectRecord(input.action).responseObjects) ?? [])
|
|
135
|
+
assertCaseResponseObject(object, scopedCase);
|
|
136
|
+
assertCaseResponseObject(readCaseResponseObject(objectRecord(objectRecord(input.assertion).recheck).responseObject), scopedCase);
|
|
120
137
|
const evidencePins = input.evidencePins !== undefined
|
|
121
138
|
? appendCaseEvidencePins([], input.evidencePins, now)
|
|
122
139
|
: input.evidencePin !== undefined
|
|
@@ -126,6 +143,7 @@ export function createCase(input, nowIso) {
|
|
|
126
143
|
const actions = input.action !== undefined
|
|
127
144
|
? appendCaseAction([], input.action, now)
|
|
128
145
|
: [];
|
|
146
|
+
const checkpointCollectionReceipts = readCaseCheckpointReceipts(input.checkpointCollectionReceipts, evidencePins);
|
|
129
147
|
const assertions = input.assertion !== undefined
|
|
130
148
|
? appendCaseAssertion([], input.assertion, now)
|
|
131
149
|
: [];
|
|
@@ -137,13 +155,19 @@ export function createCase(input, nowIso) {
|
|
|
137
155
|
? appendCaseObservedEffectReview(normalizeCaseObservedEffectHistory(undefined, now), input.observedEffectReview, now, pinIds, new Set(sightings.map((item) => item.id)))
|
|
138
156
|
: normalizeCaseObservedEffectHistory(undefined, now);
|
|
139
157
|
for (const review of observedEffects.reviews) {
|
|
158
|
+
const pin = evidencePins.find(item => item.id === review.evidencePinId);
|
|
159
|
+
if (review.responseObject && pin && !sameCaseResponseObject(review.responseObject, pin.responseObject))
|
|
160
|
+
throw new TypeError('The selected observation evidence does not explicitly concern this exact object.');
|
|
161
|
+
if (review.responseObject && review.state === 'not_reproduced' && !review.recheck)
|
|
162
|
+
throw new TypeError('Object-specific non-reproduction requires a saved baseline question and comparable evidence.');
|
|
163
|
+
assertCaseObjectObservationOutcome(review.objectOutcome, review.recheck, review.completeness, evidencePins, pin, review.observedAt);
|
|
140
164
|
if (!review.recheck)
|
|
141
165
|
continue;
|
|
142
166
|
assertCurrentRecheckQuestion(review.recheck, assertions);
|
|
143
167
|
assertRecheckNonReproduction(review.state, review.recheck, review.completeness, evidencePins, evidencePins.find(pin => pin.id === review.evidencePinId), review.observedAt);
|
|
144
168
|
}
|
|
145
169
|
const closures = input.closure !== undefined
|
|
146
|
-
? appendCaseClosure(normalizeCaseClosureHistory(undefined, now), input.closure, now, observedEffects, actions)
|
|
170
|
+
? appendCaseClosure(normalizeCaseClosureHistory(undefined, now), input.closure, now, observedEffects, actions, evidencePins)
|
|
147
171
|
: normalizeCaseClosureHistory(undefined, now);
|
|
148
172
|
if (caseStatusRequiresClosure(normalizeStatus(input.status)) && input.closure === undefined) {
|
|
149
173
|
throw new Error('Opening a resolved case requires a deliberate closure reason and its linked review context.');
|
|
@@ -152,12 +176,12 @@ export function createCase(input, nowIso) {
|
|
|
152
176
|
id: makeId(),
|
|
153
177
|
domain,
|
|
154
178
|
title: normalizeCaseObjective(input.title),
|
|
155
|
-
status: input.closure !== undefined ? 'resolved' : normalizeStatus(input.status),
|
|
179
|
+
status: input.closure !== undefined && objectRecord(input.closure).responseObject === undefined ? 'resolved' : normalizeStatus(input.status),
|
|
156
180
|
disposition: normalizeDisposition(input.disposition),
|
|
157
181
|
reviewReasonCode: normalizeReviewReasonCode(input.reviewReasonCode),
|
|
158
182
|
brandProfileIds: input.brandProfileIds === undefined ? [] : assertCaseBrandProfileIds(input.brandProfileIds),
|
|
159
183
|
tags: normalizeTags(input.tags),
|
|
160
|
-
workflowMetadata
|
|
184
|
+
workflowMetadata,
|
|
161
185
|
notes: noteBody ? [{ id: makeId(), body: noteBody, createdAt: now }] : [],
|
|
162
186
|
source,
|
|
163
187
|
evidenceHistory: normalizeEvidenceHistory(input.evidence ? [input.evidence] : [], {
|
|
@@ -166,6 +190,7 @@ export function createCase(input, nowIso) {
|
|
|
166
190
|
caseDomain: domain,
|
|
167
191
|
}),
|
|
168
192
|
evidencePins,
|
|
193
|
+
...(checkpointCollectionReceipts === undefined ? {} : { checkpointCollectionReceipts }),
|
|
169
194
|
decisions: input.decision !== undefined
|
|
170
195
|
? appendCaseDecision([], input.decision, now)
|
|
171
196
|
: [],
|
|
@@ -246,6 +271,21 @@ export function updateCase(cases, id, patch, nowIso) {
|
|
|
246
271
|
const current = cases[index];
|
|
247
272
|
if (!current)
|
|
248
273
|
throw new Error('That case no longer exists.');
|
|
274
|
+
if (patch.expectedResponseContext !== undefined && patch.expectedResponseContext !== JSON.stringify(current))
|
|
275
|
+
throw new TypeError('The Case response context changed after preview. Review the current delivery, scope and evidence before creating this draft.');
|
|
276
|
+
const workflowMetadata = updateCaseWorkflowMetadata(current.workflowMetadata
|
|
277
|
+
?? readCaseWorkflowFields(current, current.domain, current.assertions).workflowMetadata, patch, current.domain, now);
|
|
278
|
+
const scopedCase = { ...current, workflowMetadata };
|
|
279
|
+
for (const raw of [patch.evidencePin, ...(Array.isArray(patch.evidencePins) ? patch.evidencePins : []), patch.observedEffectReview, patch.closure]) {
|
|
280
|
+
assertCaseResponseObject(readCaseResponseObject(objectRecord(raw).responseObject), scopedCase);
|
|
281
|
+
}
|
|
282
|
+
for (const raw of [patch.action, patch.actionUpdate, objectRecord(patch.actionUpdate).transition]) {
|
|
283
|
+
for (const object of readCaseResponseObjects(objectRecord(raw).responseObjects) ?? [])
|
|
284
|
+
assertCaseResponseObject(object, scopedCase);
|
|
285
|
+
}
|
|
286
|
+
for (const raw of [patch.assertion, patch.assertionUpdate, patch.observedEffectReview]) {
|
|
287
|
+
assertCaseResponseObject(readCaseResponseObject(objectRecord(objectRecord(raw).recheck).responseObject), scopedCase);
|
|
288
|
+
}
|
|
249
289
|
if (patch.evidenceLink !== undefined && patch.evidenceLinkWithdrawal !== undefined)
|
|
250
290
|
throw new TypeError('Record or withdraw one evidence relationship at a time.');
|
|
251
291
|
if (patch.title !== undefined && patch.expectedTitle !== undefined && patch.expectedTitle !== (current.title ?? '')) {
|
|
@@ -283,7 +323,23 @@ export function updateCase(cases, id, patch, nowIso) {
|
|
|
283
323
|
if (patch.evidencePin !== undefined) {
|
|
284
324
|
evidencePins = appendCaseEvidencePin(evidencePins, patch.evidencePin, now);
|
|
285
325
|
}
|
|
326
|
+
const previousPinIds = new Set(current.evidencePins.map(pin => pin.id));
|
|
327
|
+
const checkpointCollectionReceipts = mergeCaseCheckpointReceipts(current, {
|
|
328
|
+
evidencePins: evidencePins.filter(pin => !previousPinIds.has(pin.id)),
|
|
329
|
+
checkpointCollectionReceipts: patch.checkpointCollectionReceipts,
|
|
330
|
+
}, evidencePins);
|
|
286
331
|
const pinIds = new Set(evidencePins.map((item) => item.id));
|
|
332
|
+
const correctionInput = patch.action !== undefined ? objectRecord(patch.action) : patch.actionUpdate !== undefined
|
|
333
|
+
? { ...current.actions.find(action => action.id === objectRecord(patch.actionUpdate).id), ...objectRecord(patch.actionUpdate) } : null;
|
|
334
|
+
if (correctionInput?.correction !== undefined)
|
|
335
|
+
correctionDelivery(current.actions, {
|
|
336
|
+
originActionId: typeof correctionInput.originActionId === 'string' ? correctionInput.originActionId : null,
|
|
337
|
+
recipient: typeof correctionInput.recipient === 'string' ? correctionInput.recipient : '',
|
|
338
|
+
responseObjects: readCaseResponseObjects(correctionInput.responseObjects), correction: readCasePacketCorrection(correctionInput.correction),
|
|
339
|
+
}, { caseId: current.id, target: current.domain, selectedPinIds: pinIds });
|
|
340
|
+
const transitionReceipt = readCaseDeliveryPacketReceipt(objectRecord(objectRecord(patch.actionUpdate).transition).packetReceipt);
|
|
341
|
+
if (transitionReceipt && (transitionReceipt.caseId !== current.id || transitionReceipt.target !== current.domain))
|
|
342
|
+
throw new TypeError('The packet receipt belongs to a different Case or target.');
|
|
287
343
|
let decisions = current.decisions;
|
|
288
344
|
if (patch.decision !== undefined) {
|
|
289
345
|
decisions = appendCaseDecision(current.decisions, patch.decision, now, pinIds);
|
|
@@ -295,6 +351,16 @@ export function updateCase(cases, id, patch, nowIso) {
|
|
|
295
351
|
if (patch.actionUpdate !== undefined) {
|
|
296
352
|
actions = updateCaseAction(actions, patch.actionUpdate, now, pinIds);
|
|
297
353
|
}
|
|
354
|
+
if (correctionInput?.correction !== undefined || transitionReceipt) {
|
|
355
|
+
for (const original of current.actions) {
|
|
356
|
+
const retained = actions.find(action => action.id === original.id);
|
|
357
|
+
if (!retained || original.history.some(event => !retained.history.some(next => JSON.stringify(next) === JSON.stringify(event)))
|
|
358
|
+
|| (patch.action !== undefined && JSON.stringify(retained) !== JSON.stringify(original)))
|
|
359
|
+
throw new TypeError('This linked operation would omit or change historical delivery records within the Case bounds. No correction or receipt was retained.');
|
|
360
|
+
}
|
|
361
|
+
if (transitionReceipt && !actions.find(action => action.id === transitionReceipt.actionId)?.history.some(event => JSON.stringify(event.packetReceipt) === JSON.stringify(transitionReceipt)))
|
|
362
|
+
throw new TypeError('The exact delivery receipt exceeds the retained history bounds. No delivery was recorded.');
|
|
363
|
+
}
|
|
298
364
|
let assertions = current.assertions;
|
|
299
365
|
if (patch.assertion !== undefined) {
|
|
300
366
|
assertions = appendCaseAssertion(current.assertions, patch.assertion, now, pinIds);
|
|
@@ -326,22 +392,28 @@ export function updateCase(cases, id, patch, nowIso) {
|
|
|
326
392
|
assertCurrentRecheckQuestion(context, assertions);
|
|
327
393
|
assertRecheckNonReproduction(review.state, context, String(review.completeness), evidencePins, evidencePins.find(pin => pin.id === review.evidencePinId), typeof review.observedAt === 'string' ? review.observedAt : null);
|
|
328
394
|
}
|
|
395
|
+
const responseObject = readCaseResponseObject(review.responseObject);
|
|
396
|
+
const pin = evidencePins.find(item => item.id === review.evidencePinId);
|
|
397
|
+
assertCaseObjectObservationOutcome(review.objectOutcome, context, String(review.completeness), evidencePins, pin, typeof review.observedAt === 'string' ? review.observedAt : null);
|
|
398
|
+
if (responseObject && pin && !sameCaseResponseObject(responseObject, pin.responseObject))
|
|
399
|
+
throw new TypeError('The selected observation evidence does not explicitly concern this exact object.');
|
|
400
|
+
if (responseObject && review.state === 'not_reproduced' && !context)
|
|
401
|
+
throw new TypeError('Object-specific non-reproduction requires a saved baseline question and comparable evidence.');
|
|
329
402
|
observedEffects = appendCaseObservedEffectReview(observedEffects, patch.observedEffectReview, now, pinIds, new Set(sightings.map((item) => item.id)));
|
|
330
403
|
}
|
|
331
404
|
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));
|
|
332
405
|
if (patch.closure !== undefined) {
|
|
333
|
-
closures = appendCaseClosure(closures, patch.closure, now, observedEffects, actions);
|
|
406
|
+
closures = appendCaseClosure(closures, patch.closure, now, observedEffects, actions, evidencePins);
|
|
334
407
|
}
|
|
335
408
|
if (caseStatusRequiresClosure(patch.status) && patch.closure === undefined) {
|
|
336
409
|
throw new Error('Resolve this case through the deliberate closure review so the reason and evidence state remain explicit.');
|
|
337
410
|
}
|
|
338
411
|
const record = {
|
|
339
412
|
...current,
|
|
340
|
-
workflowMetadata
|
|
341
|
-
?? readCaseWorkflowFields(current, current.domain, current.assertions).workflowMetadata, patch, current.domain, now),
|
|
413
|
+
workflowMetadata,
|
|
342
414
|
title: patch.title === undefined ? current.title ?? '' : normalizeCaseObjective(patch.title),
|
|
343
415
|
status: patch.closure !== undefined
|
|
344
|
-
? 'resolved'
|
|
416
|
+
? objectRecord(patch.closure).responseObject !== undefined ? current.status : 'resolved'
|
|
345
417
|
: patch.status !== undefined ? normalizeStatus(patch.status) : current.status,
|
|
346
418
|
disposition: patch.disposition !== undefined ? normalizeDisposition(patch.disposition) : current.disposition,
|
|
347
419
|
reviewReasonCode: patch.reviewReasonCode !== undefined
|
|
@@ -354,6 +426,7 @@ export function updateCase(cases, id, patch, nowIso) {
|
|
|
354
426
|
source,
|
|
355
427
|
evidenceHistory,
|
|
356
428
|
evidencePins,
|
|
429
|
+
...(checkpointCollectionReceipts === undefined ? {} : { checkpointCollectionReceipts }),
|
|
357
430
|
...((patch.evidenceLink !== undefined || patch.evidenceLinkWithdrawal !== undefined) ? {
|
|
358
431
|
evidenceLinks: patch.evidenceLink !== undefined
|
|
359
432
|
? appendCaseEvidenceLink(current.evidenceLinks ?? [], patch.evidenceLink, evidencePins, now)
|
|
@@ -371,6 +444,8 @@ export function updateCase(cases, id, patch, nowIso) {
|
|
|
371
444
|
updatedAt: now,
|
|
372
445
|
};
|
|
373
446
|
const next = [...cases];
|
|
447
|
+
if (checkpointCollectionReceipts === undefined)
|
|
448
|
+
delete record.checkpointCollectionReceipts;
|
|
374
449
|
next[index] = record;
|
|
375
450
|
return { cases: next, record };
|
|
376
451
|
}
|
|
@@ -504,6 +579,7 @@ export function recordCaseRecheckOutcome(cases, id, input, nowIso) {
|
|
|
504
579
|
observedAt: input.observedAt,
|
|
505
580
|
collectionDepth: input.collectionDepth,
|
|
506
581
|
observationHostname: input.observationHostname,
|
|
582
|
+
...(input.responseObject ? { responseObject: input.responseObject } : {}),
|
|
507
583
|
completeness: input.completeness,
|
|
508
584
|
truncated: input.comparisonTruncated === true
|
|
509
585
|
|| (typeof input.comparisonSummary === 'string' && input.comparisonSummary.length > MAX_RESPONSE_VALUE_LENGTH),
|
|
@@ -524,6 +600,8 @@ export function recordCaseRecheckOutcome(cases, id, input, nowIso) {
|
|
|
524
600
|
followUpAt: input.followUpAt,
|
|
525
601
|
limitations: input.limitations,
|
|
526
602
|
...(input.recheck ? { recheck: input.recheck } : {}),
|
|
603
|
+
...(input.responseObject ? { responseObject: input.responseObject } : {}),
|
|
604
|
+
...(input.objectOutcome ? { objectOutcome: input.objectOutcome } : {}),
|
|
527
605
|
},
|
|
528
606
|
}, now);
|
|
529
607
|
}
|