@slicedearth/whoisleuth-cli 2.1.0 → 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/README.md +2 -1
  2. package/cli/arguments.mjs +389 -1745
  3. package/cli/artifact-validation/case-response.mjs +55 -30
  4. package/cli/artifact-validation/signed-review.mjs +30 -12
  5. package/cli/bulk-checkpoint.mjs +34 -3
  6. package/cli/case-pack.mjs +39 -92
  7. package/cli/command-argument-grammar.mjs +211 -0
  8. package/cli/command-reference.mjs +200 -213
  9. package/cli/completion.mjs +5 -1
  10. package/cli/discovery-observation-snapshot.mjs +16 -3
  11. package/cli/discovery-scan-command-runner.mjs +19 -0
  12. package/cli/discovery-scan.mjs +6 -0
  13. package/cli/domain-control-observations.mjs +1 -1
  14. package/cli/inline-command-families.mjs +1 -0
  15. package/cli/input.mjs +116 -0
  16. package/cli/interchange-report.mjs +14 -0
  17. package/cli/lookalike-calibration.mjs +7 -2
  18. package/cli/lookup-brief.mjs +4 -2
  19. package/cli/lookup-browser-view.mjs +1 -1
  20. package/cli/mail-header-review.mjs +467 -0
  21. package/cli/review-command-runner.mjs +25 -0
  22. package/cli/risk-calibration.mjs +19 -17
  23. package/cli/runner.mjs +7 -1
  24. package/docs/cli-reference.md +8 -1
  25. package/docs/cli.md +24 -1
  26. package/lib/bounded-json.mjs +9 -6
  27. package/lib/dns-convergence-review.mjs +1 -0
  28. package/lib/dns-intelligence.mjs +37 -3
  29. package/lib/dnssec-chain-validation.mjs +6 -0
  30. package/lib/domain-change-packet.mjs +42 -14
  31. package/lib/domain-control-flight-recorder.mjs +4 -1
  32. package/lib/domain-control-manifest.mjs +16 -16
  33. package/lib/domain-posture-parsers.mjs +114 -3
  34. package/lib/domain-posture.mjs +34 -3
  35. package/lib/evidence-export.mjs +63 -13
  36. package/lib/evidence-report.mjs +2 -2
  37. package/lib/html-signals.mjs +39 -33
  38. package/lib/local-geoip-evidence.mjs +19 -3
  39. package/lib/lookup-response.mjs +5 -4
  40. package/lib/lookup-source-progress.mjs +27 -4
  41. package/lib/nameserver-preflight-review.mjs +1 -0
  42. package/lib/rdap-capabilities.mjs +1 -1
  43. package/lib/registry-insights.mjs +35 -22
  44. package/lib/risk-calibration-summary.mjs +1 -3
  45. package/lib/security-txt.mjs +33 -1
  46. package/lib/static-html-analysis.mjs +69 -13
  47. package/lib/website-technology.mjs +79 -11
  48. package/lib/zone-intent-review.mjs +16 -6
  49. package/package.json +1 -1
  50. package/packages/cases/case-migration-model.mjs +71 -22
  51. package/packages/cases/case-record-contracts.mjs +5 -20
  52. package/packages/cases/case-record-core.mjs +10 -21
  53. package/packages/cases/case-record-decisions.mjs +72 -0
  54. package/packages/cases/case-record-model.mjs +1 -0
  55. package/packages/cases/case-record-operations.mjs +240 -3
  56. package/packages/cases/case-record-projection.mjs +218 -0
  57. package/packages/cases/case-report.mjs +11 -6
  58. package/packages/cases/case-response-model.mjs +43 -6
  59. package/packages/cases/case-response-packet.mjs +78 -27
  60. package/packages/cases/case-storage-model.mjs +6 -2
  61. package/packages/comparison/comparison-ledger-bulk.mjs +9 -10
  62. package/packages/comparison/page-similarity.mjs +3 -1
  63. package/packages/contracts/case-portability.mjs +183 -53
  64. package/packages/contracts/cli-command-semantics.mjs +93 -0
  65. package/packages/contracts/risk-calibration.mjs +15 -10
  66. package/packages/contracts/workspace-portability.mjs +44 -7
  67. package/packages/evidence/domain-control-runtime.mjs +46 -6
  68. package/packages/interchange/external-findings-import.mjs +115 -46
  69. package/packages/investigation/lookup-asset-graph.mjs +35 -27
  70. package/packages/workspace/brand-profile-model.mjs +118 -2
  71. package/packages/workspace/bulk-review-model.mjs +13 -7
  72. package/packages/workspace/bulk-session-model.mjs +5 -4
  73. package/packages/workspace/detection-rule-model.mjs +70 -22
  74. package/packages/workspace/relationship-observation-model.mjs +4 -1
  75. package/packages/workspace/website-snapshot-model.mjs +106 -8
  76. package/packages/workspace/workspace-archive.mjs +2 -0
  77. package/cli/evidence-command-arguments.mjs +0 -156
@@ -1,13 +1,10 @@
1
- import { CASE_DISPOSITIONS, CASE_IMPORT_VERSIONS, CASE_SCHEMA_VERSION, CASE_SOURCES, CASE_STATUSES, DEFAULT_DISPOSITION, DEFAULT_SOURCE, DEFAULT_STATUS, MAX_CASES, MAX_NOTES_PER_CASE, deterministicId, caseTimestampOrNull, normalizeCase, normalizeDomain, normalizeEvidenceHistory, normalizeNotes, normalizeReviewReasonCode, normalizeTags, objectRecord, safeId, } from "./case-record-model.mjs";
1
+ import { CASE_IMPORT_VERSIONS, CASE_SCHEMA_VERSION, DEFAULT_DISPOSITION, DEFAULT_SOURCE, DEFAULT_STATUS, MAX_CASES, MAX_NOTES_PER_CASE, deterministicId, caseTimestampOrNull, normalizeCase, normalizeDomain, normalizeEvidenceHistory, normalizeNotes, normalizeReviewReasonCode, normalizeTags, objectRecord, safeId, isValidDisposition, isValidSource, isValidStatus, } from "./case-record-model.mjs";
2
2
  import { CASE_REPORT_SCHEMA, PUBLIC_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";
3
3
  import { canonicalArtifactJsonV2 } from "../evidence/artifact-integrity.mjs";
4
4
  import { assertBoundedJsonStructure } from "../../lib/bounded-json.mjs";
5
5
  import { inspectCaseBrandProfileIds, unionCaseBrandProfileIds, } from "./case-brand-profile-references.mjs";
6
6
  import { caseInvestigationBranchReferences, mergeCaseInvestigationBranches, normalizeCaseInvestigationBranches, } from "./case-investigation-branch-model.mjs";
7
- import { buildCaseClosureLinkContext, mergeCaseActions, mergeCaseAssertions, mergeCaseClosureHistories, mergeCaseDecisions, mergeCaseEvidencePins, mergeCaseManualTrail, mergeCaseObservedEffectHistories, mergeCaseSightings, normalizeCaseActions, normalizeCaseAssertions, normalizeCaseClosureHistory, normalizeCaseDecisions, normalizeCaseEvidencePins, normalizeCaseManualTrail, normalizeCaseObservedEffectHistory, normalizeCaseSightings, } from "./case-response-model.mjs";
8
- const STATUS_VALUES = new Set(CASE_STATUSES.map((item) => item.value));
9
- const DISPOSITION_VALUES = new Set(CASE_DISPOSITIONS.map((item) => item.value));
10
- const SOURCE_VALUES = new Set(CASE_SOURCES.map((item) => item.value));
7
+ import { buildCaseClosureLinkContext, mergeCaseActions, mergeCaseClosureHistories, mergeCaseObservedEffectHistories, MAX_CASE_ASSERTIONS, MAX_CASE_DECISIONS, MAX_CASE_EVIDENCE_PINS, MAX_CASE_MANUAL_TRAIL_EVENTS, MAX_CASE_SIGHTINGS, normalizeCaseActions, normalizeCaseAssertions, normalizeCaseClosureHistory, normalizeCaseDecisions, normalizeCaseEvidencePins, normalizeCaseManualTrail, normalizeCaseObservedEffectHistory, normalizeCaseSightings, } from "./case-response-model.mjs";
11
8
  export const MAX_CASE_INPUT_RECORDS = 2_000;
12
9
  function compareCodeUnits(left, right) {
13
10
  return left < right ? -1 : left > right ? 1 : 0;
@@ -51,7 +48,7 @@ export function normalizeCaseStore(raw) {
51
48
  throw new TypeError(`Unversioned Case stores are retired. Export or reset them explicitly before using Case schema ${CASE_SCHEMA_VERSION}; no data was changed.`);
52
49
  }
53
50
  if (!CASE_IMPORT_VERSIONS.includes(sourceVersion) && sourceVersion < CASE_SCHEMA_VERSION) {
54
- throw new TypeError(`Case schema ${sourceVersion} is not part of the supported compatibility boundary. Exact published schemas 12 and 13 migrate to schema ${CASE_SCHEMA_VERSION}; no data was changed.`);
51
+ throw new TypeError(`Case schema ${sourceVersion} is not part of the supported compatibility boundary. Exact published schemas ${CASE_IMPORT_VERSIONS.filter((version) => version < CASE_SCHEMA_VERSION).join(', ')} migrate to schema ${CASE_SCHEMA_VERSION}; no data was changed.`);
55
52
  }
56
53
  if (sourceVersion > CASE_SCHEMA_VERSION) {
57
54
  throw new TypeError(`Case schema ${sourceVersion} is newer than the supported schema ${CASE_SCHEMA_VERSION}; no data was changed.`);
@@ -92,6 +89,17 @@ function assertCurrentCaseShape(raw) {
92
89
  if (Object.keys(record).length > 0 && !Object.hasOwn(record, 'routeObservedAt')) {
93
90
  throw new TypeError(`Case schema ${CASE_SCHEMA_VERSION} response actions must declare their route observation time, including null; unreleased local Case checkpoints are not interpreted as the current format and no data was changed.`);
94
91
  }
92
+ if (Object.keys(record).length > 0 && !Object.hasOwn(record, 'routeReviewAfter')) {
93
+ throw new TypeError(`Case schema ${CASE_SCHEMA_VERSION} response actions must declare their route review deadline, including null; unreleased local Case checkpoints are not interpreted as the current format and no data was changed.`);
94
+ }
95
+ }
96
+ const decisions = itemRecord.decisions;
97
+ for (const decision of Array.isArray(decisions) ? decisions : []) {
98
+ const record = objectRecord(decision);
99
+ if (Object.keys(record).length > 0
100
+ && (!Object.hasOwn(record, 'confidence') || !Object.hasOwn(record, 'confidenceBasis'))) {
101
+ throw new TypeError(`Case schema ${CASE_SCHEMA_VERSION} analyst decisions must declare confidence and its basis; unreleased local Case checkpoints are not interpreted as the current format and no data was changed.`);
102
+ }
95
103
  }
96
104
  }
97
105
  }
@@ -140,9 +148,6 @@ function boundedCaseList(raw) {
140
148
  * @param {string} [nowIso]
141
149
  * @returns {CaseRecord}
142
150
  */
143
- function importScalar(value, valid) {
144
- return typeof value === 'string' && valid.has(value) ? value : undefined;
145
- }
146
151
  /**
147
152
  * Validates one imported record into a patch. Unlike normalizeCase, absent or
148
153
  * invalid scalar fields stay `undefined` (never defaulted) and a missing/invalid
@@ -174,6 +179,8 @@ function extractImportPatch(raw, importedVersion) {
174
179
  const pinIds = new Set(evidencePins.map((item) => item.id));
175
180
  const actions = normalizeCaseActions(record.actions, normalizedFallback, { ...timestampOptions, validEvidencePinIds: pinIds });
176
181
  const assertions = normalizeCaseAssertions(record.assertions, normalizedFallback, pinIds, timestampOptions);
182
+ const decisions = normalizeCaseDecisions(record.decisions, normalizedFallback, pinIds, timestampOptions);
183
+ const manualTrail = normalizeCaseManualTrail(record.manualTrail, normalizedFallback, timestampOptions);
177
184
  const sightings = normalizeCaseSightings(record.sightings, normalizedFallback, pinIds, timestampOptions);
178
185
  const observedEffects = normalizeCaseObservedEffectHistory(importedVersion >= 13 ? record.observedEffects : undefined, normalizedFallback, pinIds, new Set(sightings.map((item) => item.id)), timestampOptions);
179
186
  const closures = normalizeCaseClosureHistory(importedVersion >= 13 ? record.closures : undefined, normalizedFallback, new Set(observedEffects.reviews.map((item) => item.id)), new Set(actions.map((item) => item.id)), timestampOptions, buildCaseClosureLinkContext(observedEffects, actions));
@@ -181,17 +188,26 @@ function extractImportPatch(raw, importedVersion) {
181
188
  const brandProfileReferences = importedVersion >= 12
182
189
  ? inspectCaseBrandProfileIds(record.brandProfileIds)
183
190
  : { ids: [], omitted: 0 };
191
+ const authoredHistoryOmitted = [
192
+ [record.evidencePins, evidencePins],
193
+ [record.decisions, decisions],
194
+ [record.assertions, assertions],
195
+ [record.manualTrail, manualTrail],
196
+ [record.sightings, sightings],
197
+ ].reduce((total, [candidates, retained]) => total
198
+ + Math.max(0, (Array.isArray(candidates) ? candidates.length : 0) - retained.length), 0);
184
199
  return {
185
200
  domain,
186
201
  rawId: typeof record.id === 'string' ? record.id : null,
187
- status: importScalar(record.status, STATUS_VALUES),
188
- disposition: importScalar(record.disposition, DISPOSITION_VALUES),
202
+ status: isValidStatus(record.status) ? record.status : undefined,
203
+ disposition: isValidDisposition(record.disposition) ? record.disposition : undefined,
189
204
  reviewReasonCode: Object.hasOwn(record, 'reviewReasonCode')
190
205
  ? normalizeReviewReasonCode(record.reviewReasonCode)
191
206
  : undefined,
192
207
  brandProfileIds: brandProfileReferences.ids,
193
208
  brandProfileReferencesOmitted: brandProfileReferences.omitted,
194
- source: importScalar(record.source, SOURCE_VALUES),
209
+ authoredHistoryOmitted,
210
+ source: isValidSource(record.source) ? record.source : undefined,
195
211
  evidenceHistory: normalizeEvidenceHistory(rawEvidence, {
196
212
  source: 'import',
197
213
  fallback: importFallback,
@@ -199,10 +215,10 @@ function extractImportPatch(raw, importedVersion) {
199
215
  caseDomain: domain,
200
216
  }),
201
217
  evidencePins,
202
- decisions: normalizeCaseDecisions(record.decisions, normalizedFallback, pinIds, timestampOptions),
218
+ decisions,
203
219
  actions,
204
220
  assertions,
205
- manualTrail: normalizeCaseManualTrail(record.manualTrail, normalizedFallback, timestampOptions),
221
+ manualTrail,
206
222
  sightings,
207
223
  observedEffects,
208
224
  closures,
@@ -229,6 +245,22 @@ function unionNotes(a, b) {
229
245
  const notes = [...byId.values()].sort((left, right) => Date.parse(left.createdAt) - Date.parse(right.createdAt));
230
246
  return notes.slice(Math.max(0, notes.length - MAX_NOTES_PER_CASE));
231
247
  }
248
+ function retainLocalAuthoredRecords(local, imported, maximum) {
249
+ const records = [...local];
250
+ const retainedIds = new Set(records.map((item) => item.id));
251
+ let omitted = 0;
252
+ for (const item of imported) {
253
+ if (retainedIds.has(item.id))
254
+ continue;
255
+ if (records.length >= maximum) {
256
+ omitted += 1;
257
+ continue;
258
+ }
259
+ records.push(item);
260
+ retainedIds.add(item.id);
261
+ }
262
+ return { records, omitted };
263
+ }
232
264
  // Additive, deduplicated union of two evidence histories. Identical material
233
265
  // collapses (earliest firstCapturedAt, latest capturedAt), distinct snapshots
234
266
  // are retained subject to the per-case bound, and an older import can never
@@ -274,16 +306,29 @@ function caseFromPatch(patch, now) {
274
306
  function applyImportPatch(local, patch) {
275
307
  const importNewer = patch.updatedAt !== null && Date.parse(patch.updatedAt) > Date.parse(local.updatedAt);
276
308
  const fallback = patch.updatedAt || local.updatedAt;
277
- const evidencePins = mergeCaseEvidencePins(local.evidencePins, patch.evidencePins, fallback);
309
+ const pinSelection = retainLocalAuthoredRecords(local.evidencePins, patch.evidencePins, MAX_CASE_EVIDENCE_PINS);
310
+ const evidencePins = normalizeCaseEvidencePins(pinSelection.records, fallback);
278
311
  const pinIds = new Set(evidencePins.map((item) => item.id));
279
312
  const actions = mergeCaseActions(local.actions, patch.actions, fallback, pinIds);
280
- const assertions = mergeCaseAssertions(local.assertions, patch.assertions, fallback, pinIds);
313
+ const assertionSelection = retainLocalAuthoredRecords(local.assertions, patch.assertions, MAX_CASE_ASSERTIONS);
314
+ const assertions = normalizeCaseAssertions(assertionSelection.records, fallback, pinIds);
281
315
  const branchReferences = caseInvestigationBranchReferences({ evidencePins, actions, assertions });
282
- const sightings = mergeCaseSightings(local.sightings, patch.sightings, fallback, pinIds);
316
+ const sightingSelection = retainLocalAuthoredRecords(local.sightings, patch.sightings, MAX_CASE_SIGHTINGS);
317
+ const sightings = normalizeCaseSightings(sightingSelection.records, fallback, pinIds);
283
318
  const sightingIds = new Set(sightings.map((item) => item.id));
284
319
  const observedEffects = mergeCaseObservedEffectHistories(local.observedEffects, patch.observedEffects, fallback, pinIds, sightingIds);
285
320
  const closures = mergeCaseClosureHistories(local.closures, patch.closures, fallback, new Set(observedEffects.reviews.map((item) => item.id)), new Set(actions.map((item) => item.id)), buildCaseClosureLinkContext(observedEffects, actions));
286
321
  const brandProfileReferences = unionCaseBrandProfileIds(local.brandProfileIds, patch.brandProfileIds);
322
+ const decisionSelection = retainLocalAuthoredRecords(local.decisions, patch.decisions, MAX_CASE_DECISIONS);
323
+ const decisions = normalizeCaseDecisions(decisionSelection.records, fallback, pinIds);
324
+ const trailSelection = retainLocalAuthoredRecords(local.manualTrail, patch.manualTrail, MAX_CASE_MANUAL_TRAIL_EVENTS);
325
+ const manualTrail = normalizeCaseManualTrail(trailSelection.records, fallback);
326
+ const authoredHistoryOmitted = patch.authoredHistoryOmitted
327
+ + pinSelection.omitted
328
+ + decisionSelection.omitted
329
+ + assertionSelection.omitted
330
+ + trailSelection.omitted
331
+ + sightingSelection.omitted;
287
332
  return { record: {
288
333
  ...local,
289
334
  status: patch.status !== undefined && importNewer ? patch.status : local.status,
@@ -293,10 +338,10 @@ function applyImportPatch(local, patch) {
293
338
  source: patch.source !== undefined && importNewer ? patch.source : local.source,
294
339
  evidenceHistory: mergeEvidenceHistories(local.evidenceHistory, patch.evidenceHistory, local.domain),
295
340
  evidencePins,
296
- decisions: mergeCaseDecisions(local.decisions, patch.decisions, fallback, pinIds),
341
+ decisions,
297
342
  actions,
298
343
  assertions,
299
- manualTrail: mergeCaseManualTrail(local.manualTrail, patch.manualTrail, fallback),
344
+ manualTrail,
300
345
  sightings,
301
346
  observedEffects,
302
347
  closures,
@@ -305,7 +350,7 @@ function applyImportPatch(local, patch) {
305
350
  notes: unionNotes(local.notes, patch.notes),
306
351
  createdAt: patch.createdAt && Date.parse(patch.createdAt) < Date.parse(local.createdAt) ? patch.createdAt : local.createdAt,
307
352
  updatedAt: importNewer ? (patch.updatedAt ?? local.updatedAt) : local.updatedAt,
308
- }, brandProfileReferencesOmitted: brandProfileReferences.omitted };
353
+ }, brandProfileReferencesOmitted: brandProfileReferences.omitted, authoredHistoryOmitted };
309
354
  }
310
355
  function pickFreeId(preferred, domain, used) {
311
356
  const wanted = safeId(preferred);
@@ -418,7 +463,7 @@ function caseCollectionImportEnvelope(importedRaw) {
418
463
  * reinterpreted.
419
464
  * @param {CaseRecord[]} localCases
420
465
  * @param {unknown} importedRaw
421
- * @returns {{ cases: CaseRecord[], added: number, updated: number, skipped: number, brandProfileReferencesOmitted: number }}
466
+ * @returns {{ cases: CaseRecord[], added: number, updated: number, skipped: number, brandProfileReferencesOmitted: number, authoredHistoryOmitted: number }}
422
467
  */
423
468
  export function mergeCases(localCases, importedRaw) {
424
469
  assertBoundedJsonStructure(importedRaw, 'Case import', CASE_INPUT_JSON_LIMITS);
@@ -426,7 +471,7 @@ export function mergeCases(localCases, importedRaw) {
426
471
  const importedVersion = parseStoreVersion(importedEnvelope);
427
472
  if (importedVersion !== null && Number.isSafeInteger(importedVersion) && importedVersion < CASE_SCHEMA_VERSION
428
473
  && !CASE_IMPORT_VERSIONS.includes(importedVersion)) {
429
- throw new Error(`Case schema ${importedVersion} is not part of the supported compatibility boundary. Exact published schemas 12 and 13 migrate to schema ${CASE_SCHEMA_VERSION}; no data was changed.`);
474
+ throw new Error(`Case schema ${importedVersion} is not part of the supported compatibility boundary. Exact published schemas ${CASE_IMPORT_VERSIONS.filter((version) => version < CASE_SCHEMA_VERSION).join(', ')} migrate to schema ${CASE_SCHEMA_VERSION}; no data was changed.`);
430
475
  }
431
476
  if (importedVersion !== null && Number.isSafeInteger(importedVersion) && importedVersion > CASE_SCHEMA_VERSION) {
432
477
  throw new Error(`Case schema ${importedVersion} is newer than the supported schema ${CASE_SCHEMA_VERSION}; no data was changed.`);
@@ -446,6 +491,7 @@ export function mergeCases(localCases, importedRaw) {
446
491
  const imported = boundedCaseList(importedEnvelope);
447
492
  let skipped = imported.omitted;
448
493
  let brandProfileReferencesOmitted = 0;
494
+ let authoredHistoryOmitted = 0;
449
495
  const fallback = new Date(0).toISOString();
450
496
  for (const item of imported.items) {
451
497
  const patch = extractImportPatch(item, supportedImportedVersion);
@@ -458,6 +504,7 @@ export function mergeCases(localCases, importedRaw) {
458
504
  const merged = applyImportPatch(existing, patch);
459
505
  byDomain.set(patch.domain, merged.record);
460
506
  brandProfileReferencesOmitted += patch.brandProfileReferencesOmitted + merged.brandProfileReferencesOmitted;
507
+ authoredHistoryOmitted += merged.authoredHistoryOmitted;
461
508
  updated += 1;
462
509
  }
463
510
  else if (byDomain.size < MAX_CASES) {
@@ -466,6 +513,7 @@ export function mergeCases(localCases, importedRaw) {
466
513
  usedIds.add(record.id);
467
514
  byDomain.set(patch.domain, record);
468
515
  brandProfileReferencesOmitted += patch.brandProfileReferencesOmitted;
516
+ authoredHistoryOmitted += patch.authoredHistoryOmitted;
469
517
  added += 1;
470
518
  }
471
519
  else {
@@ -478,5 +526,6 @@ export function mergeCases(localCases, importedRaw) {
478
526
  updated,
479
527
  skipped,
480
528
  brandProfileReferencesOmitted,
529
+ authoredHistoryOmitted,
481
530
  };
482
531
  }
@@ -1,30 +1,15 @@
1
1
  import { ANALYST_REVIEW_REASONS } from "../../lib/analyst-taxonomy.mjs";
2
2
  import { CASE_IMPORT_VERSIONS, CASE_SCHEMA_VERSION, PUBLIC_CASE_SCHEMA_VERSION, MAX_CASE_BRAND_PROFILE_ID_CANDIDATES, MAX_CASE_BRAND_PROFILE_IDS, MAX_CASE_IMPORT_BYTES, MAX_CASE_STORE_BYTES, MAX_CASES, MAX_DOMAIN_LENGTH, MAX_EVIDENCE_CHANGES, MAX_EVIDENCE_DETAIL_LENGTH, MAX_EVIDENCE_FACTORS, MAX_EVIDENCE_MUTATIONS, MAX_EVIDENCE_NAMESERVERS, MAX_EVIDENCE_SNAPSHOTS_PER_CASE, MAX_EVIDENCE_STRING_LENGTH, MAX_EVIDENCE_TITLE_LENGTH, MAX_NOTE_LENGTH, MAX_NOTES_PER_CASE, MAX_TAG_LENGTH, MAX_TAGS_PER_CASE, } from "../contracts/case-portability.mjs";
3
+ import { CASE_DISPOSITIONS, CASE_STATUSES, DEFAULT_DISPOSITION, DEFAULT_STATUS, } from "./case-record-decisions.mjs";
3
4
  export { CASE_IMPORT_VERSIONS, CASE_SCHEMA_VERSION, PUBLIC_CASE_SCHEMA_VERSION, MAX_CASE_BRAND_PROFILE_ID_CANDIDATES, MAX_CASE_BRAND_PROFILE_IDS, MAX_CASE_IMPORT_BYTES, MAX_CASE_STORE_BYTES, MAX_CASES, MAX_DOMAIN_LENGTH, MAX_EVIDENCE_CHANGES, MAX_EVIDENCE_DETAIL_LENGTH, MAX_EVIDENCE_FACTORS, MAX_EVIDENCE_MUTATIONS, MAX_EVIDENCE_NAMESERVERS, MAX_EVIDENCE_SNAPSHOTS_PER_CASE, MAX_EVIDENCE_STRING_LENGTH, MAX_EVIDENCE_TITLE_LENGTH, MAX_NOTE_LENGTH, MAX_NOTES_PER_CASE, MAX_TAG_LENGTH, MAX_TAGS_PER_CASE, };
4
- export const CASE_STATUSES = [
5
- { value: 'new', label: 'New' },
6
- { value: 'reviewing', label: 'Reviewing' },
7
- { value: 'monitoring', label: 'Monitoring' },
8
- { value: 'escalated', label: 'Escalated' },
9
- { value: 'resolved', label: 'Resolved' },
10
- ];
11
- export const CASE_DISPOSITIONS = [
12
- { value: 'unreviewed', label: 'Unreviewed' },
13
- { value: 'suspicious', label: 'Suspicious' },
14
- { value: 'confirmed_abuse', label: 'Confirmed abuse' },
15
- { value: 'false_positive', label: 'False positive' },
16
- { value: 'expected', label: 'Expected' },
17
- { value: 'closed_no_action', label: 'Closed without action' },
18
- ];
5
+ export { CASE_DISPOSITIONS, CASE_STATUSES, DEFAULT_DISPOSITION, DEFAULT_STATUS, };
19
6
  export const CASE_REVIEW_REASONS = ANALYST_REVIEW_REASONS.map((item) => ({ ...item }));
20
- export const CASE_SOURCES = [
7
+ export const CASE_SOURCES = Object.freeze([
21
8
  { value: 'lookup', label: 'Lookup' },
22
9
  { value: 'bulk', label: 'Bulk' },
23
10
  { value: 'monitor', label: 'Monitor' },
24
11
  { value: 'manual', label: 'Manual' },
25
12
  { value: 'unknown', label: 'Unknown' },
26
- ];
27
- export const EVIDENCE_SOURCES = ['lookup', 'bulk', 'monitor', 'import', 'unknown'];
28
- export const DEFAULT_STATUS = 'new';
29
- export const DEFAULT_DISPOSITION = 'unreviewed';
13
+ ]);
14
+ export const EVIDENCE_SOURCES = Object.freeze(['lookup', 'bulk', 'monitor', 'import', 'unknown']);
30
15
  export const DEFAULT_SOURCE = 'unknown';
@@ -1,6 +1,7 @@
1
1
  // Pure, framework-neutral analyst-case records, evidence histories, bounded
2
2
  // record normalization, and analyst updates.
3
- import { CASE_DISPOSITIONS, CASE_SCHEMA_VERSION, CASE_REVIEW_REASONS, CASE_SOURCES, CASE_STATUSES, DEFAULT_DISPOSITION, DEFAULT_SOURCE, DEFAULT_STATUS, EVIDENCE_SOURCES, MAX_DOMAIN_LENGTH, MAX_NOTES_PER_CASE, MAX_NOTE_LENGTH, MAX_TAGS_PER_CASE, MAX_TAG_LENGTH, } from "./case-record-contracts.mjs";
3
+ import { CASE_SCHEMA_VERSION, CASE_REVIEW_REASONS, CASE_SOURCES, DEFAULT_DISPOSITION, DEFAULT_SOURCE, DEFAULT_STATUS, EVIDENCE_SOURCES, MAX_DOMAIN_LENGTH, MAX_NOTES_PER_CASE, MAX_NOTE_LENGTH, MAX_TAGS_PER_CASE, MAX_TAG_LENGTH, } from "./case-record-contracts.mjs";
4
+ import { dispositionLabel, isValidDisposition, isValidStatus, statusLabel, } from "./case-record-decisions.mjs";
4
5
  import { normalizeExplicitIsoTimestamp, normalizeLegacyIsoTimestamp } from "../evidence/observation.mjs";
5
6
  import { canonicalRegistrableDomain } from "../../lib/registrable-domain.mjs";
6
7
  // Forward-version policy (two distinct guarantees):
@@ -26,13 +27,9 @@ export const EVIDENCE_SOURCE_RANK = {
26
27
  import: 1,
27
28
  unknown: 0,
28
29
  };
29
- const STATUS_VALUES = new Set(CASE_STATUSES.map((item) => item.value));
30
- const DISPOSITION_VALUES = new Set(CASE_DISPOSITIONS.map((item) => item.value));
31
30
  const REVIEW_REASON_VALUES = new Set(CASE_REVIEW_REASONS.map((item) => item.value).filter(Boolean));
32
31
  const SOURCE_VALUES = new Set(CASE_SOURCES.map((item) => item.value));
33
- const STATUS_LABELS = Object.fromEntries(CASE_STATUSES.map((item) => [item.value, item.label]));
34
- const DISPOSITION_LABELS = Object.fromEntries(CASE_DISPOSITIONS.map((item) => [item.value, item.label]));
35
- const SOURCE_LABELS = Object.fromEntries(CASE_SOURCES.map((item) => [item.value, item.label]));
32
+ const SOURCE_LABELS = new Map(CASE_SOURCES.map((item) => [item.value, item.label]));
36
33
  // Availability tokens that actually assert something about the domain. Anything
37
34
  // else ('unknown', 'error', empty) is not, on its own, material evidence.
38
35
  export const CONCLUSIVE_AVAILABILITY = new Set([
@@ -59,21 +56,13 @@ export function objectRecord(value) {
59
56
  ? value
60
57
  : {};
61
58
  }
62
- export function statusLabel(value) {
63
- return (typeof value === 'string' ? STATUS_LABELS[value] : '') || String(value || '');
64
- }
65
- export function dispositionLabel(value) {
66
- return (typeof value === 'string' ? DISPOSITION_LABELS[value] : '') || String(value || '');
67
- }
68
59
  export function sourceLabel(value) {
69
- return (typeof value === 'string' ? SOURCE_LABELS[value] : '') || String(value || '');
70
- }
71
- export function isValidStatus(value) {
72
- return typeof value === 'string' && STATUS_VALUES.has(value);
60
+ return isValidSource(value) ? SOURCE_LABELS.get(value) : String(value || '');
73
61
  }
74
- export function isValidDisposition(value) {
75
- return typeof value === 'string' && DISPOSITION_VALUES.has(value);
62
+ export function isValidSource(value) {
63
+ return typeof value === 'string' && SOURCE_VALUES.has(value);
76
64
  }
65
+ export { dispositionLabel, isValidDisposition, isValidStatus, statusLabel, };
77
66
  /** Fresh, safe, effectively-unique id for a brand-new local record. */
78
67
  export function makeId() {
79
68
  if (typeof globalThis.crypto?.randomUUID === 'function')
@@ -147,16 +136,16 @@ export function normalizeEvidenceHostnameForCase(value, caseDomain) {
147
136
  return canonicalRegistrableDomain(hostname) === canonicalCase ? hostname : null;
148
137
  }
149
138
  export function normalizeStatus(value) {
150
- return typeof value === 'string' && STATUS_VALUES.has(value) ? value : DEFAULT_STATUS;
139
+ return isValidStatus(value) ? value : DEFAULT_STATUS;
151
140
  }
152
141
  export function normalizeDisposition(value) {
153
- return typeof value === 'string' && DISPOSITION_VALUES.has(value) ? value : DEFAULT_DISPOSITION;
142
+ return isValidDisposition(value) ? value : DEFAULT_DISPOSITION;
154
143
  }
155
144
  export function normalizeReviewReasonCode(value) {
156
145
  return typeof value === 'string' && REVIEW_REASON_VALUES.has(value) ? value : null;
157
146
  }
158
147
  export function normalizeSource(value) {
159
- return typeof value === 'string' && SOURCE_VALUES.has(value) ? value : DEFAULT_SOURCE;
148
+ return isValidSource(value) ? value : DEFAULT_SOURCE;
160
149
  }
161
150
  /** Parsed ISO string, or null when missing/invalid (used for import ordering). */
162
151
  export function isoOrNull(value) {
@@ -0,0 +1,72 @@
1
+ // Lightweight canonical Case status and disposition definitions. Persistence,
2
+ // portability and presentation consumers derive these identities and policies
3
+ // without importing the wider Case record or serialisation graph.
4
+ export const CASE_STATUSES = Object.freeze([
5
+ { value: 'new', label: 'New' },
6
+ { value: 'reviewing', label: 'Reviewing' },
7
+ { value: 'monitoring', label: 'Monitoring' },
8
+ { value: 'escalated', label: 'Escalated' },
9
+ { value: 'resolved', label: 'Resolved' },
10
+ ]);
11
+ export const CASE_DISPOSITIONS = Object.freeze([
12
+ { value: 'unreviewed', label: 'Unreviewed' },
13
+ { value: 'suspicious', label: 'Suspicious' },
14
+ { value: 'confirmed_abuse', label: 'Confirmed abuse' },
15
+ { value: 'false_positive', label: 'False positive' },
16
+ { value: 'expected', label: 'Expected' },
17
+ { value: 'closed_no_action', label: 'Closed without action' },
18
+ ]);
19
+ export const DEFAULT_STATUS = 'new';
20
+ export const DEFAULT_DISPOSITION = 'unreviewed';
21
+ const CASE_STATUS_OPERATION_POLICIES = Object.freeze({
22
+ new: { closed: false, closureRequired: false, directEdit: 'allowed' },
23
+ reviewing: { closed: false, closureRequired: false, directEdit: 'allowed' },
24
+ monitoring: { closed: false, closureRequired: false, directEdit: 'allowed' },
25
+ escalated: { closed: false, closureRequired: false, directEdit: 'allowed' },
26
+ resolved: { closed: true, closureRequired: true, directEdit: 'existing_only' },
27
+ });
28
+ const CASE_DISPOSITION_DECISION_POLICIES = Object.freeze({
29
+ unreviewed: { reviewed: false, supportsDefensiveResponse: false },
30
+ suspicious: { reviewed: true, supportsDefensiveResponse: true },
31
+ confirmed_abuse: { reviewed: true, supportsDefensiveResponse: true },
32
+ false_positive: { reviewed: true, supportsDefensiveResponse: false },
33
+ expected: { reviewed: true, supportsDefensiveResponse: false },
34
+ closed_no_action: { reviewed: true, supportsDefensiveResponse: false },
35
+ });
36
+ function statusOption(value) {
37
+ return CASE_STATUSES.find((option) => option.value === value);
38
+ }
39
+ function dispositionOption(value) {
40
+ return CASE_DISPOSITIONS.find((option) => option.value === value);
41
+ }
42
+ export function isValidStatus(value) {
43
+ return Boolean(statusOption(value));
44
+ }
45
+ export function isValidDisposition(value) {
46
+ return Boolean(dispositionOption(value));
47
+ }
48
+ export function statusLabel(value) {
49
+ return statusOption(value)?.label ?? String(value || '');
50
+ }
51
+ export function dispositionLabel(value) {
52
+ return dispositionOption(value)?.label ?? String(value || '');
53
+ }
54
+ export function caseStatusRequiresClosure(value) {
55
+ const option = statusOption(value);
56
+ return option ? CASE_STATUS_OPERATION_POLICIES[option.value].closureRequired : false;
57
+ }
58
+ export function caseStatusIsClosed(value) {
59
+ const option = statusOption(value);
60
+ return option ? CASE_STATUS_OPERATION_POLICIES[option.value].closed : false;
61
+ }
62
+ export function isReviewedCaseDisposition(value) {
63
+ const option = dispositionOption(value);
64
+ return option ? CASE_DISPOSITION_DECISION_POLICIES[option.value].reviewed : false;
65
+ }
66
+ export function caseDispositionSupportsDefensiveResponse(value) {
67
+ const option = dispositionOption(value);
68
+ return option ? CASE_DISPOSITION_DECISION_POLICIES[option.value].supportsDefensiveResponse : false;
69
+ }
70
+ export function caseStatusOptionsForDirectEdit(currentStatus) {
71
+ return CASE_STATUSES.filter((option) => (CASE_STATUS_OPERATION_POLICIES[option.value].directEdit === 'allowed' || option.value === currentStatus));
72
+ }
@@ -6,4 +6,5 @@ export * from "./case-record-core.mjs";
6
6
  export * from "./case-brand-profile-references.mjs";
7
7
  export * from "./case-evidence-model.mjs";
8
8
  export * from "./case-record-operations.mjs";
9
+ export * from "./case-record-projection.mjs";
9
10
  export * from "./case-investigation-branch-model.mjs";