/* RFC-1120: evidence bundling for notausgang.export — evaluates per-source eligibility (published claim ref + public visibility + consent scopes via evaluateGateV2), downloads R2 artifacts, recomputes SHA-256 fail-closed, and emits evidence// with integrity.txt. Do not write to the cache clone or bordbuch — the export package is the only output. Do not fetch external URL-only items — they are recorded as external-reference manifest entries. Do not reimplement R2 path derivation — reuse the nachweis-io resolvers; explicit item r2Path wins. RFC-1120: initial evidence bundling — eligibility evaluation, R2 download with injected fetcher, integrity.txt emission. */ import path from "node:path"; import { byteHash } from "@warpgogol/werkstatt-engine/fingerprint"; import { parseMarkdownFrontmatter } from "@warpgogol/werkstatt-shared/content"; import type { NotausgangEvidenceVerdict } from "@warpgogol/werkstatt-engine/schemas"; import { evaluateGateV2, resolvePbpEntityDir, resolveDefaultLang, resolveNachweisR2Path, resolveNachweisPublicR2Path, resolveNachweisScreenshotDisplayR2Path, resolveNachweisRawScreenshotR2Path, UnsupportedNachweisKindError, } from "../nachweis/nachweis-io.ts"; import { createDefaultIO, type WorkspaceIO } from "@warpgogol/werkstatt-shared/kernel/workspace-io"; export type EvidenceDownloadFn = (r2Path: string) => Promise; export interface EvidenceBundleResult { verdicts: Record; bundledSources: number; bundledArtifacts: number; } interface LoadedEntity { slug: string; lang: string; data: Record; } /** List language directories under src/content/business-profile, default lang first. */ async function listProfileLangs( siteDir: string, io: WorkspaceIO = createDefaultIO().io, ): Promise { const profileRoot = path.join(siteDir, "src", "content", "business-profile"); if (!(await io.exists(profileRoot))) return []; const entries = await io.readdir(profileRoot); const langs = entries.filter((e) => e.isDirectory).map((e) => e.name); let defaultLang: string | null = null; try { defaultLang = await resolveDefaultLang(siteDir); } catch { defaultLang = null; } return langs.sort((a, b) => (a === defaultLang ? -1 : b === defaultLang ? 1 : 0)); } async function loadEntities( siteDir: string, langs: string[], entityType: string, io: WorkspaceIO = createDefaultIO().io, ): Promise { const out: LoadedEntity[] = []; const seen = new Set(); for (const lang of langs) { const dir = resolvePbpEntityDir(siteDir, lang, entityType); if (!(await io.exists(dir))) continue; for (const file of await io.readdir(dir)) { if (!file.name.endsWith(".md")) continue; const slug = file.name.slice(0, -3); if (seen.has(slug)) continue; // translations share identity — first (default lang) wins seen.add(slug); const raw = await io.readFile(path.join(dir, file.name)); out.push({ slug, lang, data: parseMarkdownFrontmatter(raw).data }); } } return out; } function claimReferencesSource(claimData: Record, source: LoadedEntity): boolean { if (claimData.status !== "published") return false; const refs = claimData.evidenceRefs as Record | undefined; if (!refs) return false; const sourceId = typeof source.data.id === "string" ? source.data.id : null; return Object.values(refs).some((r) => { if (typeof r?.ref !== "string") return false; if (r.expectedType != null && r.expectedType !== "evidence-source") return false; if (sourceId != null && r.ref === sourceId) return true; return r.ref.endsWith(`/evidence/${source.slug}`) || r.ref.endsWith(`/${source.slug}`); }); } /** Consent scopes as evaluateGateV2 evaluates them: every visible display aspect must be granted. */ function consentScopesGranted( source: LoadedEntity, consentData: Record | undefined, ): boolean { const kind = typeof source.data.kind === "string" ? source.data.kind : "external-web-sources"; let gate: ReturnType; try { gate = evaluateGateV2(source.slug, kind, { evidenceData: source.data, consentData, bordbuchEntries: [], }); } catch (err) { // Non-Nachweis kinds have no publication policy and no consent aspects — granted trivially. if (err instanceof UnsupportedNachweisKindError) return true; throw err; } return gate.conditions .filter((c) => c.id === "consent-granted" || c.id === "display-consent-consistent") .every((c) => !c.required || c.status === "pass"); } interface ArtifactSpec { itemKey: string; r2Path: string; recordedSha256: string; } /** Collect downloadable artifact specs for an eligible source. */ function collectArtifactSpecs(systemId: string, source: LoadedEntity): ArtifactSpec[] { const specs: ArtifactSpec[] = []; const recordId = typeof source.data.recordId === "string" ? source.data.recordId : null; const version = typeof source.data.version === "number" ? source.data.version : null; const items = source.data.items as | Record< string, { sha256?: string; storage?: string; r2Path?: string; url?: string; qualityStatus?: string; } > | undefined; for (const [itemKey, item] of Object.entries(items ?? {})) { if (item.qualityStatus === "rejected") continue; // rejected artifacts never ship if (typeof item.sha256 !== "string") continue; // URL-only items carry no integrity anchor const r2Path = typeof item.r2Path === "string" && item.r2Path.length > 0 ? item.r2Path : recordId != null && version != null ? item.storage === "public" ? resolveNachweisPublicR2Path(systemId, recordId, version) : resolveNachweisR2Path(systemId, recordId, version) : null; if (r2Path == null) continue; specs.push({ itemKey, r2Path, recordedSha256: item.sha256 }); } // RFC-0890/0891: website screenshot artifacts (raw + processed display variant) const shot = source.data.websiteScreenshot as | { sha256?: string; storage?: string; rawArtifact?: { sha256?: string; r2Key?: string; originalFilename?: string }; } | undefined; const rawArtifact = shot?.rawArtifact; if (rawArtifact != null && typeof rawArtifact.sha256 === "string") { const recordedSha256 = rawArtifact.sha256; const r2Path = typeof rawArtifact.r2Key === "string" && rawArtifact.r2Key.length > 0 ? rawArtifact.r2Key : typeof rawArtifact.originalFilename === "string" ? resolveNachweisRawScreenshotR2Path(systemId, source.slug, rawArtifact.originalFilename) : null; if (r2Path != null) { specs.push({ itemKey: "website-screenshot-raw", r2Path, recordedSha256 }); } } if (typeof shot?.sha256 === "string" && shot.storage != null) { specs.push({ itemKey: "website-screenshot-display", r2Path: resolveNachweisScreenshotDisplayR2Path(systemId, source.slug), recordedSha256: shot.sha256, }); } return specs; } function hasExternalOnlyItems(source: LoadedEntity): boolean { const items = source.data.items as Record | undefined; const values = Object.values(items ?? {}); return values.length > 0 && values.every((i) => typeof i.url === "string" && i.sha256 == null); } /** * Bundle eligible evidence artifacts into `/evidence//`. * Fail-closed: a hash mismatch or an unreachable R2 artifact aborts the export. */ export async function bundleEvidenceArtifacts( params: { siteDir: string; stagingDir: string; systemId: string; download: EvidenceDownloadFn; logger?: { info(msg: string): void; warn(msg: string): void }; }, io: WorkspaceIO = createDefaultIO().io, ): Promise { const { siteDir, stagingDir, systemId, download, logger } = params; const evidenceRoot = path.join(stagingDir, "evidence"); await io.mkdir(evidenceRoot); // AC-4: emitted even when empty const langs = await listProfileLangs(siteDir); const sources = await loadEntities(siteDir, langs, "evidence-source"); const claims = await loadEntities(siteDir, langs, "claim"); const consents = await loadEntities(siteDir, langs, "consent"); const consentBySlug = new Map(consents.map((c) => [c.slug, c.data])); const verdicts: Record = {}; let bundledSources = 0; let bundledArtifacts = 0; for (const source of sources) { const publication = source.data.publication as { visibility?: string } | undefined; const referenced = claims.some((c) => claimReferencesSource(c.data, source)); const consentData = consentBySlug.get(source.slug); let excludeReason: string | null = null; if (!referenced) excludeReason = "no-published-claims"; else if (publication?.visibility !== "public") excludeReason = "not-public"; else if (!consentScopesGranted(source, consentData)) excludeReason = "consent-not-granted"; if (excludeReason != null) { verdicts[source.slug] = { verdict: `excluded:${excludeReason}` }; continue; } const specs = collectArtifactSpecs(systemId, source); if (specs.length === 0) { verdicts[source.slug] = { verdict: hasExternalOnlyItems(source) ? "external-reference" : "excluded:no-artifacts", }; continue; } const sourceDir = path.join(evidenceRoot, source.slug); await io.mkdir(sourceDir); const artifacts: NonNullable = []; const integrityLines: string[] = []; for (const spec of specs) { let bytes: Uint8Array; try { bytes = await download(spec.r2Path); } catch (err) { throw new Error( `[notausgang.export] evidence artifact unreachable: source '${source.slug}' item '${spec.itemKey}' r2Path '${spec.r2Path}' — ${(err as Error).message}`, ); } const recomputed = byteHash(bytes).replace(/^sha256:/, ""); if (recomputed !== spec.recordedSha256) { throw new Error( `[notausgang.export] evidence integrity mismatch: source '${source.slug}' item '${spec.itemKey}' — recorded ${spec.recordedSha256}, recomputed ${recomputed}`, ); } const filename = path.basename(spec.r2Path); const relPath = `${spec.itemKey}/${filename}`; const dest = path.join(sourceDir, relPath); await io.mkdir(path.dirname(dest)); await io.writeFile(dest, bytes); integrityLines.push(`${recomputed} ${relPath}`); artifacts.push({ itemKey: spec.itemKey, filename, sha256: recomputed, recordedSha256: spec.recordedSha256, r2Path: spec.r2Path, }); bundledArtifacts++; } await io.writeFile(path.join(sourceDir, "integrity.txt"), integrityLines.join("\n") + "\n"); verdicts[source.slug] = { verdict: "bundled", artifacts }; bundledSources++; logger?.info(` evidence: bundled ${artifacts.length} artifact(s) for '${source.slug}'`); } return { verdicts, bundledSources, bundledArtifacts }; }