Policies: enforce on upload or export (#6614)

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.
This commit is contained in:
Reece Browne
2026-06-11 18:12:01 +01:00
committed by GitHub
parent 5fa5e12c64
commit 9ee0bc4b32
31 changed files with 529 additions and 123 deletions
@@ -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 });
}
/**