From 1752a9fd4b9ff66237b92c25fecf7269b956cc2f Mon Sep 17 00:00:00 2001 From: Seowoo Han Date: Wed, 20 May 2026 19:45:09 +0900 Subject: [PATCH] Add artifact preview cache guard --- artifact-preview-cache-guard/README.md | 20 ++ .../acceptance-notes.md | 30 +++ artifact-preview-cache-guard/demo.js | 67 +++++++ artifact-preview-cache-guard/demo.mp4 | Bin 0 -> 43635 bytes artifact-preview-cache-guard/demo.svg | 26 +++ artifact-preview-cache-guard/index.js | 176 ++++++++++++++++++ .../requirements-map.md | 13 ++ artifact-preview-cache-guard/test.js | 148 +++++++++++++++ 8 files changed, 480 insertions(+) create mode 100644 artifact-preview-cache-guard/README.md create mode 100644 artifact-preview-cache-guard/acceptance-notes.md create mode 100644 artifact-preview-cache-guard/demo.js create mode 100644 artifact-preview-cache-guard/demo.mp4 create mode 100644 artifact-preview-cache-guard/demo.svg create mode 100644 artifact-preview-cache-guard/index.js create mode 100644 artifact-preview-cache-guard/requirements-map.md create mode 100644 artifact-preview-cache-guard/test.js diff --git a/artifact-preview-cache-guard/README.md b/artifact-preview-cache-guard/README.md new file mode 100644 index 00000000..79ac8ed4 --- /dev/null +++ b/artifact-preview-cache-guard/README.md @@ -0,0 +1,20 @@ +# Artifact Preview Cache Guard + +This module covers a metadata-aware preview and versioning slice of SCIBASE issue #14. + +It evaluates hosted scientific artifacts after uploads or version changes and decides whether previews can be reused, must be regenerated, or should be held because they could expose sensitive fields. The guard keeps spreadsheet previews, notebook previews, thumbnails, and metadata cards aligned with artifact hashes, upload versions, schema versions, and FAIR metadata. + +## What It Does + +- Classifies datasets, code, images, media, models, and supplementary artifacts. +- Detects stale previews when content hashes, upload versions, schema versions, or metadata digests drift. +- Blocks previews that expose sensitive fields such as participant IDs, emails, geolocation, or patient data. +- Produces dataset column diff summaries for upload version changes. +- Emits preview regeneration plans, FAIR metadata warnings, and a deterministic audit digest. + +## Run + +```bash +node artifact-preview-cache-guard/test.js +node artifact-preview-cache-guard/demo.js +``` diff --git a/artifact-preview-cache-guard/acceptance-notes.md b/artifact-preview-cache-guard/acceptance-notes.md new file mode 100644 index 00000000..a01d2a9b --- /dev/null +++ b/artifact-preview-cache-guard/acceptance-notes.md @@ -0,0 +1,30 @@ +# Acceptance Notes + +## Review Scenarios + +1. Current preview reuse + - Preview kind, artifact hash, upload version, schema version, and metadata digest match the artifact. + - The result is ready with a stable audit digest. + +2. Stale preview regeneration + - A new artifact version changes the content hash, schema, or metadata snapshot. + - The result requests preview regeneration and reports dataset column differences. + +3. Sensitive preview hold + - A preview exposes sensitive columns such as participant IDs or email addresses. + - The result blocks the preview until redaction is complete. + +4. FAIR metadata warning + - Missing license, creator, DOI, or persistent identifier metadata is surfaced before public release. + +## Validation + +```bash +node artifact-preview-cache-guard/test.js +node artifact-preview-cache-guard/demo.js +node --check artifact-preview-cache-guard/index.js +node --check artifact-preview-cache-guard/test.js +node --check artifact-preview-cache-guard/demo.js +``` + +The included `demo.mp4` is a five-second visual walkthrough of preview reuse, regeneration, and hold decisions. diff --git a/artifact-preview-cache-guard/demo.js b/artifact-preview-cache-guard/demo.js new file mode 100644 index 00000000..aedc5f04 --- /dev/null +++ b/artifact-preview-cache-guard/demo.js @@ -0,0 +1,67 @@ +"use strict" + +const { assessArtifactPreviews } = require("./index") + +const result = assessArtifactPreviews({ + artifacts: [ + { + id: "dose-response-csv", + path: "data/dose-response.csv", + version: "v3", + hash: "sha256:333", + schemaVersion: "v3", + access: "public", + metadata: { + title: "Dose response measurements", + license: "CC-BY-4.0", + creators: ["North Lab"], + doi: "10.1234/scibase.demo", + columns: ["sample_id", "dose", "response", "batch"], + }, + previousVersion: { + hash: "sha256:222", + metadata: { columns: ["sample_id", "dose", "response"] }, + }, + preview: { + kind: "table", + generatedFromHash: "sha256:222", + generatedFromVersion: "v2", + schemaVersion: "v2", + metadataDigest: "old", + }, + }, + { + id: "participant-roster", + path: "data/participants.csv", + version: "v1", + hash: "sha256:111", + schemaVersion: "v1", + access: "restricted", + metadata: { + title: "Participant roster", + license: "DUA-required", + creators: ["Clinic Lab"], + columns: ["participant_id", "email", "cohort"], + }, + preview: { + kind: "table", + generatedFromHash: "sha256:111", + generatedFromVersion: "v1", + schemaVersion: "v1", + metadataDigest: "old", + redactedFields: ["participant_id"], + }, + }, + ], +}) + +console.log("Artifact Preview Cache Guard Demo") +console.log("=================================") +console.log(`status: ${result.status}`) +console.log(`artifacts checked: ${result.artifactCount}`) +console.log(`refresh count: ${result.refreshCount}`) +console.log(`blocked count: ${result.blockedCount}`) +console.log(`first plan: ${result.previewPlan[0].action}`) +console.log(`first diff added: ${result.artifacts[0].diffSummary.addedColumns.join(", ")}`) +console.log(`blocked reason: ${result.previewPlan[1].reason}`) +console.log(`digest: ${result.auditDigest.slice(0, 16)}...`) diff --git a/artifact-preview-cache-guard/demo.mp4 b/artifact-preview-cache-guard/demo.mp4 new file mode 100644 index 0000000000000000000000000000000000000000..f820cc17aba99e070743b02209af9350f215e610 GIT binary patch literal 43635 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 TR7`fpOc9vN#b7Rdu(kXTyPduV literal 0 HcmV?d00001 diff --git a/artifact-preview-cache-guard/demo.svg b/artifact-preview-cache-guard/demo.svg new file mode 100644 index 00000000..c4bc823a --- /dev/null +++ b/artifact-preview-cache-guard/demo.svg @@ -0,0 +1,26 @@ + + Artifact Preview Cache Guard + A visual summary showing artifact previews being reused, regenerated, or held based on hash, version, metadata, and sensitive fields. + + + Artifact Preview Cache Guard + Keep hosted data/code previews aligned with hashes, versions, metadata, and redaction. + + + Reuse + Preview matches artifact + + + + Regenerate + Hash or version drift + + + + Hold + Sensitive field exposed + + Inputs + content hash, upload version, schema version, metadata digest, redacted fields + Output: preview plan, dataset diff summary, FAIR warnings, stable audit digest + diff --git a/artifact-preview-cache-guard/index.js b/artifact-preview-cache-guard/index.js new file mode 100644 index 00000000..bb225de2 --- /dev/null +++ b/artifact-preview-cache-guard/index.js @@ -0,0 +1,176 @@ +"use strict" + +const crypto = require("node:crypto") +const path = require("node:path") + +function stableStringify(value) { + if (Array.isArray(value)) return `[${value.map(stableStringify).join(",")}]` + if (value && typeof value === "object") { + return `{${Object.keys(value) + .sort() + .map((key) => `${JSON.stringify(key)}:${stableStringify(value[key])}`) + .join(",")}}` + } + return JSON.stringify(value) +} + +function digest(value) { + return crypto.createHash("sha256").update(stableStringify(value)).digest("hex") +} + +function artifactType(filePath) { + const ext = path.extname(filePath || "").toLowerCase() + if ([".csv", ".tsv", ".xlsx", ".parquet", ".json"].includes(ext)) return "dataset" + if ([".ipynb", ".py", ".r", ".jl"].includes(ext)) return "code" + if ([".png", ".jpg", ".jpeg", ".svg", ".tif", ".tiff"].includes(ext)) return "image" + if ([".mp4", ".mov", ".avi"].includes(ext)) return "media" + if ([".pt", ".onnx", ".pkl", ".safetensors"].includes(ext)) return "model" + return "supplement" +} + +function previewKindFor(type) { + return { + dataset: "table", + code: "notebook-or-code", + image: "thumbnail", + media: "poster-frame", + model: "model-card", + supplement: "metadata-card", + }[type] +} + +function isSensitiveField(field) { + return /patient|subject|email|phone|ssn|dob|birth|address|lat|lon|geo|location|participant/i.test(field) +} + +function compareColumns(previous = [], current = []) { + const prevSet = new Set(previous) + const currSet = new Set(current) + return { + added: current.filter((column) => !prevSet.has(column)), + removed: previous.filter((column) => !currSet.has(column)), + } +} + +function normalizeArtifact(artifact) { + return { + id: artifact.id, + path: artifact.path || "", + version: artifact.version || null, + hash: artifact.hash || null, + schemaVersion: artifact.schemaVersion || artifact.metadata?.schemaVersion || null, + access: artifact.access || "private", + metadata: artifact.metadata || {}, + preview: artifact.preview || null, + previousVersion: artifact.previousVersion || null, + } +} + +function evaluateArtifact(artifactInput) { + const artifact = normalizeArtifact(artifactInput) + const type = artifactType(artifact.path) + const expectedPreviewKind = previewKindFor(type) + const blockers = [] + const warnings = [] + const refreshReasons = [] + const metadataDigest = digest(artifact.metadata) + const columns = artifact.metadata.columns || [] + const sensitiveFields = columns.filter(isSensitiveField) + + if (!artifact.id) blockers.push("artifact is missing an id") + if (!artifact.path) blockers.push("artifact is missing a path") + if (!artifact.version) blockers.push(`${artifact.id || "artifact"} is missing an upload version`) + if (!artifact.hash) blockers.push(`${artifact.id || "artifact"} is missing a content hash`) + + for (const field of ["title", "license", "creators"]) { + if (!artifact.metadata[field]) warnings.push(`${artifact.id} metadata is missing ${field}`) + } + if (artifact.access === "public" && !artifact.metadata.doi && !artifact.metadata.persistentId) { + warnings.push(`${artifact.id} public artifact is missing DOI or persistentId metadata`) + } + + if (!artifact.preview) { + refreshReasons.push("preview is missing") + } else { + if (artifact.preview.kind !== expectedPreviewKind) { + refreshReasons.push(`preview kind ${artifact.preview.kind} should be ${expectedPreviewKind}`) + } + if (artifact.preview.generatedFromHash !== artifact.hash) { + refreshReasons.push("preview content hash is stale") + } + if (artifact.preview.generatedFromVersion !== artifact.version) { + refreshReasons.push("preview upload version is stale") + } + if (artifact.preview.schemaVersion !== artifact.schemaVersion) { + refreshReasons.push("preview schema version is stale") + } + if (artifact.preview.metadataDigest !== metadataDigest) { + refreshReasons.push("preview metadata snapshot is stale") + } + } + + const redactedFields = new Set(artifact.preview?.redactedFields || []) + const unredactedSensitiveFields = sensitiveFields.filter((field) => !redactedFields.has(field)) + if (unredactedSensitiveFields.length > 0) { + blockers.push(`${artifact.id} preview exposes sensitive fields: ${unredactedSensitiveFields.join(", ")}`) + } + + const previousColumns = artifact.previousVersion?.metadata?.columns || [] + const columnDiff = compareColumns(previousColumns, columns) + const changedHash = artifact.previousVersion?.hash && artifact.previousVersion.hash !== artifact.hash + const diffSummary = { + changedHash: Boolean(changedHash), + addedColumns: columnDiff.added, + removedColumns: columnDiff.removed, + } + + const action = + blockers.length > 0 ? "hold-preview" : refreshReasons.length > 0 ? "regenerate-preview" : "reuse-preview" + + return { + id: artifact.id, + path: artifact.path, + type, + expectedPreviewKind, + action, + blockers, + warnings, + refreshReasons, + diffSummary, + metadataDigest, + } +} + +function assessArtifactPreviews(input) { + const artifacts = (input.artifacts || []).map(evaluateArtifact) + const blockers = artifacts.flatMap((artifact) => artifact.blockers) + const refreshCount = artifacts.filter((artifact) => artifact.action === "regenerate-preview").length + const warningCount = artifacts.reduce((count, artifact) => count + artifact.warnings.length, 0) + const previewPlan = artifacts.map((artifact) => ({ + artifactId: artifact.id, + action: artifact.action, + reason: artifact.blockers[0] || artifact.refreshReasons[0] || artifact.warnings[0] || "preview is current", + })) + const fairWarnings = artifacts.flatMap((artifact) => + artifact.warnings.map((warning) => ({ artifactId: artifact.id, warning })), + ) + + const result = { + status: blockers.length > 0 ? "blocked" : refreshCount > 0 ? "needs-refresh" : warningCount > 0 ? "needs-review" : "ready", + artifactCount: artifacts.length, + refreshCount, + blockedCount: artifacts.filter((artifact) => artifact.action === "hold-preview").length, + artifacts, + previewPlan, + fairWarnings, + } + + return { + ...result, + auditDigest: digest(result), + } +} + +module.exports = { + assessArtifactPreviews, +} diff --git a/artifact-preview-cache-guard/requirements-map.md b/artifact-preview-cache-guard/requirements-map.md new file mode 100644 index 00000000..78acbea7 --- /dev/null +++ b/artifact-preview-cache-guard/requirements-map.md @@ -0,0 +1,13 @@ +# Requirements Map + +| Issue #14 requirement | Coverage in this module | +| --- | --- | +| Support datasets, code files, supplementary files, images, videos, and models | Classifies common artifact extensions into dataset, code, image, media, model, or supplement preview lanes. | +| Metadata-aware previews | Verifies preview kind, content hash, upload version, schema version, and metadata digest before reuse. | +| Upload versioning and diffing | Compares previous and current dataset columns and reports added or removed fields. | +| Structured metadata and FAIR compliance | Warns on missing title, license, creators, DOI, or persistent ID metadata. | +| Access control for reusable hosted assets | Holds previews that expose sensitive fields before they are displayed or exported. | + +## Non-Overlap Note + +This submission is distinct from broad data/code hosting ledgers, FAIR manifest validators, artifact access gates, package integrity gates, quarantine/rerun governance, provenance chains, storage quota/deduplication ledgers, and executable-environment drift modules. It focuses specifically on preview cache correctness, upload-version drift, metadata snapshots, and sensitive-field preview safety. diff --git a/artifact-preview-cache-guard/test.js b/artifact-preview-cache-guard/test.js new file mode 100644 index 00000000..9449a481 --- /dev/null +++ b/artifact-preview-cache-guard/test.js @@ -0,0 +1,148 @@ +"use strict" + +const assert = require("node:assert/strict") +const { assessArtifactPreviews } = require("./index") + +const currentMetadata = { + title: "Dose response measurements", + license: "CC-BY-4.0", + creators: ["North Lab"], + doi: "10.1234/scibase.demo", + columns: ["sample_id", "dose", "response"], + schemaVersion: "v2", +} + +{ + const metadataDigest = require("node:crypto") + .createHash("sha256") + .update( + '{"columns":["sample_id","dose","response"],"creators":["North Lab"],"doi":"10.1234/scibase.demo","license":"CC-BY-4.0","schemaVersion":"v2","title":"Dose response measurements"}', + ) + .digest("hex") + + const result = assessArtifactPreviews({ + artifacts: [ + { + id: "artifact-ready", + path: "data/dose-response.csv", + version: "v2", + hash: "sha256:222", + schemaVersion: "v2", + access: "public", + metadata: currentMetadata, + preview: { + kind: "table", + generatedFromHash: "sha256:222", + generatedFromVersion: "v2", + schemaVersion: "v2", + metadataDigest, + }, + }, + ], + }) + + assert.equal(result.status, "ready") + assert.equal(result.refreshCount, 0) + assert.equal(result.previewPlan[0].action, "reuse-preview") + assert.match(result.auditDigest, /^[0-9a-f]{64}$/) +} + +{ + const result = assessArtifactPreviews({ + artifacts: [ + { + id: "artifact-stale", + path: "data/results.csv", + version: "v3", + hash: "sha256:333", + schemaVersion: "v3", + access: "public", + metadata: { + title: "Updated measurements", + license: "CC-BY-4.0", + creators: ["North Lab"], + doi: "10.1234/scibase.updated", + columns: ["sample_id", "dose", "response", "batch"], + }, + previousVersion: { + hash: "sha256:222", + metadata: { columns: ["sample_id", "dose", "response"] }, + }, + preview: { + kind: "table", + generatedFromHash: "sha256:222", + generatedFromVersion: "v2", + schemaVersion: "v2", + metadataDigest: "old", + }, + }, + ], + }) + + assert.equal(result.status, "needs-refresh") + assert.equal(result.refreshCount, 1) + assert.ok(result.artifacts[0].refreshReasons.includes("preview content hash is stale")) + assert.deepEqual(result.artifacts[0].diffSummary.addedColumns, ["batch"]) +} + +{ + const result = assessArtifactPreviews({ + artifacts: [ + { + id: "artifact-sensitive", + path: "data/participants.csv", + version: "v1", + hash: "sha256:111", + schemaVersion: "v1", + access: "restricted", + metadata: { + title: "Participant metadata", + license: "DUA-required", + creators: ["Clinic Lab"], + columns: ["participant_id", "email", "cohort"], + }, + preview: { + kind: "table", + generatedFromHash: "sha256:111", + generatedFromVersion: "v1", + schemaVersion: "v1", + metadataDigest: "stale", + redactedFields: ["participant_id"], + }, + }, + ], + }) + + assert.equal(result.status, "blocked") + assert.equal(result.blockedCount, 1) + assert.ok(result.previewPlan[0].reason.includes("email")) +} + +{ + const result = assessArtifactPreviews({ + artifacts: [ + { + id: "artifact-metadata-warning", + path: "notebooks/analysis.ipynb", + version: "v1", + hash: "sha256:notebook", + schemaVersion: "v1", + access: "private", + metadata: { title: "Notebook preview", columns: [] }, + preview: { + kind: "notebook-or-code", + generatedFromHash: "sha256:notebook", + generatedFromVersion: "v1", + schemaVersion: "v1", + metadataDigest: "old", + }, + }, + ], + }) + + assert.equal(result.status, "needs-refresh") + assert.ok(result.fairWarnings.some((item) => item.warning.includes("license"))) + assert.ok(result.fairWarnings.some((item) => item.warning.includes("creators"))) +} + +console.log("artifact-preview-cache-guard tests passed")