@slicedearth/whoisleuth-cli 2.1.0 → 2.2.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/docs/cli.md CHANGED
@@ -56,6 +56,18 @@ whoisleuth lookup example.test --deep --browse
56
56
  whoisleuth lookup example.test --deep --markdown --output lookup.md
57
57
  ```
58
58
 
59
+ To continue a reviewed terminal Lookup in the browser, save the completed
60
+ private document from the interactive view:
61
+
62
+ ```bash
63
+ whoisleuth lookup example.test --deep --browse --save-lookup lookup.json
64
+ ```
65
+
66
+ In Console Lookup, open **Replay exported evidence**, select the file, verify
67
+ its digest and source states, then create or update a browser-local Case. The
68
+ file is not uploaded. Case classification, exact incident links, response
69
+ actions and packet preparation remain deliberate browser steps.
70
+
59
71
  Process selected local input:
60
72
 
61
73
  ```bash
@@ -10,8 +10,9 @@ import { REGISTRAR_STANDING_SCHEMA, REGISTRAR_STANDING_VERSION, registrarIanaIds
10
10
  export const LOOKUP_EVIDENCE_SCHEMA = 'whoisleuth.lookup-evidence';
11
11
  export const V1_PUBLIC_LOOKUP_EVIDENCE_SCHEMA_VERSION = 26;
12
12
  export const PUBLISHED_V2_LOOKUP_EVIDENCE_SCHEMA_VERSION = 27;
13
+ export const LATEST_PUBLIC_LOOKUP_EVIDENCE_SCHEMA_VERSION = 28;
13
14
  export const PRIVACY_MINIMIZED_LOOKUP_EVIDENCE_SCHEMA_VERSION = PUBLISHED_V2_LOOKUP_EVIDENCE_SCHEMA_VERSION;
14
- export const LOOKUP_EVIDENCE_SCHEMA_VERSION = 28;
15
+ export const LOOKUP_EVIDENCE_SCHEMA_VERSION = LATEST_PUBLIC_LOOKUP_EVIDENCE_SCHEMA_VERSION;
15
16
  export const SUPPORTED_LOOKUP_EVIDENCE_SCHEMA_VERSIONS = Object.freeze([
16
17
  V1_PUBLIC_LOOKUP_EVIDENCE_SCHEMA_VERSION,
17
18
  PUBLISHED_V2_LOOKUP_EVIDENCE_SCHEMA_VERSION,
@@ -35,7 +36,7 @@ export const LOOKUP_EVIDENCE_COMPATIBILITY = defineSchemaCompatibility({
35
36
  writeSemantics: 'read_only',
36
37
  byteBudget: LOOKUP_EVIDENCE_PORTABLE_MAX_BYTES,
37
38
  owner: 'lib/evidence-export.mts',
38
- note: 'Exact v1.47.4 version 26 and published v2.0.1 version 27 remain replayable; version 28 adds a bounded official-source registrar-standing projection.',
39
+ note: 'Exact v1.47.4 version 26, published v2.0.1 version 27, and latest-public v2.1.0 version 28 remain replayable; version 28 adds a bounded official-source registrar-standing projection.',
39
40
  });
40
41
  const REGISTRAR_RDAP_STATUSES = new Set([
41
42
  'success', 'partial', 'error', 'unsupported', 'not_found', 'skipped', 'disabled',
package/package.json CHANGED
@@ -33,7 +33,7 @@
33
33
  "bugs": {
34
34
  "url": "https://github.com/slicedearth/whoisleuth/issues"
35
35
  },
36
- "version": "2.1.0",
36
+ "version": "2.2.0",
37
37
  "dependencies": {
38
38
  "@peculiar/x509": "2.0.0",
39
39
  "maxmind": "5.0.7",
@@ -1,11 +1,101 @@
1
1
  // Pure, framework-neutral analyst-case records, evidence histories, bounded
2
2
  // record normalization, and analyst updates.
3
+ import { canonicalRegistrableDomain } from "../../lib/registrable-domain.mjs";
3
4
  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";
4
5
  import { CASE_SCHEMA_VERSION, MAX_CASES, MAX_NOTES_PER_CASE, } from "./case-record-contracts.mjs";
5
6
  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";
6
7
  import { assertCaseBrandProfileIds, normalizeCaseBrandProfileIds, } from "./case-brand-profile-references.mjs";
7
8
  import { normalizeEvidenceHistory, } from "./case-evidence-model.mjs";
8
9
  import { appendCaseInvestigationBranch, caseInvestigationBranchReferences, normalizeCaseInvestigationBranches, updateCaseInvestigationBranch, } from "./case-investigation-branch-model.mjs";
10
+ export const MAX_CASE_OBJECTIVE_LENGTH = 320;
11
+ export const MAX_CASE_INCIDENT_URL_LENGTH = 1_850;
12
+ export const INCIDENT_CONTEXT_STATEMENT_PREFIX = 'Investigate incident URL: ';
13
+ const OBJECTIVE_PREFIX = 'Objective: ';
14
+ const RETENTION_SEPARATOR = ' | URL retained: ';
15
+ function boundedContextText(value, maximum) {
16
+ return typeof value === 'string'
17
+ ? value.replace(/[\u0000-\u001f\u007f]/gu, ' ').replace(/\s+/gu, ' ').trim().slice(0, maximum)
18
+ : '';
19
+ }
20
+ export function normalizeCaseObjective(value) {
21
+ return boundedContextText(value, MAX_CASE_OBJECTIVE_LENGTH);
22
+ }
23
+ export function parseIncidentUrlContext(value) {
24
+ if (typeof value !== 'string' || !value || value.length > MAX_CASE_INCIDENT_URL_LENGTH)
25
+ return null;
26
+ if (/[\u0000-\u001f\u007f]/u.test(value) || value.trim() !== value)
27
+ return null;
28
+ let parsed;
29
+ try {
30
+ parsed = new URL(value);
31
+ }
32
+ catch {
33
+ return null;
34
+ }
35
+ if (!['http:', 'https:'].includes(parsed.protocol) || parsed.username || parsed.password || !parsed.hostname)
36
+ return null;
37
+ const hostname = parsed.hostname.toLowerCase().replace(/\.$/u, '');
38
+ const registrableDomain = canonicalRegistrableDomain(hostname);
39
+ if (!registrableDomain)
40
+ return null;
41
+ const exactUrl = parsed.toString();
42
+ if (exactUrl.length > MAX_CASE_INCIDENT_URL_LENGTH)
43
+ return null;
44
+ return Object.freeze({
45
+ exactUrl,
46
+ hostname,
47
+ registrableDomain,
48
+ originUrl: parsed.origin,
49
+ hasPath: parsed.pathname !== '/',
50
+ hasQuery: Boolean(parsed.search),
51
+ hasFragment: Boolean(parsed.hash),
52
+ });
53
+ }
54
+ export function caseInvestigationContext(record) {
55
+ if (!record)
56
+ return null;
57
+ for (const assertion of [...record.assertions].reverse()) {
58
+ if (assertion.kind !== 'next_step' || assertion.state !== 'open'
59
+ || !assertion.statement.startsWith(INCIDENT_CONTEXT_STATEMENT_PREFIX))
60
+ continue;
61
+ const incidentUrl = assertion.statement.slice(INCIDENT_CONTEXT_STATEMENT_PREFIX.length);
62
+ const parsed = parseIncidentUrlContext(incidentUrl);
63
+ if (!parsed || parsed.registrableDomain !== record.domain)
64
+ continue;
65
+ const rationale = assertion.rationale ?? '';
66
+ const separatorIndex = rationale.lastIndexOf(RETENTION_SEPARATOR);
67
+ const objective = separatorIndex > OBJECTIVE_PREFIX.length && rationale.startsWith(OBJECTIVE_PREFIX)
68
+ ? normalizeCaseObjective(rationale.slice(OBJECTIVE_PREFIX.length, separatorIndex))
69
+ : '';
70
+ const retention = separatorIndex >= 0 ? rationale.slice(separatorIndex + RETENTION_SEPARATOR.length) : '';
71
+ if (!objective || (retention !== 'exact' && retention !== 'origin_only'))
72
+ continue;
73
+ return Object.freeze({
74
+ objective,
75
+ incidentUrl: parsed.exactUrl,
76
+ urlRetention: retention,
77
+ assertionId: assertion.id,
78
+ updatedAt: assertion.updatedAt,
79
+ });
80
+ }
81
+ return null;
82
+ }
83
+ export function caseInvestigationContextAssertion(input) {
84
+ const objective = normalizeCaseObjective(input.objective);
85
+ if (!objective)
86
+ throw new Error('Enter the investigation objective before retaining Incident context.');
87
+ const parsed = parseIncidentUrlContext(input.incidentUrl);
88
+ if (!parsed)
89
+ throw new Error(`Enter one absolute HTTP(S) Incident URL of at most ${MAX_CASE_INCIDENT_URL_LENGTH} characters without credentials.`);
90
+ const retention = input.retainExactUrl ? 'exact' : 'origin_only';
91
+ const retainedUrl = input.retainExactUrl ? parsed.exactUrl : parsed.originUrl;
92
+ return Object.freeze({
93
+ statement: `${INCIDENT_CONTEXT_STATEMENT_PREFIX}${retainedUrl}`,
94
+ rationale: `${OBJECTIVE_PREFIX}${objective}${RETENTION_SEPARATOR}${retention}`,
95
+ retainedUrl,
96
+ retention,
97
+ });
98
+ }
9
99
  // ---------------------------------------------------------------------------
10
100
  // Case normalization
11
101
  // ---------------------------------------------------------------------------
@@ -300,3 +390,148 @@ export function updateCase(cases, id, patch, nowIso) {
300
390
  next[index] = record;
301
391
  return { cases: next, record };
302
392
  }
393
+ /**
394
+ * Records one reviewed conclusion as a single pure Case mutation. The selected
395
+ * observations become bounded Case pins before the decision is created, so the
396
+ * decision cannot point at absent evidence. Counterevidence is retained as a
397
+ * separate resolved contradiction assertion rather than being hidden inside a
398
+ * favourable disposition.
399
+ */
400
+ export function recordCaseConclusion(cases, id, input, nowIso) {
401
+ const now = caseTimestampOrNull(nowIso) || new Date().toISOString();
402
+ const disposition = normalizeDisposition(input.disposition);
403
+ const reviewReasonCode = normalizeReviewReasonCode(input.reviewReasonCode);
404
+ if (disposition === 'unreviewed') {
405
+ throw new Error('Select a reviewed disposition before recording a conclusion.');
406
+ }
407
+ if (!reviewReasonCode) {
408
+ throw new Error('Select the reviewed reason before recording a conclusion.');
409
+ }
410
+ if (!Array.isArray(input.evidence) || !input.evidence.length) {
411
+ throw new Error('Select at least one observed fact for this conclusion.');
412
+ }
413
+ const invalidStance = input.evidence.find((item) => (!item || !['supports', 'contradicts', 'unresolved'].includes(item.stance)));
414
+ if (invalidStance)
415
+ throw new Error('A conclusion evidence relationship is invalid.');
416
+ if (!input.evidence.some((item) => item.stance === 'supports')) {
417
+ throw new Error('A conclusion requires at least one observed fact that supports it. Record only contradictory or unresolved material as an assertion instead.');
418
+ }
419
+ const current = cases.find((item) => item.id === id);
420
+ if (!current)
421
+ throw new Error('That case no longer exists.');
422
+ const existingPinIds = new Set(current.evidencePins.map((pin) => pin.id));
423
+ const withPins = updateCase(cases, id, {
424
+ evidencePins: input.evidence.map((item) => item.pin),
425
+ }, now);
426
+ const addedPins = withPins.record.evidencePins.filter((pin) => !existingPinIds.has(pin.id));
427
+ if (addedPins.length !== input.evidence.length) {
428
+ throw new Error('The selected conclusion evidence could not be retained completely.');
429
+ }
430
+ const supportingPinIds = addedPins.flatMap((pin, index) => (input.evidence[index]?.stance === 'supports' ? [pin.id] : []));
431
+ let concluded = updateCase(withPins.cases, id, {
432
+ disposition,
433
+ reviewReasonCode,
434
+ decision: {
435
+ summary: input.summary,
436
+ rationale: input.rationale,
437
+ evidencePinIds: supportingPinIds,
438
+ },
439
+ }, now);
440
+ const contradictionRelations = addedPins.flatMap((pin, index) => (input.evidence[index]?.stance === 'contradicts'
441
+ ? [{ evidencePinId: pin.id, stance: 'contradicts' }]
442
+ : []));
443
+ if (contradictionRelations.length) {
444
+ const retainedSummary = concluded.record.decisions.at(-1)?.summary ?? 'Analyst conclusion';
445
+ concluded = updateCase(concluded.cases, id, {
446
+ assertion: {
447
+ kind: 'contradiction',
448
+ statement: `Counterevidence considered for: ${retainedSummary}`,
449
+ rationale: input.rationale,
450
+ evidenceRelations: contradictionRelations,
451
+ state: 'resolved',
452
+ },
453
+ }, now);
454
+ }
455
+ const unresolvedRelations = addedPins.flatMap((pin, index) => (input.evidence[index]?.stance === 'unresolved'
456
+ ? [{ evidencePinId: pin.id, stance: 'unresolved' }]
457
+ : []));
458
+ if (unresolvedRelations.length) {
459
+ const retainedSummary = concluded.record.decisions.at(-1)?.summary ?? 'Analyst conclusion';
460
+ concluded = updateCase(concluded.cases, id, {
461
+ assertion: {
462
+ kind: 'unknown',
463
+ statement: `Unresolved evidence considered for: ${retainedSummary}`,
464
+ rationale: input.rationale,
465
+ evidenceRelations: unresolvedRelations,
466
+ state: 'open',
467
+ },
468
+ }, now);
469
+ }
470
+ return concluded;
471
+ }
472
+ export function recordCaseInvestigationContext(cases, id, input, nowIso) {
473
+ const current = cases.find((item) => item.id === id);
474
+ if (!current)
475
+ throw new Error('That case no longer exists.');
476
+ const parsed = parseIncidentUrlContext(input.incidentUrl);
477
+ if (!parsed || parsed.registrableDomain !== current.domain) {
478
+ throw new Error(`The Incident URL must belong to the Case domain ${current.domain}.`);
479
+ }
480
+ const context = caseInvestigationContextAssertion(input);
481
+ const existing = caseInvestigationContext(current);
482
+ return updateCase(cases, id, existing
483
+ ? {
484
+ assertionUpdate: {
485
+ id: existing.assertionId,
486
+ statement: context.statement,
487
+ rationale: context.rationale,
488
+ state: 'open',
489
+ },
490
+ }
491
+ : {
492
+ assertion: {
493
+ kind: 'next_step',
494
+ statement: context.statement,
495
+ rationale: context.rationale,
496
+ evidenceRelations: [],
497
+ state: 'open',
498
+ },
499
+ }, nowIso);
500
+ }
501
+ export function recordCaseRecheckOutcome(cases, id, input, nowIso) {
502
+ const now = caseTimestampOrNull(nowIso) || new Date().toISOString();
503
+ const current = cases.find((item) => item.id === id);
504
+ if (!current)
505
+ throw new Error('That case no longer exists.');
506
+ const beforePinIds = new Set(current.evidencePins.map((pin) => pin.id));
507
+ const withPin = updateCase(cases, id, {
508
+ evidencePin: {
509
+ field: 'case.recheck_comparison',
510
+ category: 'recheck',
511
+ label: 'Recheck comparison',
512
+ value: input.comparisonSummary,
513
+ source: input.source,
514
+ sourceState: 'reviewed',
515
+ observedAt: input.observedAt,
516
+ collectionDepth: input.collectionDepth,
517
+ completeness: input.completeness,
518
+ truncated: false,
519
+ limitations: input.limitations,
520
+ },
521
+ }, now);
522
+ const comparisonPin = withPin.record.evidencePins.find((pin) => !beforePinIds.has(pin.id));
523
+ if (!comparisonPin)
524
+ throw new Error('The recheck comparison could not be retained.');
525
+ return updateCase(withPin.cases, id, {
526
+ observedEffectReview: {
527
+ state: input.state,
528
+ observedAt: input.observedAt,
529
+ sourceClass: 'analyst',
530
+ source: input.source,
531
+ completeness: input.completeness,
532
+ evidencePinId: comparisonPin.id,
533
+ followUpAt: input.followUpAt,
534
+ limitations: input.limitations,
535
+ },
536
+ }, now);
537
+ }
@@ -2,9 +2,14 @@ import { MAX_DOMAIN_NAME_LENGTH } from "./domain-name.mjs";
2
2
  import { defineSchemaCompatibility } from "./schema-compatibility.mjs";
3
3
  import { defineSchemaLifecycleFamily } from "./schema-lifecycle.mjs";
4
4
  export const CASE_CONTRACT_OWNER = 'packages/contracts/case-portability.mts';
5
- export const LATEST_PUBLIC_APPLICATION_VERSION = '2.0.1';
5
+ export const LATEST_PUBLIC_APPLICATION_VERSION = '2.1.0';
6
6
  export const CASE_PORTABILITY_IDENTITY_CONSTANTS = Object.freeze([
7
7
  'LATEST_PUBLIC_APPLICATION_VERSION',
8
+ 'LATEST_PUBLIC_CASE_SCHEMA_VERSION',
9
+ 'LATEST_PUBLIC_CASE_REPORT_SCHEMA_VERSION',
10
+ 'LATEST_PUBLIC_CASE_RESPONSE_PACKET_VERSION',
11
+ 'LATEST_PUBLIC_CASE_RESPONSE_REVIEW_INPUTS_VERSION',
12
+ 'LATEST_PUBLIC_WORKSPACE_ARCHIVE_VERSION',
8
13
  'CASE_BROWSER_STORE_LIFECYCLE_SCHEMA',
9
14
  'CASE_EXPORT_LIFECYCLE_SCHEMA',
10
15
  'CASE_SCHEMA_VERSION',
@@ -127,6 +132,7 @@ export const CASE_EXPORT_LIFECYCLE_SCHEMA = 'whoisleuth.case-export';
127
132
  export const PUBLIC_CASE_SCHEMA_VERSION = 12;
128
133
  export const PUBLISHED_V2_CASE_SCHEMA_VERSION = 13;
129
134
  export const CASE_SCHEMA_VERSION = 14;
135
+ export const LATEST_PUBLIC_CASE_SCHEMA_VERSION = 14;
130
136
  export const CASE_BROWSER_SUPPORTED_VERSIONS = Object.freeze([
131
137
  PUBLIC_CASE_SCHEMA_VERSION,
132
138
  PUBLISHED_V2_CASE_SCHEMA_VERSION,
@@ -137,6 +143,7 @@ export const CASE_REPORT_SCHEMA = 'whoisleuth.case-report';
137
143
  export const PUBLIC_CASE_REPORT_SCHEMA_VERSION = 8;
138
144
  export const PUBLISHED_V2_CASE_REPORT_SCHEMA_VERSION = 9;
139
145
  export const CASE_REPORT_SCHEMA_VERSION = 10;
146
+ export const LATEST_PUBLIC_CASE_REPORT_SCHEMA_VERSION = 10;
140
147
  export const CASE_REPORT_OUTPUT_VERSIONS = Object.freeze([
141
148
  PUBLISHED_V2_CASE_REPORT_SCHEMA_VERSION,
142
149
  CASE_REPORT_SCHEMA_VERSION,
@@ -145,6 +152,7 @@ export const CASE_RESPONSE_PACKET_SCHEMA = 'whoisleuth.case-response-packet';
145
152
  export const PUBLIC_CASE_RESPONSE_PACKET_VERSION = 6;
146
153
  export const PUBLISHED_V2_CASE_RESPONSE_PACKET_VERSION = 7;
147
154
  export const CASE_RESPONSE_PACKET_VERSION = 8;
155
+ export const LATEST_PUBLIC_CASE_RESPONSE_PACKET_VERSION = 8;
148
156
  export const CASE_RESPONSE_PACKET_OUTPUT_VERSIONS = Object.freeze([
149
157
  PUBLIC_CASE_RESPONSE_PACKET_VERSION,
150
158
  PUBLISHED_V2_CASE_RESPONSE_PACKET_VERSION,
@@ -154,6 +162,7 @@ export const SUPPORTED_CASE_RESPONSE_PACKET_VERSIONS = CASE_RESPONSE_PACKET_OUTP
154
162
  export const CASE_RESPONSE_REVIEW_INPUTS_SCHEMA = 'whoisleuth.case-response-review-inputs';
155
163
  export const PUBLISHED_V2_CASE_RESPONSE_REVIEW_INPUTS_VERSION = 1;
156
164
  export const CASE_RESPONSE_REVIEW_INPUTS_VERSION = 2;
165
+ export const LATEST_PUBLIC_CASE_RESPONSE_REVIEW_INPUTS_VERSION = 2;
157
166
  export const SUPPORTED_CASE_RESPONSE_REVIEW_INPUTS_VERSIONS = Object.freeze([
158
167
  PUBLISHED_V2_CASE_RESPONSE_REVIEW_INPUTS_VERSION,
159
168
  CASE_RESPONSE_REVIEW_INPUTS_VERSION,
@@ -187,6 +196,7 @@ export const WORKSPACE_ARCHIVE_SCHEMA = 'whoisleuth.workspace-archive';
187
196
  export const PUBLIC_WORKSPACE_ARCHIVE_VERSION = 5;
188
197
  export const PUBLISHED_V2_WORKSPACE_ARCHIVE_VERSION = 6;
189
198
  export const WORKSPACE_ARCHIVE_VERSION = 7;
199
+ export const LATEST_PUBLIC_WORKSPACE_ARCHIVE_VERSION = 7;
190
200
  export const SUPPORTED_WORKSPACE_ARCHIVE_VERSIONS = Object.freeze([
191
201
  PUBLIC_WORKSPACE_ARCHIVE_VERSION,
192
202
  PUBLISHED_V2_WORKSPACE_ARCHIVE_VERSION,
@@ -595,8 +605,8 @@ const CASE_LIFECYCLE_FIXTURES = Object.freeze([
595
605
  id: 'cli-case-pack-v2-case-v14',
596
606
  path: 'test/fixtures/case-lifecycle/cli-case-pack-v2-case-v14.json',
597
607
  bytes: 9363,
598
- sha256: 'b51f23bca2dc91715f2aeeb931b79e4f990d59c3564d7cd60a9abac7e0d687a6',
599
- contentDigestSha256: 'sha256:82b88b1251c773a01823fccac9baf3e95f752570f091559c58dd0b1664444655',
608
+ sha256: 'e25e98602c92e696a50c9fd48924c6c13853a5c23ea6fe617abc0b89d8c9e8ee',
609
+ contentDigestSha256: 'sha256:50044835b0e7aa45137b2f75b9894aa35c71101c769062eb524078bfb55eecc0',
600
610
  schema: CLI_CASE_PACK_SCHEMA,
601
611
  version: CLI_CASE_PACK_VERSION,
602
612
  role: 'current',
@@ -1,4 +1,5 @@
1
1
  import { normalizeDomain, openOrCreateCase, updateCase, } from "../cases/case-model.mjs";
2
+ import { canonicalRegistrableDomain } from "../../lib/registrable-domain.mjs";
2
3
  import { normalizeExplicitIsoTimestamp } from "../evidence/observation.mjs";
3
4
  import { EXTERNAL_FINDINGS_SCHEMA, EXTERNAL_FINDINGS_VERSION, MAX_EXTERNAL_FINDING_DOMAINS, MAX_EXTERNAL_FINDINGS, MAX_EXTERNAL_FINDINGS_IMPORT_BYTES, MAX_EXTERNAL_FINDINGS_PER_DOMAIN, } from "../contracts/external-observation-interchange.mjs";
4
5
  import { hasUnsafeRetainedText } from "./retained-text.mjs";
@@ -311,6 +312,58 @@ function structuredPinFields(finding) {
311
312
  certificateObservation,
312
313
  };
313
314
  }
315
+ function mergeExternalFindingIntoCase(current, caseId, finding, source, now) {
316
+ const target = current.find((candidate) => candidate.id === caseId) ?? null;
317
+ if (!target)
318
+ throw new Error('The selected Case is unavailable for this finding.');
319
+ if (existingPinKey(target, finding, source.name)) {
320
+ return { cases: [...current], record: target, added: false };
321
+ }
322
+ const sourceLimitations = [
323
+ finding.evidenceClass === 'deployment_observation'
324
+ ? `Imported as an observation made by ${source.name}; this browser session did not collect or independently verify it.`
325
+ : `Reported by ${source.name}; WHOISleuth did not collect or independently verify this provider finding.`,
326
+ ...(source.reference ? [`Source reference: ${source.reference}`] : []),
327
+ ...finding.limitations,
328
+ ];
329
+ const existingPinIds = new Set(target.evidencePins.map((pin) => pin.id));
330
+ let updated = updateCase([...current], target.id, {
331
+ evidencePin: {
332
+ ...structuredPinFields(finding),
333
+ label: `External ${finding.category} finding`,
334
+ value: finding.structuredObservation?.value ?? pinValue(finding),
335
+ source: importedSourceLabel(finding, source.name),
336
+ observedAt: finding.observedAt,
337
+ completeness: finding.completeness,
338
+ limitations: sourceLimitations,
339
+ },
340
+ }, now);
341
+ const addedPin = updated.record.evidencePins.find((pin) => !existingPinIds.has(pin.id)) ?? null;
342
+ if (!addedPin)
343
+ throw new Error('The imported finding could not be retained as Case evidence.');
344
+ updated = updateCase(updated.cases, target.id, {
345
+ sighting: {
346
+ state: finding.evidenceClass === 'deployment_observation'
347
+ ? 'observed_by_deployment'
348
+ : 'reported_by_provider',
349
+ category: finding.category === 'dns'
350
+ ? 'delegation'
351
+ : finding.category === 'certificate'
352
+ ? 'certificate'
353
+ : finding.category === 'registration'
354
+ ? 'registration'
355
+ : finding.category === 'http' || finding.category === 'page'
356
+ ? 'website'
357
+ : 'other',
358
+ source: source.name,
359
+ observedAt: finding.observedAt,
360
+ completeness: finding.completeness,
361
+ evidencePinId: addedPin.id,
362
+ limitations: sourceLimitations,
363
+ },
364
+ }, now);
365
+ return { cases: updated.cases, record: updated.record, added: true };
366
+ }
314
367
  export function mergeExternalFindingsIntoCases(current, document, now = new Date().toISOString()) {
315
368
  let cases = [...current];
316
369
  const createdDomains = new Set();
@@ -322,54 +375,12 @@ export function mergeExternalFindingsIntoCases(current, document, now = new Date
322
375
  const opened = openOrCreateCase(cases, { domain: finding.domain, source: 'import' }, now);
323
376
  cases = opened.cases;
324
377
  const target = cases.find((candidate) => candidate.id === opened.record.id) ?? opened.record;
325
- if (existingPinKey(target, finding, document.source.name)) {
378
+ const merged = mergeExternalFindingIntoCase(cases, target.id, finding, document.source, now);
379
+ cases = merged.cases;
380
+ if (!merged.added) {
326
381
  duplicatesSkipped += 1;
327
382
  continue;
328
383
  }
329
- const sourceLimitations = [
330
- finding.evidenceClass === 'deployment_observation'
331
- ? `Imported as an observation made by ${document.source.name}; this browser session did not collect or independently verify it.`
332
- : `Reported by ${document.source.name}; WHOISleuth did not collect or independently verify this provider finding.`,
333
- ...(document.source.reference ? [`Source reference: ${document.source.reference}`] : []),
334
- ...finding.limitations,
335
- ];
336
- const existingPinIds = new Set(target.evidencePins.map((pin) => pin.id));
337
- const updated = updateCase(cases, target.id, {
338
- evidencePin: {
339
- ...structuredPinFields(finding),
340
- label: `External ${finding.category} finding`,
341
- value: finding.structuredObservation?.value ?? pinValue(finding),
342
- source: importedSourceLabel(finding, document.source.name),
343
- observedAt: finding.observedAt,
344
- completeness: finding.completeness,
345
- limitations: sourceLimitations,
346
- },
347
- }, now);
348
- cases = updated.cases;
349
- const addedPin = updated.record.evidencePins.find((pin) => !existingPinIds.has(pin.id)) ?? null;
350
- if (addedPin) {
351
- cases = updateCase(cases, updated.record.id, {
352
- sighting: {
353
- state: finding.evidenceClass === 'deployment_observation'
354
- ? 'observed_by_deployment'
355
- : 'reported_by_provider',
356
- category: finding.category === 'dns'
357
- ? 'delegation'
358
- : finding.category === 'certificate'
359
- ? 'certificate'
360
- : finding.category === 'registration'
361
- ? 'registration'
362
- : finding.category === 'http' || finding.category === 'page'
363
- ? 'website'
364
- : 'other',
365
- source: document.source.name,
366
- observedAt: finding.observedAt,
367
- completeness: finding.completeness,
368
- evidencePinId: addedPin.id,
369
- limitations: sourceLimitations,
370
- },
371
- }, now).cases;
372
- }
373
384
  findingsAdded += 1;
374
385
  if (existing)
375
386
  updatedDomains.add(finding.domain);
@@ -384,3 +395,60 @@ export function mergeExternalFindingsIntoCases(current, document, now = new Date
384
395
  duplicatesSkipped,
385
396
  };
386
397
  }
398
+ function targetedFinding(finding, caseDomain) {
399
+ if (finding.domain === caseDomain)
400
+ return finding;
401
+ const prefix = `Captured hostname ${finding.domain}. `;
402
+ const maximumSummaryLength = 900;
403
+ const remaining = maximumSummaryLength - prefix.length;
404
+ const summary = remaining > 1 && finding.summary.length > remaining
405
+ ? `${prefix}${finding.summary.slice(0, remaining - 1).trimEnd()}…`
406
+ : `${prefix}${finding.summary}`;
407
+ return { ...finding, domain: caseDomain, summary };
408
+ }
409
+ export function externalFindingsCaseTargets(document, caseDomain) {
410
+ if (canonicalRegistrableDomain(caseDomain) !== caseDomain) {
411
+ throw new Error('The selected Case does not have a canonical registrable-domain identity.');
412
+ }
413
+ const targets = new Set();
414
+ for (const finding of document.findings) {
415
+ if (canonicalRegistrableDomain(finding.domain) !== caseDomain) {
416
+ throw new Error(`The imported finding for ${finding.domain} does not belong to the selected Case for ${caseDomain}.`);
417
+ }
418
+ targets.add(finding.domain);
419
+ }
420
+ return Object.freeze([...targets]);
421
+ }
422
+ /**
423
+ * Merges already-validated external findings into one selected Case. A Case is
424
+ * keyed to its registrable domain, while rendered captures may retain an exact
425
+ * hostname. Only same-domain findings are accepted, the exact hostname remains
426
+ * explicit in the retained evidence, and this operation can never open another
427
+ * Case as a side effect.
428
+ */
429
+ export function mergeExternalFindingsIntoCase(current, caseId, document, now = new Date().toISOString()) {
430
+ const selected = current.find((candidate) => candidate.id === caseId) ?? null;
431
+ if (!selected)
432
+ throw new Error('Select an existing Case before importing rendered-capture evidence.');
433
+ externalFindingsCaseTargets(document, selected.domain);
434
+ let cases = [...current];
435
+ let record = selected;
436
+ let findingsAdded = 0;
437
+ let duplicatesSkipped = 0;
438
+ for (const original of document.findings) {
439
+ const finding = targetedFinding(original, selected.domain);
440
+ const merged = mergeExternalFindingIntoCase(cases, selected.id, finding, document.source, now);
441
+ cases = merged.cases;
442
+ record = merged.record;
443
+ if (merged.added)
444
+ findingsAdded += 1;
445
+ else
446
+ duplicatesSkipped += 1;
447
+ }
448
+ return {
449
+ cases,
450
+ record,
451
+ findingsAdded,
452
+ duplicatesSkipped,
453
+ };
454
+ }