From 6f1c19c1790ae4c8be9e85010d9c8dd980da172d Mon Sep 17 00:00:00 2001 From: Reece Browne <74901996+reecebrowne@users.noreply.github.com> Date: Thu, 11 Jun 2026 21:27:44 +0100 Subject: [PATCH] Policies: enforce input on uploads only; badge follows edited files (#6627) --- .../core/contexts/file/FileReducer.test.ts | 113 ++++++++++++++++ .../src/core/contexts/file/FileReducer.ts | 28 +++- .../services/fileStorage.migration.test.ts | 56 ++++++++ .../editor/src/core/services/fileStorage.ts | 30 +++++ frontend/editor/src/core/types/file.ts | 24 ++++ .../components/policies/usePolicyAutoRun.ts | 5 + .../hooks/usePolicyFileBadges.test.ts | 113 ++++++++++++++++ .../proprietary/hooks/usePolicyFileBadges.ts | 122 ++++++++++++++---- 8 files changed, 466 insertions(+), 25 deletions(-) create mode 100644 frontend/editor/src/core/contexts/file/FileReducer.test.ts create mode 100644 frontend/editor/src/core/services/fileStorage.migration.test.ts create mode 100644 frontend/editor/src/proprietary/hooks/usePolicyFileBadges.test.ts diff --git a/frontend/editor/src/core/contexts/file/FileReducer.test.ts b/frontend/editor/src/core/contexts/file/FileReducer.test.ts new file mode 100644 index 000000000..406345ead --- /dev/null +++ b/frontend/editor/src/core/contexts/file/FileReducer.test.ts @@ -0,0 +1,113 @@ +import { describe, it, expect } from "vitest"; +import { + fileContextReducer, + initialFileContextState, +} from "@app/contexts/file/FileReducer"; +import type { + FileContextState, + StirlingFileStub, +} from "@app/types/fileContext"; +import type { FileId } from "@app/types/file"; + +function stub( + id: string, + overrides: Partial = {}, +): StirlingFileStub { + return { + id: id as FileId, + name: `${id}.pdf`, + type: "application/pdf", + size: 1, + lastModified: 0, + isLeaf: true, + originalFileId: id, + versionNumber: 1, + ...overrides, + }; +} + +function stateWith(stubs: StirlingFileStub[]): FileContextState { + return { + ...initialFileContextState, + files: { + ids: stubs.map((s) => s.id), + byId: Object.fromEntries(stubs.map((s) => [s.id, s])), + }, + }; +} + +describe("fileContextReducer — derivedFromTool provenance", () => { + it("ADD_FILES leaves uploads unmarked (a genuine upload is not tool-derived)", () => { + const next = fileContextReducer(initialFileContextState, { + type: "ADD_FILES", + payload: { stirlingFileStubs: [stub("a")] }, + }); + expect(next.files.byId["a" as FileId].derivedFromTool).toBeUndefined(); + }); + + it("CONSUME_FILES marks every output as tool-derived", () => { + // An upload "a" is consumed by a tool producing an independent artifact "b" + // (version metadata identical to an upload — only the flag distinguishes it). + const next = fileContextReducer(stateWith([stub("a")]), { + type: "CONSUME_FILES", + payload: { + inputFileIds: ["a" as FileId], + outputStirlingFileStubs: [stub("b")], + }, + }); + expect(next.files.byId["b" as FileId].derivedFromTool).toBe(true); + // Provenance: "b" records the input it derived from. + expect(next.files.byId["b" as FileId].sourceFileIds).toEqual(["a"]); + expect(next.files.byId["a" as FileId]).toBeUndefined(); // input consumed + }); + + it("CONSUME_FILES accumulates sourceFileIds transitively", () => { + // "b" already derived from "a"; consuming "b" → "c" carries both, so the + // badge still resolves after the intermediate "b" is gone (e.g. split). + const start = stateWith([stub("b", { sourceFileIds: ["a" as FileId] })]); + const next = fileContextReducer(start, { + type: "CONSUME_FILES", + payload: { + inputFileIds: ["b" as FileId], + outputStirlingFileStubs: [stub("c")], + }, + }); + expect(next.files.byId["c" as FileId].sourceFileIds).toEqual(["b", "a"]); + }); + + it("CONSUME_FILES with multiple inputs (merge) records all sources", () => { + const start = stateWith([stub("a"), stub("b")]); + const next = fileContextReducer(start, { + type: "CONSUME_FILES", + payload: { + inputFileIds: ["a" as FileId, "b" as FileId], + outputStirlingFileStubs: [stub("merged")], + }, + }); + expect(next.files.byId["merged" as FileId].sourceFileIds).toEqual([ + "a", + "b", + ]); + }); + + it("UNDO_CONSUME_FILES restores the original upload without mislabelling it", () => { + // Reverses the swap above: the original upload "a" comes back through the + // same helper, but must NOT be flagged tool-derived. + const consumed = fileContextReducer(stateWith([stub("a")]), { + type: "CONSUME_FILES", + payload: { + inputFileIds: ["a" as FileId], + outputStirlingFileStubs: [stub("b")], + }, + }); + const undone = fileContextReducer(consumed, { + type: "UNDO_CONSUME_FILES", + payload: { + inputStirlingFileStubs: [stub("a")], + outputFileIds: ["b" as FileId], + }, + }); + expect(undone.files.byId["a" as FileId].derivedFromTool).toBeUndefined(); + expect(undone.files.byId["b" as FileId]).toBeUndefined(); // output removed + }); +}); diff --git a/frontend/editor/src/core/contexts/file/FileReducer.ts b/frontend/editor/src/core/contexts/file/FileReducer.ts index 0af9ce9e4..189a73e77 100644 --- a/frontend/editor/src/core/contexts/file/FileReducer.ts +++ b/frontend/editor/src/core/contexts/file/FileReducer.ts @@ -288,7 +288,33 @@ export function fileContextReducer( case "CONSUME_FILES": { const { inputFileIds, outputStirlingFileStubs } = action.payload; - return processFileSwap(state, inputFileIds, outputStirlingFileStubs); + // Transitive provenance: the outputs derive from these inputs AND from + // whatever those inputs themselves derived from. Accumulating the closure + // (rather than just the immediate inputs) means a policy badge still + // resolves after an intermediate edit has been consumed and removed — + // e.g. redact → edit → split still tags each split part. Captured before + // the inputs are swapped out below. + const sourceFileIds = Array.from( + new Set( + inputFileIds.flatMap((id) => [ + id, + ...(state.files.byId[id]?.sourceFileIds ?? []), + ]), + ), + ); + + // Mark every consume output as tool-produced (the single chokepoint for + // both versioned edits and independent artifacts like convert/split/merge) + // and stamp its provenance. Tag here, not in processFileSwap, so + // UNDO_CONSUME (which restores the original inputs through the same helper) + // doesn't mislabel real uploads. + const taggedOutputs = outputStirlingFileStubs.map((stub) => ({ + ...stub, + derivedFromTool: true, + sourceFileIds, + })); + + return processFileSwap(state, inputFileIds, taggedOutputs); } case "UNDO_CONSUME_FILES": { diff --git a/frontend/editor/src/core/services/fileStorage.migration.test.ts b/frontend/editor/src/core/services/fileStorage.migration.test.ts new file mode 100644 index 000000000..472d83b49 --- /dev/null +++ b/frontend/editor/src/core/services/fileStorage.migration.test.ts @@ -0,0 +1,56 @@ +import { describe, it, expect } from "vitest"; +import { + legacyDerivedFromTool, + type StoredStirlingFileRecord, +} from "@app/services/fileStorage"; +import type { FileId } from "@app/types/file"; + +/** + * Backfill of `derivedFromTool` for files persisted before the field existed + * (old users' IndexedDB). New records always carry an explicit flag, so this + * helper only governs pre-upgrade records. + */ +function record( + overrides: Partial, +): StoredStirlingFileRecord { + return { + id: "f" as FileId, + fileId: "f" as FileId, + quickKey: "k", + name: "f.pdf", + type: "application/pdf", + size: 1, + lastModified: 0, + isLeaf: true, + originalFileId: "f", + versionNumber: 1, + data: new ArrayBuffer(0), + ...overrides, + } as StoredStirlingFileRecord; +} + +describe("legacyDerivedFromTool — IndexedDB backfill for pre-upgrade files", () => { + it("flags a legacy versioned edit (has tool history)", () => { + expect( + legacyDerivedFromTool( + record({ toolHistory: [{ toolId: "compress" as any, timestamp: 0 }] }), + ), + ).toBe(true); + }); + + it("flags a legacy file past its first version", () => { + expect(legacyDerivedFromTool(record({ versionNumber: 2 }))).toBe(true); + }); + + it("flags a legacy file with a parent", () => { + expect(legacyDerivedFromTool(record({ parentFileId: "p" as FileId }))).toBe( + true, + ); + }); + + it("leaves a clean legacy root unflagged — treated as an upload (safe default for enforcement)", () => { + // A genuine upload AND a legacy independent artifact (convert/split/merge) + // both look like this; old data can't tell them apart, so we enforce. + expect(legacyDerivedFromTool(record({}))).toBeUndefined(); + }); +}); diff --git a/frontend/editor/src/core/services/fileStorage.ts b/frontend/editor/src/core/services/fileStorage.ts index eb0da6ac7..6706e7abe 100644 --- a/frontend/editor/src/core/services/fileStorage.ts +++ b/frontend/editor/src/core/services/fileStorage.ts @@ -38,6 +38,25 @@ export interface StorageStats { quota?: number; } +/** + * Best-effort provenance for records persisted before `derivedFromTool` + * existed. A version chain (a tool history, a version past the first, or a + * parent) is unambiguously a tool output, so flag it. Legacy independent + * artifacts (convert/split/merge) recorded none of that and are + * indistinguishable from uploads in old data — they stay unflagged, which for + * an enforcement feature is the safe default (enforce rather than silently + * skip). New records always carry an explicit flag, so this only fires for + * pre-existing files on first read after upgrade. + */ +export function legacyDerivedFromTool( + record: StoredStirlingFileRecord, +): boolean | undefined { + if ((record.toolHistory?.length ?? 0) > 0) return true; + if ((record.versionNumber ?? 1) > 1) return true; + if (record.parentFileId != null) return true; + return undefined; +} + class FileStorageService { private readonly dbConfig = DATABASE_CONFIGS.FILES; private readonly storeName = "files"; @@ -124,6 +143,8 @@ class FileStorageService { originalFileId: stub.originalFileId ?? stirlingFile.fileId, parentFileId: stub.parentFileId ?? undefined, toolHistory: stub.toolHistory ?? [], + derivedFromTool: stub.derivedFromTool ?? false, + sourceFileIds: stub.sourceFileIds, // Folder organisation (root when null) folderId: stub.folderId ?? null, @@ -247,6 +268,9 @@ class FileStorageService { originalFileId: record.originalFileId, parentFileId: record.parentFileId, toolHistory: record.toolHistory, + derivedFromTool: + record.derivedFromTool ?? legacyDerivedFromTool(record), + sourceFileIds: record.sourceFileIds, folderId: record.folderId ?? null, createdAt: record.createdAt || Date.now(), }; @@ -303,6 +327,9 @@ class FileStorageService { originalFileId: record.originalFileId || record.id, parentFileId: record.parentFileId, toolHistory: record.toolHistory || [], + derivedFromTool: + record.derivedFromTool ?? legacyDerivedFromTool(record), + sourceFileIds: record.sourceFileIds, folderId: record.folderId ?? null, createdAt: record.createdAt || Date.now(), }); @@ -391,6 +418,9 @@ class FileStorageService { originalFileId: record.originalFileId || record.id, parentFileId: record.parentFileId, toolHistory: record.toolHistory || [], + derivedFromTool: + record.derivedFromTool ?? legacyDerivedFromTool(record), + sourceFileIds: record.sourceFileIds, folderId: record.folderId ?? null, createdAt: record.createdAt || Date.now(), }); diff --git a/frontend/editor/src/core/types/file.ts b/frontend/editor/src/core/types/file.ts index 66902f712..98a0094f4 100644 --- a/frontend/editor/src/core/types/file.ts +++ b/frontend/editor/src/core/types/file.ts @@ -37,6 +37,30 @@ export interface BaseFileMetadata { parentFileId?: FileId; // Immediate parent file ID toolHistory?: ToolOperation[]; // Tool chain for history tracking + /** + * True if this file was produced by a tool/automation in-app (any + * `consumeFiles` output — a versioned edit OR an independent artifact like a + * convert/split/merge result) rather than entering the system as a genuine + * upload. Set at the consume chokepoint so it covers both kinds, including + * independent artifacts whose version metadata is otherwise indistinguishable + * from a fresh upload. Persisted so the distinction survives a reload. + * Used by input-mode (upload) policy auto-run to enforce only on real uploads. + */ + derivedFromTool?: boolean; + + /** + * Transitive set of fileIds this file was derived from — the inputs of the + * tool operation that produced it, plus those inputs' own `sourceFileIds`. + * Recorded at the `consumeFiles` boundary, the only place that knows the + * input→output mapping. Unlike `parentFileId` (the version chain) this is a + * pure provenance link, so it covers independent artifacts — split (1→N), + * merge (N→1), convert — that intentionally have no parent. Being transitive, + * it survives an intermediate edit being consumed/removed. Persisted; used so + * a policy badge follows a document onto everything derived from it. Legacy + * files predate it (the link was never recorded) and stay empty. + */ + sourceFileIds?: FileId[]; + /** * The cloud folder this file lives in. Semantics: * - `remoteStorageId == null` → file is local-only; folderId MUST be null. diff --git a/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.ts b/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.ts index 5dd143e3f..20d9fc4a2 100644 --- a/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.ts +++ b/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.ts @@ -83,6 +83,11 @@ export function usePolicyAutoRun(): void { ); for (const [categoryId, s] of active) { for (const stub of fileStubs) { + // Input-mode policies enforce only on files that actually entered the + // system as an upload — not on files a tool/automation produced in-app + // (versioned edits or independent artifacts like convert/split/merge). + // Those are enforced only by export-mode policies, at export time. + if (stub.derivedFromTool) continue; const key = dispatchKey(categoryId, stub.id); // Skip if already run (persisted) or a dispatch is in flight — the // in-memory guard prevents double-firing during the async wait. diff --git a/frontend/editor/src/proprietary/hooks/usePolicyFileBadges.test.ts b/frontend/editor/src/proprietary/hooks/usePolicyFileBadges.test.ts new file mode 100644 index 000000000..30c5a8395 --- /dev/null +++ b/frontend/editor/src/proprietary/hooks/usePolicyFileBadges.test.ts @@ -0,0 +1,113 @@ +import { describe, it, expect } from "vitest"; +import { buildPolicyBadgeMap } from "@app/hooks/usePolicyFileBadges"; +import type { PolicyRunRecord } from "@app/components/policies/policyRunStore"; + +const NOW = 1_000_000; +const labels = new Map([ + ["security", "Security"], + ["watermark", "Watermark"], +]); + +function run(overrides: Partial): PolicyRunRecord { + return { + runId: "r", + categoryId: "security", + fileId: "in", + fileName: "in.pdf", + fileSize: 1, + status: "COMPLETED", + outputs: [], + outputFileIds: ["out"], + error: null, + startedAt: NOW - 1_000, // recent by default + ...overrides, + }; +} + +describe("buildPolicyBadgeMap — badge follows the document onto derived files", () => { + it("badges a policy's direct output, and marks it recent within the window", () => { + const map = buildPolicyBadgeMap([run({})], [{ id: "out" }], labels, NOW); + const badges = map.get("out") ?? []; + expect(badges.map((b) => b.id)).toEqual(["security"]); + expect(badges[0].recent).toBe(true); + }); + + it("a versioned edit inherits the badge via parentFileId (never glows)", () => { + const map = buildPolicyBadgeMap( + [run({})], + [{ id: "out" }, { id: "edit", parentFileId: "out" }], + labels, + NOW, + ); + const edit = map.get("edit") ?? []; + expect(edit.map((b) => b.id)).toEqual(["security"]); + expect(edit[0].recent).toBe(false); + }); + + it("SPLIT parts inherit the badge via sourceFileIds, though they have no parent", () => { + // Split consumes the policy output "out" → two fresh roots, no parentFileId, + // each recording sourceFileIds=["out"]. "out" itself is gone from the + // workbench (consumed) but still lives in the run store. + const map = buildPolicyBadgeMap( + [run({})], + [ + { id: "part1", sourceFileIds: ["out"] }, + { id: "part2", sourceFileIds: ["out"] }, + ], + labels, + NOW, + ); + expect((map.get("part1") ?? []).map((b) => b.id)).toEqual(["security"]); + expect((map.get("part2") ?? []).map((b) => b.id)).toEqual(["security"]); + expect((map.get("part1") ?? [])[0].recent).toBe(false); + }); + + it("resolves transitively when an intermediate edit was consumed/removed", () => { + // redact → edit (consumed) → split. The split part's sourceFileIds carries + // the original output id directly, so the badge still resolves. + const map = buildPolicyBadgeMap( + [run({})], + [{ id: "part", sourceFileIds: ["editGone", "out"] }], + labels, + NOW, + ); + expect((map.get("part") ?? []).map((b) => b.id)).toEqual(["security"]); + }); + + it("MERGE output inherits every input's badge", () => { + const map = buildPolicyBadgeMap( + [ + run({ runId: "r1", categoryId: "security", outputFileIds: ["a"] }), + run({ runId: "r2", categoryId: "watermark", outputFileIds: ["b"] }), + ], + [{ id: "merged", sourceFileIds: ["a", "b"] }], + labels, + NOW, + ); + expect((map.get("merged") ?? []).map((b) => b.id).sort()).toEqual([ + "security", + "watermark", + ]); + }); + + it("a file with no policy provenance gets no badge", () => { + const map = buildPolicyBadgeMap( + [run({})], + [{ id: "out" }, { id: "unrelated", sourceFileIds: ["someUpload"] }], + labels, + NOW, + ); + expect(map.has("unrelated")).toBe(false); + }); + + it("inherited badges never glow even when the source run is recent", () => { + const map = buildPolicyBadgeMap( + [run({ startedAt: NOW })], // maximally recent + [{ id: "out" }, { id: "part", sourceFileIds: ["out"] }], + labels, + NOW, + ); + expect((map.get("out") ?? [])[0].recent).toBe(true); + expect((map.get("part") ?? [])[0].recent).toBe(false); + }); +}); diff --git a/frontend/editor/src/proprietary/hooks/usePolicyFileBadges.ts b/frontend/editor/src/proprietary/hooks/usePolicyFileBadges.ts index 563f49161..ad4c01689 100644 --- a/frontend/editor/src/proprietary/hooks/usePolicyFileBadges.ts +++ b/frontend/editor/src/proprietary/hooks/usePolicyFileBadges.ts @@ -1,5 +1,7 @@ import { useMemo } from "react"; import { usePolicyRuns } from "@app/components/policies/policyRunStore"; +import type { PolicyRunRecord } from "@app/components/policies/policyRunStore"; +import { useAllFiles } from "@app/contexts/FileContext"; import { loadPolicyCatalog } from "@app/services/policyCatalog"; import { ROW_ACCENT } from "@app/components/policies/policyStatus"; import type { FileItemPolicyRef } from "@app/components/shared/FileSidebarFileItem"; @@ -18,38 +20,110 @@ const ACCENT_VAR: Record = { red: "var(--color-red)", }; +/** Minimal provenance shape needed to resolve a file's inherited badges. */ +type LineageStub = { + id: string; + parentFileId?: string; + sourceFileIds?: string[]; +}; + +/** Merge a ref into a list, deduping by policy id. A direct (recent) hit wins + * the glow over an inherited one for the same policy. */ +function mergeRef(list: FileItemPolicyRef[], ref: FileItemPolicyRef): void { + const existing = list.find((p) => p.id === ref.id); + if (!existing) { + list.push(ref); + } else if (ref.recent) { + existing.recent = true; + } +} + +/** + * Pure core of {@link usePolicyFileBadges} (no React/storage deps, so it's + * unit-testable). Returns the policies that have run on each file, keyed by + * fileId. + * + * A policy run is pinned to a specific output fileId, so a later tool edit + * produces a NEW file that wouldn't carry the badge — "been through a policy" + * would vanish the moment you edit the file. To keep the badge with the + * document, every file also INHERITS the badges of the files it was derived + * from: its transitive `sourceFileIds` (recorded at the consume boundary, so it + * covers split/merge/convert too) plus, defensively, its `parentFileId`. + * Because `sourceFileIds` is transitive, a flat lookup suffices — no chain walk, + * and it survives a consumed intermediate. Inherited badges never glow + * (recent=false): only the original application does. + */ +export function buildPolicyBadgeMap( + runs: ReadonlyArray, + stubs: ReadonlyArray, + labelById: ReadonlyMap, + now: number, +): Map { + // Direct badges: a file that IS a policy run's output. + const directByFile = new Map(); + for (const run of runs) { + const name = labelById.get(run.categoryId); + if (!name) continue; + const recent = now - run.startedAt < RECENT_MS; + for (const fileId of run.outputFileIds ?? []) { + const list = directByFile.get(fileId) ?? []; + if (!list.some((p) => p.id === run.categoryId)) { + list.push({ + id: run.categoryId, + name, + accentColor: ACCENT_VAR[ROW_ACCENT[run.categoryId] ?? "blue"], + recent, + }); + directByFile.set(fileId, list); + } + } + } + + // Seed the result with the direct badges (cloning the arrays so the lineage + // pass can mutate freely without touching the direct map). + const result = new Map(); + for (const [id, refs] of directByFile) { + if (refs.length) + result.set( + id, + refs.map((r) => ({ ...r })), + ); + } + + // Inheritance pass: a derived file carries the badges of every file it came + // from. `sourceFileIds` is the transitive provenance set (so a flat lookup + // catches even ancestors whose intermediate edits were consumed), and + // `parentFileId` is included defensively for any child not created via a + // consume. Inherited badges are marked recent=false (carried, not applied). + for (const stub of stubs) { + const sources = new Set(stub.sourceFileIds ?? []); + if (stub.parentFileId) sources.add(stub.parentFileId); + for (const src of sources) { + const srcBadges = directByFile.get(src); + if (!srcBadges?.length) continue; + const list = result.get(stub.id) ?? []; + for (const ref of srcBadges) mergeRef(list, { ...ref, recent: false }); + result.set(stub.id, list); + } + } + + return result; +} + /** * Distinct policies that have produced each file, keyed by fileId, derived from * the reactive policy run store. Drives the file sidebar's shield badges. The - * badge marks a policy's OUTPUT (the versioned/added result), not the input it - * ran on — so it keys off each run's imported output fileIds. Shadows the core - * stub via the {@code @app/*} alias cascade. + * badge follows a document down its tool-edit chain — see + * {@link buildPolicyBadgeMap}. Shadows the core stub via the {@code @app/*} + * alias cascade. */ export function usePolicyFileBadges(): Map { const runs = usePolicyRuns(); + const { fileStubs } = useAllFiles(); return useMemo(() => { const labelById = new Map( loadPolicyCatalog().categories.map((c) => [c.id, c.label]), ); - const now = Date.now(); - const byFile = new Map(); - for (const run of runs) { - const name = labelById.get(run.categoryId); - if (!name) continue; - const recent = now - run.startedAt < RECENT_MS; - for (const fileId of run.outputFileIds ?? []) { - const list = byFile.get(fileId) ?? []; - if (!list.some((p) => p.id === run.categoryId)) { - list.push({ - id: run.categoryId, - name, - accentColor: ACCENT_VAR[ROW_ACCENT[run.categoryId] ?? "blue"], - recent, - }); - byFile.set(fileId, list); - } - } - } - return byFile; - }, [runs]); + return buildPolicyBadgeMap(runs, fileStubs, labelById, Date.now()); + }, [runs, fileStubs]); }