From 9ee0bc4b32b1febb688f8a98a06e3dded37b48a3 Mon Sep 17 00:00:00 2001 From: Reece Browne <74901996+reecebrowne@users.noreply.github.com> Date: Thu, 11 Jun 2026 18:12:01 +0100 Subject: [PATCH] Policies: enforce on upload or export (#6614) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to #6604 (merged). Builds the Security policy out so it actually enforces, driven from the editor. ## What it does - **Run on upload or export** — a single choice in the wizard: enforce when a file is uploaded, or just before it's exported. - **Output** — enforced result is a **new version** of the file (default) or a **new file**, with optional filename prefix/suffix/auto-number ("Output filename" subsection; auto-number only for new files). - **Export enforcement** — exporting an export-mode file runs the policy first and downloads the enforced result; never hard-blocks (on failure the original downloads). For new-version policies the in-editor file is versioned too. Covers every export path incl. multi-file ZIP. A toast (glowing in the policy's accent while it runs) reports progress and fades after ~10s. - **Affordances** — a freshly enforced file briefly glows its policy accent and carries a shield badge. - **Config tidy-up** — removed the unwired Security setting fields + the wizard's review step; "Upgrade to enterprise" on locked categories; category accent in the detail/wizard headers. ## Notes - Builds on the manual-only (client-driven) policy model from #6587 (`trigger: null`, metadata in `output.options`), adding the `runOn` field + export-time enforcement. - The page-editor merge-export (no single source file) enforces + downloads but doesn't version in place. ## Verification typecheck (core + proprietary), eslint, prettier; proprietary suite (105) green; flows checked in-app. --- .../core/components/fileEditor/FileEditor.tsx | 3 +- .../fileEditor/FileEditorThumbnail.tsx | 3 +- .../components/pageEditor/FileThumbnail.tsx | 3 +- .../components/shared/FileSidebarFileItem.css | 26 +++ .../components/shared/FileSidebarFileItem.tsx | 15 +- .../core/components/shared/WorkbenchBar.tsx | 4 +- .../core/components/toast/ToastRenderer.css | 21 ++ .../core/components/toast/ToastRenderer.tsx | 12 +- .../editor/src/core/components/toast/types.ts | 4 + .../src/core/services/downloadService.ts | 3 + .../src/core/services/exportWithPolicy.ts | 29 +++ .../src/core/services/pdfExportService.ts | 6 +- .../editor/src/core/services/policyExport.ts | 12 ++ .../editor/src/core/utils/downloadUtils.ts | 22 +- .../components/policies/Policies.css | 28 +++ .../policies/PoliciesSidebar.test.tsx | 6 +- .../components/policies/PoliciesSidebar.tsx | 4 +- .../components/policies/PolicySetupWizard.tsx | 144 ++++++++----- .../components/policies/PolicyToolConfig.tsx | 5 +- .../components/policies/usePolicyAutoRun.ts | 13 +- .../data/policyDefinitions.test.ts | 4 +- .../proprietary/data/policyDefinitions.tsx | 26 +-- .../src/proprietary/hooks/usePolicies.test.ts | 1 + .../src/proprietary/hooks/usePolicies.ts | 3 + .../proprietary/hooks/usePolicyFileBadges.ts | 8 + .../src/proprietary/services/policyBackend.ts | 1 + .../src/proprietary/services/policyExport.ts | 192 ++++++++++++++++++ .../services/policyPipeline.test.ts | 3 +- .../proprietary/services/policyPipeline.ts | 45 ++-- .../src/proprietary/services/policyStorage.ts | 2 + .../editor/src/proprietary/types/policies.ts | 4 + 31 files changed, 529 insertions(+), 123 deletions(-) create mode 100644 frontend/editor/src/core/services/exportWithPolicy.ts create mode 100644 frontend/editor/src/core/services/policyExport.ts create mode 100644 frontend/editor/src/proprietary/services/policyExport.ts diff --git a/frontend/editor/src/core/components/fileEditor/FileEditor.tsx b/frontend/editor/src/core/components/fileEditor/FileEditor.tsx index 3ddd5ca40..ed8944fb0 100644 --- a/frontend/editor/src/core/components/fileEditor/FileEditor.tsx +++ b/frontend/editor/src/core/components/fileEditor/FileEditor.tsx @@ -17,7 +17,7 @@ import AddFileCard from "@app/components/fileEditor/AddFileCard"; import FilePickerModal from "@app/components/shared/FilePickerModal"; import { FileId, StirlingFile } from "@app/types/fileContext"; import { alert } from "@app/components/toast"; -import { downloadFile } from "@app/services/downloadService"; +import { downloadFileWithPolicy as downloadFile } from "@app/services/exportWithPolicy"; import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext"; interface FileEditorProps { @@ -256,6 +256,7 @@ const FileEditor = ({ data: file, filename: file.name, localPath: record.localFilePath, + fileId, }); console.log("[FileEditor] Download complete, checking dirty state:", { localFilePath: record.localFilePath, diff --git a/frontend/editor/src/core/components/fileEditor/FileEditorThumbnail.tsx b/frontend/editor/src/core/components/fileEditor/FileEditorThumbnail.tsx index 0b78d4e26..632d9ea00 100644 --- a/frontend/editor/src/core/components/fileEditor/FileEditorThumbnail.tsx +++ b/frontend/editor/src/core/components/fileEditor/FileEditorThumbnail.tsx @@ -36,7 +36,7 @@ import ToolChain from "@app/components/shared/ToolChain"; import HoverActionMenu, { HoverAction, } from "@app/components/shared/HoverActionMenu"; -import { downloadFile } from "@app/services/downloadService"; +import { downloadFileWithPolicy as downloadFile } from "@app/services/exportWithPolicy"; import { PrivateContent } from "@app/components/shared/PrivateContent"; import UploadToServerModal from "@app/components/shared/UploadToServerModal"; import ShareFileModal from "@app/components/shared/ShareFileModal"; @@ -248,6 +248,7 @@ const FileEditorThumbnail = ({ data: fileToSave, filename: file.name, localPath: file.localFilePath, + fileId: file.id, }); if (!result.cancelled && result.savedPath) { fileActions.updateStirlingFileStub(file.id, { diff --git a/frontend/editor/src/core/components/pageEditor/FileThumbnail.tsx b/frontend/editor/src/core/components/pageEditor/FileThumbnail.tsx index 0356aa6c6..93ff3c2ab 100644 --- a/frontend/editor/src/core/components/pageEditor/FileThumbnail.tsx +++ b/frontend/editor/src/core/components/pageEditor/FileThumbnail.tsx @@ -23,7 +23,7 @@ import { FileId } from "@app/types/file"; import { PrivateContent } from "@app/components/shared/PrivateContent"; import { useFileActionTerminology } from "@app/hooks/useFileActionTerminology"; import { useFileActionIcons } from "@app/hooks/useFileActionIcons"; -import { downloadFile } from "@app/services/downloadService"; +import { downloadFileWithPolicy as downloadFile } from "@app/services/exportWithPolicy"; interface FileItem { id: FileId; @@ -98,6 +98,7 @@ const FileThumbnail = ({ void downloadFile({ data: maybeFile, filename: maybeFile.name || file.name || "download", + fileId: file.id, }); return; } diff --git a/frontend/editor/src/core/components/shared/FileSidebarFileItem.css b/frontend/editor/src/core/components/shared/FileSidebarFileItem.css index 81ca24ae0..02e6de971 100644 --- a/frontend/editor/src/core/components/shared/FileSidebarFileItem.css +++ b/frontend/editor/src/core/components/shared/FileSidebarFileItem.css @@ -36,6 +36,32 @@ background-color: rgba(59, 130, 246, 0.06); } +/* A policy-enforced file glows in that policy's accent colour so it's obvious + the file has had a policy applied: it pulses a few times to catch the eye and + then fades out (no lingering glow). */ +.file-sidebar-file-item.policy-enforced { + animation: policy-glow-fade 4.5s ease-in-out forwards; +} +@keyframes policy-glow-fade { + 0%, + 24%, + 48% { + box-shadow: + inset 0 0 0 1px color-mix(in srgb, var(--policy-glow) 30%, transparent), + 0 0 6px -2px var(--policy-glow); + } + 12%, + 36%, + 60% { + box-shadow: + inset 0 0 0 1px color-mix(in srgb, var(--policy-glow) 75%, transparent), + 0 0 16px 0 var(--policy-glow); + } + 100% { + box-shadow: 0 0 0 0 transparent; + } +} + .file-sidebar-file-item.selected { background-color: rgba(59, 130, 246, 0.12); } diff --git a/frontend/editor/src/core/components/shared/FileSidebarFileItem.tsx b/frontend/editor/src/core/components/shared/FileSidebarFileItem.tsx index 1b2f566f1..6c5ecaba3 100644 --- a/frontend/editor/src/core/components/shared/FileSidebarFileItem.tsx +++ b/frontend/editor/src/core/components/shared/FileSidebarFileItem.tsx @@ -132,6 +132,9 @@ export interface FileItemPolicyRef { name: string; /** CSS colour for the badge (matches the policy's accent). */ accentColor: string; + /** True only just after the policy was applied — drives the one-off glow, so + * it doesn't replay on every reload of an already-enforced file. */ + recent: boolean; } export interface FileItemProps { @@ -201,6 +204,9 @@ export function FileItem({ const handleMouseLeave = useCallback(() => setHoverRect(null), []); + // A just-applied policy (recent run) drives the one-off row glow. + const recentPolicy = policies.find((p) => p.recent); + // Reactive: tooltip appears as soon as both hover rect and thumbnail are ready const thumbPos = hoverRect && resolvedThumbnail @@ -214,7 +220,14 @@ export function FileItem({ <>
onClick(fileId)} draggable={draggable} onDragStart={ diff --git a/frontend/editor/src/core/components/shared/WorkbenchBar.tsx b/frontend/editor/src/core/components/shared/WorkbenchBar.tsx index 6b35a7fd3..2f4cb7299 100644 --- a/frontend/editor/src/core/components/shared/WorkbenchBar.tsx +++ b/frontend/editor/src/core/components/shared/WorkbenchBar.tsx @@ -29,7 +29,7 @@ import { ViewerContext, useViewer } from "@app/contexts/ViewerContext"; import { WorkbenchType, isBaseWorkbench } from "@app/types/workbench"; import { Tooltip } from "@app/components/shared/Tooltip"; import LocalIcon from "@app/components/shared/LocalIcon"; -import { downloadFile } from "@app/services/downloadService"; +import { downloadFileWithPolicy as downloadFile } from "@app/services/exportWithPolicy"; import { WorkbenchBarButtonConfig, WorkbenchBarRenderContext, @@ -150,6 +150,7 @@ export default function WorkbenchBar({ data: new Blob([buffer], { type: "application/pdf" }), filename: fileToExport.name, localPath: forceNewFile ? undefined : stub?.localFilePath, + fileId: stub?.id, }); if (!forceNewFile && !result.cancelled && stub && result.savedPath) { fileActions.updateStirlingFileStub(stub.id, { @@ -179,6 +180,7 @@ export default function WorkbenchBar({ data: file, filename: file.name, localPath: forceNewFile ? undefined : stub?.localFilePath, + fileId: stub?.id, }); if (result.cancelled) continue; if (!forceNewFile && stub && result.savedPath) { diff --git a/frontend/editor/src/core/components/toast/ToastRenderer.css b/frontend/editor/src/core/components/toast/ToastRenderer.css index 0cf2aac09..6ecd9e3df 100644 --- a/frontend/editor/src/core/components/toast/ToastRenderer.css +++ b/frontend/editor/src/core/components/toast/ToastRenderer.css @@ -51,6 +51,27 @@ pointer-events: auto; } +/* A toast tied to a policy pulses a glow in that policy's accent (var set + inline as --toast-glow), so it's obvious which policy is being applied. */ +.toast-item--glow { + animation: toast-glow-pulse 1.8s ease-in-out infinite; +} +@keyframes toast-glow-pulse { + 0%, + 100% { + box-shadow: + var(--shadow-lg), + 0 0 0 1px color-mix(in srgb, var(--toast-glow) 35%, transparent), + 0 0 10px -2px var(--toast-glow); + } + 50% { + box-shadow: + var(--shadow-lg), + 0 0 0 1px color-mix(in srgb, var(--toast-glow) 80%, transparent), + 0 0 20px 0 var(--toast-glow); + } +} + /* Toast Alert Type Colors */ .toast-item--success { background: var(--color-green-100); diff --git a/frontend/editor/src/core/components/toast/ToastRenderer.tsx b/frontend/editor/src/core/components/toast/ToastRenderer.tsx index 7c8f40395..c13010e27 100644 --- a/frontend/editor/src/core/components/toast/ToastRenderer.tsx +++ b/frontend/editor/src/core/components/toast/ToastRenderer.tsx @@ -1,3 +1,4 @@ +import type { CSSProperties } from "react"; import { useTranslation } from "react-i18next"; import { useToast } from "@app/components/toast/ToastContext"; import { ToastInstance, ToastLocation } from "@app/components/toast/types"; @@ -64,7 +65,16 @@ export default function ToastRenderer() {
{grouped[loc].map((t) => { return ( -
+
{/* Top row: Icon + Title + Controls */}
{/* Icon */} diff --git a/frontend/editor/src/core/components/toast/types.ts b/frontend/editor/src/core/components/toast/types.ts index 1377cda4b..1edbf62fb 100644 --- a/frontend/editor/src/core/components/toast/types.ts +++ b/frontend/editor/src/core/components/toast/types.ts @@ -25,6 +25,10 @@ export interface ToastOptions { id?: string; /** If true, show chevron and collapse/expand animation. Defaults to true. */ expandable?: boolean; + /** Optional accent colour (any CSS colour, e.g. a `var(--color-…)`). When set, + * the toast pulses a glow in this colour — used to tie a toast to a policy's + * accent. */ + glowColor?: string; } export interface ToastInstance extends Omit< diff --git a/frontend/editor/src/core/services/downloadService.ts b/frontend/editor/src/core/services/downloadService.ts index db728e66d..c61015f36 100644 --- a/frontend/editor/src/core/services/downloadService.ts +++ b/frontend/editor/src/core/services/downloadService.ts @@ -2,6 +2,9 @@ export interface DownloadRequest { data: Blob | File; filename: string; localPath?: string; + /** Workspace fileId of the file being exported, when known. Lets export-time + * policy enforcement version the in-editor file (not just the download). */ + fileId?: string; } export interface DownloadResult { diff --git a/frontend/editor/src/core/services/exportWithPolicy.ts b/frontend/editor/src/core/services/exportWithPolicy.ts new file mode 100644 index 000000000..207e37d78 --- /dev/null +++ b/frontend/editor/src/core/services/exportWithPolicy.ts @@ -0,0 +1,29 @@ +/** + * Export entry points call {@link downloadFileWithPolicy} instead of + * {@link downloadFile} so any "export"-triggered policy enforces on the file + * before it's downloaded. The enforcement itself is proprietary (a no-op in the + * core build via the `@app/services/policyExport` stub), and never hard-blocks: + * on failure the original file is downloaded. + */ + +import { + downloadFile, + type DownloadRequest, + type DownloadResult, +} from "@app/services/downloadService"; +import { enforceExportPolicies } from "@app/services/policyExport"; + +export async function downloadFileWithPolicy( + request: DownloadRequest, +): Promise { + // enforceExportPolicies only touches PDFs and is a no-op without an active + // export policy, so non-PDF / non-policy downloads pass straight through. + const input = + request.data instanceof File + ? request.data + : new File([request.data], request.filename, { + type: request.data.type, + }); + const [enforced] = await enforceExportPolicies([input], [request.fileId]); + return downloadFile({ ...request, data: enforced ?? request.data }); +} diff --git a/frontend/editor/src/core/services/pdfExportService.ts b/frontend/editor/src/core/services/pdfExportService.ts index dd605d481..da7bbc9a0 100644 --- a/frontend/editor/src/core/services/pdfExportService.ts +++ b/frontend/editor/src/core/services/pdfExportService.ts @@ -7,7 +7,7 @@ import { setPageRotation, addNewPage, } from "@app/services/pdfiumService"; -import { downloadFile } from "@app/services/downloadService"; +import { downloadFileWithPolicy } from "@app/services/exportWithPolicy"; import { PDFDocument, PDFPage } from "@app/types/pageEditor"; // A4 dimensions in PDF points (72 dpi) @@ -259,10 +259,10 @@ export class PDFExportService { } /** - * Download a single file + * Download a single file, applying any export-triggered policy first. */ downloadFile(blob: Blob, filename: string): void { - void downloadFile({ data: blob, filename }); + void downloadFileWithPolicy({ data: blob, filename }); } /** diff --git a/frontend/editor/src/core/services/policyExport.ts b/frontend/editor/src/core/services/policyExport.ts new file mode 100644 index 000000000..dc23a4093 --- /dev/null +++ b/frontend/editor/src/core/services/policyExport.ts @@ -0,0 +1,12 @@ +/** + * Open-source stub for export-time policy enforcement. Policies are a proprietary + * feature, so in the core build there's nothing to enforce — this returns the + * files unchanged. The proprietary build shadows this module via the `@app/*` + * alias with the real implementation. + */ +export async function enforceExportPolicies( + files: File[], + _fileIds?: (string | undefined)[], +): Promise { + return files; +} diff --git a/frontend/editor/src/core/utils/downloadUtils.ts b/frontend/editor/src/core/utils/downloadUtils.ts index b4af832a2..0f25935b3 100644 --- a/frontend/editor/src/core/utils/downloadUtils.ts +++ b/frontend/editor/src/core/utils/downloadUtils.ts @@ -2,6 +2,8 @@ import { StirlingFileStub } from "@app/types/fileContext"; import { fileStorage } from "@app/services/fileStorage"; import { zipFileService } from "@app/services/zipFileService"; import { downloadFile } from "@app/services/downloadService"; +import { downloadFileWithPolicy } from "@app/services/exportWithPolicy"; +import { enforceExportPolicies } from "@app/services/policyExport"; /** * Downloads a blob as a file using browser download API @@ -27,10 +29,11 @@ export async function downloadFileFromStorage( throw new Error(`File "${file.name}" not found in storage`); } - await downloadFile({ + await downloadFileWithPolicy({ data: stirlingFile, filename: stirlingFile.name, localPath: file.localFilePath, + fileId: file.id, }); } @@ -59,15 +62,15 @@ export async function downloadFilesAsZip( throw new Error("No files provided for ZIP download"); } - // Convert stored files to File objects + // Convert stored files to File objects (tracking ids so export policies can + // version the in-editor file). const filesToZip: File[] = []; + const fileIds: (string | undefined)[] = []; for (const fileWithUrl of files) { - const lookupKey = fileWithUrl.id; - const stirlingFile = await fileStorage.getStirlingFile(lookupKey); - + const stirlingFile = await fileStorage.getStirlingFile(fileWithUrl.id); if (stirlingFile) { - // StirlingFile is already a File object! filesToZip.push(stirlingFile); + fileIds.push(fileWithUrl.id); } } @@ -75,6 +78,9 @@ export async function downloadFilesAsZip( throw new Error("No valid files found in storage for ZIP download"); } + // Enforce any export-triggered policy on each PDF before they're zipped. + const enforced = await enforceExportPolicies(filesToZip, fileIds); + // Generate default filename if not provided const finalZipFilename = zipFilename || @@ -82,7 +88,7 @@ export async function downloadFilesAsZip( // Create and download ZIP const { zipFile } = await zipFileService.createZipFromFiles( - filesToZip, + enforced, finalZipFilename, ); await downloadFile({ data: zipFile, filename: finalZipFilename }); @@ -125,7 +131,7 @@ export async function downloadFiles( * @param filename - Optional custom filename */ export function downloadFileObject(file: File, filename?: string): void { - void downloadFile({ data: file, filename: filename || file.name }); + void downloadFileWithPolicy({ data: file, filename: filename || file.name }); } /** diff --git a/frontend/editor/src/proprietary/components/policies/Policies.css b/frontend/editor/src/proprietary/components/policies/Policies.css index fc6e24324..4102183a5 100644 --- a/frontend/editor/src/proprietary/components/policies/Policies.css +++ b/frontend/editor/src/proprietary/components/policies/Policies.css @@ -222,6 +222,34 @@ margin: 0 0 var(--space-2); } +/* Sub-section header inside a settings card (e.g. "Output filename"). The field + directly below it carries data-first so the borders don't double up. */ +.pol-subhead { + padding: 0.55rem 0.875rem 0.4rem; + font-size: 0.625rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--color-text-4); + background: var(--color-surface); + border-top: 1px solid var(--color-border); +} + +/* Position dropdown + custom-text input sitting on one row under the + "Output filename" subhead — the dropdown sizes to content, text fills. */ +.pol-name-row { + display: flex; + gap: var(--space-2); + align-items: center; +} +.pol-name-row > :first-child { + flex: 0 0 auto; +} +.pol-name-row > :last-child { + flex: 1 1 auto; + min-width: 0; +} + /* ---- Fields (PolicyFieldRow) — row inset matches SUI ListRow ---- */ .pol-field { padding: 0.7rem 0.875rem; diff --git a/frontend/editor/src/proprietary/components/policies/PoliciesSidebar.test.tsx b/frontend/editor/src/proprietary/components/policies/PoliciesSidebar.test.tsx index 0eabd502f..cc34c5e5e 100644 --- a/frontend/editor/src/proprietary/components/policies/PoliciesSidebar.test.tsx +++ b/frontend/editor/src/proprietary/components/policies/PoliciesSidebar.test.tsx @@ -112,14 +112,14 @@ describe("Policies right-sidebar surface", () => { } }); - it("shows Security as active and the unbuilt categories as Coming soon", () => { + it("shows Security as active and the unbuilt categories as upgrade-gated", () => { renderHost(); expect(screen.getAllByText("Active").length).toBeGreaterThanOrEqual(1); // Ingestion, Compliance, Routing, Retention are locked for this release. - expect(screen.getAllByText("Coming soon")).toHaveLength(4); + expect(screen.getAllByText("Upgrade to enterprise")).toHaveLength(4); }); - it("does not open a Coming soon policy when its row is clicked", () => { + it("does not open an upgrade-gated policy when its row is clicked", () => { renderHost(); fireEvent.click(screen.getByText("Ingestion")); // The locked row isn't a button — we stay on the list, nothing opens. diff --git a/frontend/editor/src/proprietary/components/policies/PoliciesSidebar.tsx b/frontend/editor/src/proprietary/components/policies/PoliciesSidebar.tsx index 2028063fe..5f401167c 100644 --- a/frontend/editor/src/proprietary/components/policies/PoliciesSidebar.tsx +++ b/frontend/editor/src/proprietary/components/policies/PoliciesSidebar.tsx @@ -151,7 +151,9 @@ export function PoliciesSection({ {cat.label} - Coming soon + + Upgrade to enterprise +
); diff --git a/frontend/editor/src/proprietary/components/policies/PolicySetupWizard.tsx b/frontend/editor/src/proprietary/components/policies/PolicySetupWizard.tsx index 2231d5112..a36adc1d5 100644 --- a/frontend/editor/src/proprietary/components/policies/PolicySetupWizard.tsx +++ b/frontend/editor/src/proprietary/components/policies/PolicySetupWizard.tsx @@ -80,8 +80,7 @@ interface PolicySetupWizardProps { * The shared policy wizard, used for both setup and edit. Two steps: Workflow * (the tool pipeline, reusing the Watch Folders builder) → Settings (the policy * fields + output/retry config). The workflow builder is kept mounted across - * steps so the final action can trigger its save. (A Sources step exists in - * code, gated off by SOURCES_IN_FLOW, for when non-editor sources return.) + * steps so the final action can trigger its save. */ export function PolicySetupWizard({ category, @@ -113,8 +112,8 @@ export function PolicySetupWizard({ ); const [scopeNarrow, setScopeNarrow] = useState(initial.scopeTypes.length > 0); const [scopeTypes, setScopeTypes] = useState(initial.scopeTypes); - // Reviewer is no longer configured in the flow (there's no human-review step), - // but the field is kept in the saved policy, defaulted to the signed-in user. + // Reviewer isn't shown in the flow; the field is still saved on the policy, + // defaulted to the signed-in user. const reviewerEmail = initial.reviewerEmail || user?.email || ""; // Output + retry settings — the real, working folder settings (the engine // applies them). Pre-filled from the backing folder in edit mode. @@ -129,6 +128,10 @@ export function PolicySetupWizard({ const [retryDelayMinutes, setRetryDelayMinutes] = useState( initialFolder?.retryDelayMinutes ?? 5, ); + // The editor event this policy runs on: input on upload, or output on export. + const [runOn, setRunOn] = useState<"upload" | "export">( + initial.runOn ?? "upload", + ); const workflowSave = useRef<(() => void) | null>(null); const [submitting, setSubmitting] = useState(false); const [saveError, setSaveError] = useState(null); @@ -197,6 +200,7 @@ export function PolicySetupWizard({ scopeTypes: scopeNarrow ? scopeTypes : [], reviewerEmail, folder: { + runOn, outputMode, outputName: outputName.trim(), outputNamePosition, @@ -229,6 +233,7 @@ export function PolicySetupWizard({ scopeTypes: scopeNarrow ? scopeTypes : [], reviewerEmail, folder: { + runOn, outputMode, outputName: outputName.trim(), outputNamePosition, @@ -329,35 +334,69 @@ export function PolicySetupWizard({ {step === 2 && ( <>

{category.desc}

- - {config.fields.map((f, i) => ( - - setFieldValues((prev) => ({ ...prev, [f.key]: v })) - } - /> - ))} - + {config.fields.length > 0 && ( + + {config.fields.map((f, i) => ( + + setFieldValues((prev) => ({ ...prev, [f.key]: v })) + } + /> + ))} + + )} {/* Real, working output + retry settings (applied by the engine). */}

Output & retries

+ {/* The editor event the policy runs on: input on upload, or + output on export (enforced before the file is exported). */} +
Run on
+ setRunOn(e.target.value as "upload" | "export") + } + aria-label="Run on" + options={[ + { value: "upload", label: "Upload" }, + { value: "export", label: "Export" }, + ]} + /> + } + /> +
+
Output
+
+ - setOutputMode( - e.target.value as "new_file" | "new_version", - ) - } + onChange={(e) => { + const mode = e.target.value as + | "new_file" + | "new_version"; + setOutputMode(mode); + // Auto-number only applies to new files; a new version + // replaces the file in place, so fall back to suffix. + if ( + mode === "new_version" && + outputNamePosition === "auto-number" + ) { + setOutputNamePosition("suffix"); + } + }} aria-label="Output mode" options={[ { value: "new_file", label: "New file" }, @@ -367,41 +406,40 @@ export function PolicySetupWizard({ } />
-
- - setOutputNamePosition( - e.target.value as "prefix" | "suffix" | "auto-number", - ) - } - aria-label="Add to filename" - options={[ - { value: "prefix", label: "Prefix" }, - { value: "suffix", label: "Suffix" }, - { value: "auto-number", label: "Auto-number" }, - ]} - /> - } - /> -
-
- Output filename
+
+
+ setOutputName(e.target.value)} - placeholder="optional" - aria-label="Text to add" + placeholder="Text to add (optional)" + aria-label="Filename text" /> - } - /> + )} +
{tool.enabled && (tool.operation === "redact" ? ( - // Redact config is reduced to just the PII type picker.
) : tool.operation === "watermark" ? ( - // Watermark: full settings minus the "Flatten PDF pages to - // images" toggle (hidden), with flatten forced on. + // Watermark settings with flatten hidden + forced on (see + // PolicyWatermarkConfig).
{ if (!POLICIES_ENABLED) return; const active = Object.entries(policies).filter( - ([, s]) => s.configured && s.status === "active" && s.backendId, + ([, s]) => + s.configured && + s.status === "active" && + s.backendId && + // Only auto-run on upload when the policy is set to run on upload + // (export-triggered policies enforce at export time instead). + (s.runOn ?? "upload") === "upload", ); for (const [categoryId, s] of active) { for (const stub of fileStubs) { const key = dispatchKey(categoryId, stub.id); - // Skip if already run (persisted) or a dispatch is mid-flight (this - // session) — runPolicyOnFile now waits for the file's bytes to commit, - // so the in-memory guard, not an eager mark, prevents double-firing. + // Skip if already run (persisted) or a dispatch is in flight — the + // in-memory guard prevents double-firing during the async wait. if (isDispatched(categoryId, stub.id) || dispatching.current.has(key)) { continue; } diff --git a/frontend/editor/src/proprietary/data/policyDefinitions.test.ts b/frontend/editor/src/proprietary/data/policyDefinitions.test.ts index c3e1e6003..7ca0c28e8 100644 --- a/frontend/editor/src/proprietary/data/policyDefinitions.test.ts +++ b/frontend/editor/src/proprietary/data/policyDefinitions.test.ts @@ -5,7 +5,9 @@ describe("policy definitions integrity", () => { it("every category has a matching config entry", () => { for (const cat of POLICY_CATEGORIES) { expect(POLICY_CONFIG[cat.id], `config for ${cat.id}`).toBeDefined(); - expect(POLICY_CONFIG[cat.id].fields.length).toBeGreaterThan(0); + // A category may have no policy-level setting fields (e.g. Security's were + // all unwired and removed); fields is required but can be empty. + expect(Array.isArray(POLICY_CONFIG[cat.id].fields)).toBe(true); expect(POLICY_CONFIG[cat.id].rules.length).toBeGreaterThan(0); // Every preset seeds a real, non-empty pipeline (the category→steps map). expect( diff --git a/frontend/editor/src/proprietary/data/policyDefinitions.tsx b/frontend/editor/src/proprietary/data/policyDefinitions.tsx index 07601a0d3..64fe0abb8 100644 --- a/frontend/editor/src/proprietary/data/policyDefinitions.tsx +++ b/frontend/editor/src/proprietary/data/policyDefinitions.tsx @@ -200,29 +200,9 @@ export const POLICY_CONFIG: Record = { }, ], scopeLabel: "All PDFs on this device", - // Policy-level controls only — detection/encryption/signing/watermark are - // per-tool and now live in the Workflow step. - fields: [ - { - label: "User can override", - key: "userOverride", - type: "toggle", - value: true, - }, - { - label: "Default access level", - key: "defaultAccess", - type: "select", - value: "Restricted", - options: ["Open", "Restricted", "Confidential", "Top Secret"], - }, - { - label: "Block external sharing", - key: "blockExternal", - type: "toggle", - value: false, - }, - ], + // No policy-level setting fields: tool config lives in the Workflow step; + // output naming + retries are set in the wizard. + fields: [], }, compliance: { summary: diff --git a/frontend/editor/src/proprietary/hooks/usePolicies.test.ts b/frontend/editor/src/proprietary/hooks/usePolicies.test.ts index 6563c48b9..1a05591c4 100644 --- a/frontend/editor/src/proprietary/hooks/usePolicies.test.ts +++ b/frontend/editor/src/proprietary/hooks/usePolicies.test.ts @@ -52,6 +52,7 @@ const wizardResult = { scopeTypes: [], reviewerEmail: "reviewer@x.com", folder: { + runOn: "upload" as const, outputMode: "new_file" as const, outputName: "", outputNamePosition: "prefix" as const, diff --git a/frontend/editor/src/proprietary/hooks/usePolicies.ts b/frontend/editor/src/proprietary/hooks/usePolicies.ts index 8e954b23f..1e5019248 100644 --- a/frontend/editor/src/proprietary/hooks/usePolicies.ts +++ b/frontend/editor/src/proprietary/hooks/usePolicies.ts @@ -130,6 +130,7 @@ export function usePolicies() { reviewerEmail: result.reviewerEmail, outputMode: result.folder.outputMode, outputName: result.folder.outputName, + runOn: result.folder.runOn, }); }, [], @@ -165,6 +166,7 @@ export function usePolicies() { reviewerEmail: result.reviewerEmail, outputMode: result.folder.outputMode, outputName: result.folder.outputName, + runOn: result.folder.runOn, }); }, [], @@ -228,6 +230,7 @@ export function usePolicies() { reviewerEmail: result.reviewerEmail, outputMode: result.folder.outputMode, outputName: result.folder.outputName, + runOn: result.folder.runOn, }); }, [], diff --git a/frontend/editor/src/proprietary/hooks/usePolicyFileBadges.ts b/frontend/editor/src/proprietary/hooks/usePolicyFileBadges.ts index 6afc43a48..563f49161 100644 --- a/frontend/editor/src/proprietary/hooks/usePolicyFileBadges.ts +++ b/frontend/editor/src/proprietary/hooks/usePolicyFileBadges.ts @@ -4,6 +4,11 @@ import { loadPolicyCatalog } from "@app/services/policyCatalog"; import { ROW_ACCENT } from "@app/components/policies/policyStatus"; import type { FileItemPolicyRef } from "@app/components/shared/FileSidebarFileItem"; +/** How long after a run a badge counts as "recent" (drives the one-off glow). + * Covers the run + import delay; old/reloaded runs fall outside it, so the glow + * fires only just after a policy is applied, not on every page reload. */ +const RECENT_MS = 60_000; + /** Policy accent name (ROW_ACCENT) → the CSS colour var the badge uses. */ const ACCENT_VAR: Record = { blue: "var(--color-blue)", @@ -26,10 +31,12 @@ export function usePolicyFileBadges(): Map { 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)) { @@ -37,6 +44,7 @@ export function usePolicyFileBadges(): Map { id: run.categoryId, name, accentColor: ACCENT_VAR[ROW_ACCENT[run.categoryId] ?? "blue"], + recent, }); byFile.set(fileId, list); } diff --git a/frontend/editor/src/proprietary/services/policyBackend.ts b/frontend/editor/src/proprietary/services/policyBackend.ts index 77ff6ecbd..5b3097e02 100644 --- a/frontend/editor/src/proprietary/services/policyBackend.ts +++ b/frontend/editor/src/proprietary/services/policyBackend.ts @@ -53,6 +53,7 @@ export function decodedToState( fieldValues: decoded.fieldValues, outputMode: decoded.folder.outputMode, outputName: decoded.folder.outputName, + runOn: decoded.folder.runOn, folderId: localFolderId, backendId: decoded.id, // Catalog-category policies are built-in defaults (not deletable). diff --git a/frontend/editor/src/proprietary/services/policyExport.ts b/frontend/editor/src/proprietary/services/policyExport.ts new file mode 100644 index 000000000..c4c712e3c --- /dev/null +++ b/frontend/editor/src/proprietary/services/policyExport.ts @@ -0,0 +1,192 @@ +/** + * Export-time policy enforcement. A policy whose `runOn` is "export" runs its + * pipeline on a file just before it leaves the editor; the enforced result is + * what gets exported. Core export handlers reach this via the `@app/*` alias + * (the open-source build ships a no-op stub). + * + * Export is never hard-blocked: on failure the original file is exported and a + * warning toast is shown. For "new version" policies, the run is also recorded + * so the mounted import effect versions the in-editor file, not just the + * download. Only PDFs are enforced. + */ + +import { loadPolicies } from "@app/services/policyStorage"; +import { loadPolicyCatalog } from "@app/services/policyCatalog"; +import { + runStoredPolicy, + getPolicyRun, + downloadPolicyOutput, +} from "@app/services/policyApi"; +import { recordRunStart } from "@app/components/policies/policyRunStore"; +import { ROW_ACCENT } from "@app/components/policies/policyStatus"; +import { alert, updateToast, dismissToast } from "@app/components/toast"; +import { POLICIES_ENABLED } from "@app/constants/featureFlags"; + +/** Poll cadence + cap for a single export run (≈2.5 min worst case). */ +const POLL_MS = 2000; +const MAX_POLLS = 75; +/** How long the result toast lingers before fading out. */ +const TOAST_LINGER_MS = 10_000; +const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +const isPdf = (f: File) => + f.type === "application/pdf" || /\.pdf$/i.test(f.name); + +interface ExportPolicy { + categoryId: string; + backendId: string; + label: string; + outputMode: "new_file" | "new_version"; + /** The policy's accent as a CSS colour, for the toast glow. */ + accent: string; +} + +interface PolicyRunResult { + file: File; + runId: string; + outputs: { fileId: string; fileName: string }[]; +} + +/** Configured, active policies set to enforce on export (read from the cache). */ +function activeExportPolicies(): ExportPolicy[] { + if (!POLICIES_ENABLED) return []; + const labels = new Map( + loadPolicyCatalog().categories.map((c) => [c.id, c.label]), + ); + return Object.entries(loadPolicies()) + .filter( + ([, s]) => + s.configured && + s.status === "active" && + s.backendId && + s.runOn === "export", + ) + .map(([id, s]) => ({ + categoryId: id, + backendId: s.backendId as string, + label: labels.get(id) ?? "Policy", + outputMode: s.outputMode === "new_file" ? "new_file" : "new_version", + accent: `var(--color-${ROW_ACCENT[id] ?? "blue"})`, + })); +} + +/** Run one policy on a file and resolve the enforced bytes + run info (throws on + * failure). */ +async function runToCompletion( + backendId: string, + file: File, +): Promise { + const runId = await runStoredPolicy(backendId, [file]); + for (let i = 0; i < MAX_POLLS; i++) { + await delay(POLL_MS); + let view; + try { + view = await getPolicyRun(runId); + } catch { + continue; // transient — keep polling within the cap. + } + if (view.status === "COMPLETED") { + const out = view.outputs?.[0]; + if (!out) throw new Error("policy produced no output"); + const blob = await downloadPolicyOutput(out.fileId); + // Keep the export's filename; only the bytes are the enforced result. + const enforced = new File([blob], file.name, { + type: blob.type || file.type || "application/pdf", + }); + return { file: enforced, runId, outputs: view.outputs ?? [] }; + } + if (view.status === "FAILED" || view.status === "CANCELLED") { + throw new Error(view.error || `policy run ${view.status.toLowerCase()}`); + } + } + throw new Error("policy run timed out"); +} + +/** + * Enforce every active export-policy on each PDF just before export, returning + * the files in order (enforced, or the original on failure). `fileIds[i]` is the + * workspace id of `files[i]` when known — used to version the in-editor file for + * "new version" policies. Non-PDFs pass through untouched, and a single toast + * (glowing in the policy's accent while it runs) fades a few seconds after the + * result. + */ +export async function enforceExportPolicies( + files: File[], + fileIds?: (string | undefined)[], +): Promise { + const active = activeExportPolicies(); + const targets = files.flatMap((f, i) => (isPdf(f) ? [i] : [])); + if (!active.length || targets.length === 0) return files; + + const names = active.map((p) => p.label).join(", "); + const toastId = alert({ + alertType: "neutral", + title: `Applying ${names}`, + body: `Enforcing ${ + targets.length === 1 ? "your file" : `${targets.length} files` + } before export…`, + isPersistentPopup: true, + expandable: false, + glowColor: active[0].accent, + }); + + const out = [...files]; + let failures = 0; + for (const i of targets) { + const file = files[i]; + const fileId = fileIds?.[i]; + try { + let current = file; + // The last "new version" policy's output is what versions the editor file + // (recording every policy would double-consume the same input). + let versionRun: PolicyRunResult & { categoryId: string }; + let hasVersionRun = false; + for (const policy of active) { + const result = await runToCompletion(policy.backendId, current); + current = result.file; + if (policy.outputMode === "new_version" && fileId) { + versionRun = { ...result, categoryId: policy.categoryId }; + hasVersionRun = true; + } + } + out[i] = current; + if (hasVersionRun && fileId) { + recordRunStart({ + runId: versionRun!.runId, + categoryId: versionRun!.categoryId, + fileId, + fileName: file.name, + fileSize: file.size, + status: "COMPLETED", + outputs: versionRun!.outputs, + error: null, + startedAt: Date.now(), + }); + } + } catch { + failures += 1; // leave out[i] as the original — never hard-block. + } + } + + updateToast( + toastId, + failures + ? { + alertType: "warning", + title: "Exported without full enforcement", + body: `${failures} of ${targets.length} file(s) couldn't be processed and were exported as-is.`, + isPersistentPopup: false, + glowColor: undefined, + } + : { + alertType: "success", + title: `${names} applied`, + body: "Enforced before export.", + isPersistentPopup: false, + glowColor: undefined, + }, + ); + // update() doesn't reschedule auto-dismiss, so fade the result out explicitly. + window.setTimeout(() => dismissToast(toastId), TOAST_LINGER_MS); + return out; +} diff --git a/frontend/editor/src/proprietary/services/policyPipeline.test.ts b/frontend/editor/src/proprietary/services/policyPipeline.test.ts index 9b0315eac..e6017e99b 100644 --- a/frontend/editor/src/proprietary/services/policyPipeline.test.ts +++ b/frontend/editor/src/proprietary/services/policyPipeline.test.ts @@ -116,6 +116,7 @@ const samplePolicy = { reviewerEmail: "me@x.com", fieldValues: { minConfidence: "80%" }, folder: { + runOn: "export" as const, outputMode: "new_version" as const, outputName: "secured", outputNamePosition: "suffix" as const, @@ -133,7 +134,7 @@ describe("buildBackendPolicy", () => { expect(policy.steps).toEqual([ { operation: "/api/v1/misc/compress-pdf", parameters: {} }, ]); - // Trigger is null (browser-driven); extras ride in output.options. + // Manual-only policy: no server-side trigger, extras ride in output.options. expect(policy.trigger).toBeNull(); expect(policy.output.options.categoryId).toBe("security"); expect(policy.output.options.reviewerEmail).toBe("me@x.com"); diff --git a/frontend/editor/src/proprietary/services/policyPipeline.ts b/frontend/editor/src/proprietary/services/policyPipeline.ts index abbd28dd9..29f38695a 100644 --- a/frontend/editor/src/proprietary/services/policyPipeline.ts +++ b/frontend/editor/src/proprietary/services/policyPipeline.ts @@ -35,7 +35,7 @@ export interface BackendPipelineDefinition { output: BackendOutputSpec; } -/** A policy's automatic trigger ("schedule" | "folder-watch"); null means manual (run on demand). */ +/** How a stored policy is triggered ("manual" | "folder" | "schedule" | "s3"). */ export interface BackendTriggerConfig { type: string; options: Record; @@ -49,7 +49,8 @@ export interface BackendPolicy { owner: string; /** Gates automatic triggering; an explicit run ignores it. */ enabled: boolean; - /** Null = manual; these policies are run from the browser on uploaded files. */ + /** Null for a manual-only (client-driven) policy — the editor fires runs on + * upload/export via /run, so there's no server-side trigger. */ trigger: BackendTriggerConfig | null; steps: BackendPipelineStep[]; output: BackendOutputSpec; @@ -189,7 +190,7 @@ export interface PolicyToStore { /** The decoded policy read back from the backend. */ export interface DecodedPolicy { id: string; - /** The catalog category this policy maps to (from output.options.categoryId). */ + /** The catalog category this policy maps to (from trigger.options.categoryId). */ categoryId: string; name: string; enabled: boolean; @@ -203,6 +204,7 @@ export interface DecodedPolicy { } const DEFAULT_FOLDER: PolicyFolderSettings = { + runOn: "upload", outputMode: "new_version", outputName: "", outputNamePosition: "prefix", @@ -211,11 +213,16 @@ const DEFAULT_FOLDER: PolicyFolderSettings = { }; /** - * Map a frontend policy to the backend {@link BackendPolicy} for persistence. Trigger is null: - * these policies are run from the browser on uploaded files, not fired by a backend trigger. The - * backend models only name/enabled/trigger/steps/output, so all policy-level extras (categoryId, - * sources, scope, reviewer, fields, output/retry settings, and the full automation for a lossless - * UI round-trip) ride in `output.options`; `steps` carries the endpoint-mapped pipeline. + * Map a frontend policy to the backend {@link BackendPolicy} for persistence. + * Policies are manual-only (client-driven): the editor fires runs on upload / + * before export via /run, so `trigger` is null (a server-side folder-watch or + * schedule trigger doesn't fit the in-editor model, and a null trigger skips + * trigger validation on the backend). The backend models only + * name/enabled/trigger/steps/output, so the policy-level extras (categoryId, + * sources, scope, reviewer, fields) and the output + retry settings all ride in + * `output.options`; the full frontend automation is stashed in + * `output.options.automation` for a lossless UI round-trip (while `steps` + * carries the endpoint-mapped pipeline the engine runs, pre-built by the caller). */ export function buildBackendPolicy(input: PolicyToStore): BackendPolicy { return { @@ -234,6 +241,8 @@ export function buildBackendPolicy(input: PolicyToStore): BackendPolicy { maxRetries: input.folder.maxRetries, retryDelayMinutes: input.folder.retryDelayMinutes, automation: input.automation, + runOn: input.folder.runOn, + // Policy-level metadata (no trigger bag to hold it any more). categoryId: input.categoryId, sources: input.sources, scopeTypes: input.scopeTypes, @@ -247,27 +256,29 @@ export function buildBackendPolicy(input: PolicyToStore): BackendPolicy { /** Decode a stored backend policy back into the frontend settings. */ export function fromBackendPolicy(policy: BackendPolicy): DecodedPolicy { const output = policy.output.options; + // Metadata lives in output.options; legacy records kept it in trigger.options, + // so merge both (output wins) to decode either shape. + const meta = { ...(policy.trigger?.options ?? {}), ...output }; const str = (v: unknown, fallback = "") => typeof v === "string" ? v : fallback; const num = (v: unknown, fallback: number) => typeof v === "number" ? v : fallback; return { id: policy.id, - categoryId: str(output.categoryId), + categoryId: str(meta.categoryId), name: policy.name, enabled: policy.enabled, automation: (output.automation as AutomationConfig | undefined) ?? null, - sources: Array.isArray(output.sources) ? (output.sources as string[]) : [], - scopeTypes: Array.isArray(output.scopeTypes) - ? (output.scopeTypes as string[]) + sources: Array.isArray(meta.sources) ? (meta.sources as string[]) : [], + scopeTypes: Array.isArray(meta.scopeTypes) + ? (meta.scopeTypes as string[]) : [], - reviewerEmail: str(output.reviewerEmail), + reviewerEmail: str(meta.reviewerEmail), fieldValues: - (output.fieldValues as DecodedPolicy["fieldValues"] | undefined) ?? {}, + (meta.fieldValues as DecodedPolicy["fieldValues"] | undefined) ?? {}, folder: { - // Default to versioning unless the stored policy explicitly says new_file, - // so a missing/legacy output.mode follows the new-version default rather - // than silently flipping a reconciled policy to spawning separate files. + runOn: meta.runOn === "export" ? "export" : "upload", + // Legacy/missing output.mode defaults to new_version, not new_file. outputMode: output.mode === "new_file" ? "new_file" : "new_version", outputName: str(output.name), outputNamePosition: diff --git a/frontend/editor/src/proprietary/services/policyStorage.ts b/frontend/editor/src/proprietary/services/policyStorage.ts index 87616555c..20db264e4 100644 --- a/frontend/editor/src/proprietary/services/policyStorage.ts +++ b/frontend/editor/src/proprietary/services/policyStorage.ts @@ -26,6 +26,8 @@ function defaultState(): PolicyState { outputMode: "new_version", // No rename by default — the output keeps the input's filename. outputName: "", + // Enforce on upload by default; export enforcement is the alternative. + runOn: "upload", // Every catalog category is a shipped, built-in policy → default (not // deletable). User-created policies (later) will set this false. isDefault: true, diff --git a/frontend/editor/src/proprietary/types/policies.ts b/frontend/editor/src/proprietary/types/policies.ts index 7cc5dc27c..9c0bf0f0b 100644 --- a/frontend/editor/src/proprietary/types/policies.ts +++ b/frontend/editor/src/proprietary/types/policies.ts @@ -132,6 +132,8 @@ export interface PolicyState { * input's filename; when set, it's applied as a prefix/suffix per the policy's * name-position setting. */ outputName?: string; + /** When the policy runs: on "upload" or before "export". Defaults to "upload". */ + runOn?: "upload" | "export"; /** * The backing folder-trigger record (a Watched Folders `WatchedFolder`) that * holds this policy's editable steps (its automation), output config and run @@ -160,6 +162,8 @@ export type PoliciesByCategory = Record; * backing folder (the real, working settings reused from the folder setup). */ export interface PolicyFolderSettings { + /** The editor event the policy runs on: "upload" or "export". */ + runOn: "upload" | "export"; outputMode: "new_file" | "new_version"; outputName: string; outputNamePosition: "prefix" | "suffix" | "auto-number";