feat(policies): backend-driven policy enforcement (frontend) (#6598)

## Summary
Adds the **Policies** feature (proprietary, behind the
`POLICIES_ENABLED` flag): backend-driven enforcement that runs a fixed
tool pipeline on documents, docked in the right tool sidebar alongside
Tools.

## Highlights
- **Policy catalog** — 5 categories; **Security** is wired (redact PII +
sanitize), the others are marked "Coming soon".
- **Backend as source of truth** — policies persist via the Policies
engine (`/api/v1/policies`), one policy per category, with a local cache
+ offline fallback.
- **Auto-run** — enabled policies run on every uploaded file: dispatch →
poll → import outputs into the workspace.
- **Security redact config** — PII preset dropdown + custom word/regex
entry + advanced options; tool params map to the backend endpoint
fields.
- **Activity feed** with retry on failures; **file badges** showing
which policies ran on a file (sidebar + files page), tinted to the
policy colour.
- Reuses the **Watched Folders** engine for each policy's backing
folder; policy-owned folders are filtered out of the Watched Folders UI.

## Notes
- Gated by `POLICIES_ENABLED` (true in proprietary, false in core) —
unreachable in the open-source build.
- Frontend-only diff; depends on the backend Policies engine and the
merged Watched Folders feature.
This commit is contained in:
Reece Browne
2026-06-10 15:57:08 +01:00
committed by GitHub
parent ebc28b0a14
commit 8dde4262ec
91 changed files with 7250 additions and 99 deletions
@@ -0,0 +1,99 @@
/**
* Client for the backend Policies engine (`/api/v1/policies`). Runs a
* pipeline on the server — the "backend automation" path — and polls its status.
* Outputs are downloaded via the existing `/api/v1/general/files/{id}` endpoint
* using the file ids in the run view.
*/
import apiClient from "@app/services/apiClient";
import type {
BackendPipelineDefinition,
BackendPolicy,
PolicyRunView,
} from "@app/services/policyPipeline";
interface JobResponse {
async: boolean;
jobId: string;
result: unknown;
}
// --- Policy config persistence (server-side store, JPA-backed) ---
/** Create or update a policy; the backend assigns a blank id and returns it. */
export async function savePolicy(
policy: BackendPolicy,
): Promise<BackendPolicy> {
const res = await apiClient.post<BackendPolicy>("/api/v1/policies", policy);
return res.data;
}
/** List all stored policies. */
export async function listPolicies(): Promise<BackendPolicy[]> {
const res = await apiClient.get<BackendPolicy[]>("/api/v1/policies");
return res.data;
}
/** Fetch a stored policy by id. */
export async function getPolicy(id: string): Promise<BackendPolicy> {
const res = await apiClient.get<BackendPolicy>(
`/api/v1/policies/${encodeURIComponent(id)}`,
);
return res.data;
}
/** Delete a stored policy by id. */
export async function deletePolicy(id: string): Promise<void> {
await apiClient.delete(`/api/v1/policies/${encodeURIComponent(id)}`);
}
/** Run a stored policy by id on the supplied files; returns the run id. */
export async function runStoredPolicy(
id: string,
files: File[],
): Promise<string> {
const form = new FormData();
for (const file of files) form.append("fileInput", file);
const res = await apiClient.post<JobResponse>(
`/api/v1/policies/${encodeURIComponent(id)}/run`,
form,
{ headers: { "Content-Type": "multipart/form-data" } },
);
return res.data.jobId;
}
// --- Ad-hoc pipeline runs (no stored policy) ---
/**
* Run an ad-hoc pipeline on the backend over the given documents. Returns the
* run id; poll {@link getPolicyRun} for status + output file ids.
*/
export async function runPolicyPipeline(
definition: BackendPipelineDefinition,
files: File[],
): Promise<string> {
const form = new FormData();
for (const file of files) form.append("fileInput", file);
form.append("json", JSON.stringify(definition));
const res = await apiClient.post<JobResponse>("/api/v1/policies/run", form, {
headers: { "Content-Type": "multipart/form-data" },
});
return res.data.jobId;
}
/** Download a run's output file by id (via the shared general-files endpoint). */
export async function downloadPolicyOutput(fileId: string): Promise<Blob> {
const res = await apiClient.get<Blob>(
`/api/v1/general/files/${encodeURIComponent(fileId)}`,
{ responseType: "blob" },
);
return res.data;
}
/** Current status, step cursor and output files of a run. */
export async function getPolicyRun(runId: string): Promise<PolicyRunView> {
const res = await apiClient.get<PolicyRunView>(
`/api/v1/policies/run/${encodeURIComponent(runId)}`,
);
return res.data;
}
@@ -0,0 +1,95 @@
/**
* Backend source-of-truth layer for Policies. Wraps the raw `policyApi` client +
* the `policyPipeline` mapper into category-shaped operations the hook can use:
* fetch the stored policies (grouped by catalog category), persist one, flip its
* enabled flag, and delete it.
*
* The frontend is category-keyed (one policy per catalog category); the backend
* is a flat list with assigned ids. The bridge is `trigger.options.categoryId`,
* which `policyPipeline` encodes on save and decodes on read.
*/
import * as policyApi from "@app/services/policyApi";
import {
buildBackendPolicy,
fromBackendPolicy,
type DecodedPolicy,
type PolicyToStore,
} from "@app/services/policyPipeline";
import type { PolicyState } from "@app/types/policies";
/**
* Fetch every stored policy and decode it, keyed by its catalog category. If two
* stored policies share a category (shouldn't happen — one per category), the
* last one wins; policies with no recognised categoryId are skipped.
*/
export async function fetchPoliciesByCategory(): Promise<
Map<string, DecodedPolicy>
> {
const stored = await policyApi.listPolicies();
const byCategory = new Map<string, DecodedPolicy>();
for (const policy of stored) {
const decoded = fromBackendPolicy(policy);
if (decoded.categoryId) byCategory.set(decoded.categoryId, decoded);
}
return byCategory;
}
/**
* Project a decoded backend policy onto the frontend per-category state. The
* locally-cached `folderId` (the editable-automation link, which the backend
* doesn't track) is preserved by the caller via `localFolderId`.
*/
export function decodedToState(
decoded: DecodedPolicy,
localFolderId: string | undefined,
): PolicyState {
return {
configured: true,
status: decoded.enabled ? "active" : "paused",
sources: decoded.sources,
scopeTypes: decoded.scopeTypes,
reviewerEmail: decoded.reviewerEmail,
fieldValues: decoded.fieldValues,
folderId: localFolderId,
backendId: decoded.id,
// Catalog-category policies are built-in defaults (not deletable).
isDefault: true,
};
}
/**
* The backend id of the stored policy for a category, if one exists. Used to
* enforce one-policy-per-category: a save reuses this id (update) rather than
* creating a duplicate, even if the local cache lost the link.
*/
export async function findBackendId(
categoryId: string,
): Promise<string | undefined> {
const byCategory = await fetchPoliciesByCategory();
return byCategory.get(categoryId)?.id;
}
/** Persist a policy (create or update); returns the backend-assigned id. */
export async function persistPolicy(store: PolicyToStore): Promise<string> {
const saved = await policyApi.savePolicy(buildBackendPolicy(store));
return saved.id;
}
/**
* Flip a stored policy's `enabled` flag (pause/resume) — the backend gates
* automatic triggering on it. Reads the current policy so the rest of its config
* is preserved on the round-trip.
*/
export async function setPolicyEnabled(
backendId: string,
enabled: boolean,
): Promise<void> {
const current = await policyApi.getPolicy(backendId);
await policyApi.savePolicy({ ...current, enabled });
}
/** Delete a stored policy by its backend id. */
export async function removePolicy(backendId: string): Promise<void> {
await policyApi.deletePolicy(backendId);
}
@@ -0,0 +1,43 @@
/**
* The catalog of available policy *definitions* — the policy types, their
* editable fields, the sources a policy can run over, and the document types
* scope can be narrowed to. This is the single seam through which definitions
* reach the UI: components never import the static definitions directly, they
* read the catalog (via {@link usePolicyCatalog}).
*
* Backed by the static `policyDefinitions` today; swap {@link loadPolicyCatalog}
* for a fetch to move definitions server-side without touching any component.
*/
import {
POLICY_CATEGORIES,
POLICY_CONFIG,
POLICY_SOURCES,
POLICY_DOC_TYPES,
} from "@app/data/policyDefinitions";
import type {
PolicyCategory,
PolicyConfigDef,
PolicySource,
} from "@app/types/policies";
export interface PolicyCatalog {
/** Available policy types, in display order. */
categories: PolicyCategory[];
/** Per-category narrative + editable field definitions, keyed by category id. */
configs: Record<string, PolicyConfigDef>;
/** Sources a policy can run over (wizard step 2). */
sources: PolicySource[];
/** Document types scope can be narrowed to (gated behind classification). */
docTypes: string[];
}
/** Load the policy catalog. Swap this for a backend fetch to go live. */
export function loadPolicyCatalog(): PolicyCatalog {
return {
categories: POLICY_CATEGORIES,
configs: POLICY_CONFIG,
sources: POLICY_SOURCES,
docTypes: POLICY_DOC_TYPES,
};
}
@@ -0,0 +1,85 @@
import "fake-indexeddb/auto";
import { describe, it, expect, beforeEach, vi } from "vitest";
// jsdom's crypto has no randomUUID, which watchedFolderStorage uses for folder ids.
if (typeof globalThis.crypto?.randomUUID !== "function") {
const orig = globalThis.crypto;
vi.stubGlobal("crypto", {
getRandomValues: orig?.getRandomValues?.bind(orig),
randomUUID: () =>
`p-${Math.random().toString(36).slice(2)}${Date.now().toString(36)}`,
});
}
import {
createPolicyFolder,
getPolicyOperations,
updatePolicyOperations,
setPolicyFolderPaused,
deletePolicyFolder,
} from "@app/services/policyFolders";
import { watchedFolderStorage } from "@app/services/watchedFolderStorage";
import { automationStorage } from "@app/services/automationStorage";
import type { PolicyCategory } from "@app/types/policies";
const category: PolicyCategory = {
id: "security",
label: "Security",
icon: null,
desc: "Detect PII, encrypt, verify.",
};
const steps = [
{ operation: "sanitize", parameters: {} },
{ operation: "addPassword", parameters: {} },
];
describe("policyFolders backing-folder layer", () => {
beforeEach(async () => {
// Clean slate between tests (fake-indexeddb persists within a run).
for (const f of await watchedFolderStorage.getAllFolders()) {
await watchedFolderStorage.deleteFolder(f.id);
}
for (const a of await automationStorage.getAllAutomations()) {
await automationStorage.deleteAutomation(a.id);
}
});
it("creates a folder + automation tagged with the policy category", async () => {
const folder = await createPolicyFolder(category, steps);
expect(folder.policyCategoryId).toBe("security");
expect(folder.automationId).toBeTruthy();
const ops = await getPolicyOperations(folder.id);
expect(ops.map((o) => o.operation)).toEqual(["sanitize", "addPassword"]);
});
it("updates the steps through the backing automation", async () => {
const folder = await createPolicyFolder(category, steps);
await updatePolicyOperations(folder.id, [
{ operation: "compress", parameters: {} },
]);
const ops = await getPolicyOperations(folder.id);
expect(ops.map((o) => o.operation)).toEqual(["compress"]);
});
it("pauses/resumes via the backing folder flag", async () => {
const folder = await createPolicyFolder(category, steps);
await setPolicyFolderPaused(folder.id, true);
expect((await watchedFolderStorage.getFolder(folder.id))?.isPaused).toBe(
true,
);
await setPolicyFolderPaused(folder.id, false);
expect((await watchedFolderStorage.getFolder(folder.id))?.isPaused).toBe(
false,
);
});
it("deletes the folder and its automation", async () => {
const folder = await createPolicyFolder(category, steps);
const automationId = folder.automationId;
await deletePolicyFolder(folder.id);
expect(await watchedFolderStorage.getFolder(folder.id)).toBeNull();
expect(await automationStorage.getAutomation(automationId)).toBeNull();
});
});
@@ -0,0 +1,137 @@
/**
* Backing-folder layer for Policies. A configured policy's folder trigger,
* editable steps, output and run-state all live in a Watched Folders
* {@link WatchedFolder} (+ its {@link AutomationConfig}) — the policy reuses the
* Watched Folders engine rather than re-implementing execution. This module is
* the seam that creates and manages that backing record.
*
* The folder is tagged with `policyCategoryId` so the Watched Folders UI can
* filter it out (it's owned by Policies). The backing automation also rides
* along to the backend (in the saved policy's output.options) for round-trip;
* this folder remains the locally-editable copy.
*/
import { automationStorage } from "@app/services/automationStorage";
import { watchedFolderStorage } from "@app/services/watchedFolderStorage";
import type {
AutomationConfig,
AutomationOperation,
} from "@app/types/automation";
import type { WatchedFolder } from "@app/types/watchedFolders";
import type { PolicyCategory, PolicyFolderSettings } from "@app/types/policies";
/** Folder icon (a name string) used for each policy category's backing folder. */
const CATEGORY_FOLDER_ICON: Record<string, string> = {
ingestion: "StorageIcon",
security: "SecurityIcon",
compliance: "CheckIcon",
routing: "SwapHorizIcon",
retention: "StorageIcon",
};
const POLICY_FOLDER_ACCENT = "#3b82f6";
/**
* Create the backing folder for a policy: persist an automation from the given
* steps, then a WatchedFolder (the folder trigger) referencing it, tagged with
* the policy's category id. Returns the created folder.
*/
export async function createPolicyFolder(
category: PolicyCategory,
operations: AutomationOperation[],
): Promise<WatchedFolder> {
const automation = await automationStorage.saveAutomation({
name: `${category.label} Policy`,
description: `Pipeline for the ${category.label} policy`,
operations,
});
return watchedFolderStorage.createFolder({
name: `${category.label} Policy`,
description: category.desc,
automationId: automation.id,
icon: CATEGORY_FOLDER_ICON[category.id] ?? "WorkIcon",
accentColor: POLICY_FOLDER_ACCENT,
policyCategoryId: category.id,
inputSource: "idb",
});
}
/**
* Create the backing folder for a policy from an *already-saved* automation
* (e.g. one the workflow builder just created). Pairs with the wizard, where
* AutomationCreation persists the automation and we link a folder to it.
*/
export async function createPolicyFolderForAutomation(
category: PolicyCategory,
automationId: string,
): Promise<WatchedFolder> {
return watchedFolderStorage.createFolder({
name: `${category.label} Policy`,
description: category.desc,
automationId,
icon: CATEGORY_FOLDER_ICON[category.id] ?? "WorkIcon",
accentColor: POLICY_FOLDER_ACCENT,
policyCategoryId: category.id,
inputSource: "idb",
});
}
/** The policy's current steps, resolved through its backing folder's automation. */
export async function getPolicyOperations(
folderId: string,
): Promise<AutomationOperation[]> {
const folder = await watchedFolderStorage.getFolder(folderId);
if (!folder) return [];
const automation = await automationStorage.getAutomation(folder.automationId);
return automation?.operations ?? [];
}
/** The policy's backing automation (its editable pipeline), via its folder. */
export async function getPolicyAutomation(
folderId: string,
): Promise<AutomationConfig | null> {
const folder = await watchedFolderStorage.getFolder(folderId);
if (!folder) return null;
return automationStorage.getAutomation(folder.automationId);
}
/** Replace the policy's steps by updating its backing automation. */
export async function updatePolicyOperations(
folderId: string,
operations: AutomationOperation[],
): Promise<void> {
const folder = await watchedFolderStorage.getFolder(folderId);
if (!folder) return;
const automation = await automationStorage.getAutomation(folder.automationId);
if (!automation) return;
await automationStorage.updateAutomation({ ...automation, operations });
}
/** Apply output + retry settings to the policy's backing folder. */
export async function updatePolicyFolderSettings(
folderId: string,
settings: PolicyFolderSettings,
): Promise<void> {
const folder = await watchedFolderStorage.getFolder(folderId);
if (!folder) return;
await watchedFolderStorage.updateFolder({ ...folder, ...settings });
}
/** Pause/resume the policy by toggling its backing folder's paused flag. */
export async function setPolicyFolderPaused(
folderId: string,
paused: boolean,
): Promise<void> {
const folder = await watchedFolderStorage.getFolder(folderId);
if (!folder) return;
await watchedFolderStorage.updateFolder({ ...folder, isPaused: paused });
}
/** Delete the policy's backing folder and its automation. */
export async function deletePolicyFolder(folderId: string): Promise<void> {
const folder = await watchedFolderStorage.getFolder(folderId);
if (folder) {
await automationStorage.deleteAutomation(folder.automationId);
}
await watchedFolderStorage.deleteFolder(folderId);
}
@@ -0,0 +1,97 @@
import { describe, it, expect } from "vitest";
import {
runsToActivity,
runsToStats,
policyActiveFor,
} from "@app/services/policyLiveData";
import type { PolicyRunRecord } from "@app/components/policies/policyRunStore";
function run(over: Partial<PolicyRunRecord>): PolicyRunRecord {
return {
runId: "r1",
categoryId: "security",
fileId: "f1",
fileName: "f.pdf",
fileSize: 0,
status: "COMPLETED",
outputs: [],
error: null,
startedAt: Date.now(),
...over,
};
}
const HOUR = 3_600_000;
describe("runsToActivity", () => {
it("maps completed/running/failed runs to activity rows", () => {
const activity = runsToActivity([
run({ runId: "a", fileName: "fresh.pdf", status: "RUNNING" }),
run({
runId: "b",
fileName: "contract.pdf",
status: "COMPLETED",
fileSize: 2_100_000,
startedAt: Date.now() - HOUR,
}),
run({
runId: "c",
fileName: "bad.pdf",
status: "FAILED",
error: "Step 2 failed",
}),
]);
expect(activity).toHaveLength(3);
expect(activity[0]).toMatchObject({
doc: "fresh.pdf",
status: "processing",
action: "Enforcing…",
});
expect(activity[1]).toMatchObject({
doc: "contract.pdf",
status: "enforced",
});
expect(activity[1].action).toContain("2.0 MB");
expect(activity[2]).toMatchObject({
doc: "bad.pdf",
status: "flagged",
action: "Step 2 failed",
});
});
});
describe("runsToStats", () => {
it("counts + sizes only the completed runs", () => {
const stats = runsToStats(
[
run({ runId: "a", status: "COMPLETED", fileSize: 2_100_000 }),
run({ runId: "b", status: "COMPLETED", fileSize: 900_000 }),
run({ runId: "c", status: "RUNNING", fileSize: 5_000_000 }),
],
undefined,
);
expect(stats.enforced).toBe(2);
expect(stats.dataProcessed).toBe("2.9 MB");
expect(stats.activeFor).toBe("Today");
});
it("derives activeFor from the backing folder's creation time", () => {
const fiveDaysAgo = new Date(Date.now() - 5 * 86400000).toISOString();
expect(runsToStats([], fiveDaysAgo).activeFor).toBe("5d");
});
});
describe("policyActiveFor", () => {
it("returns 'Today' for a just-activated policy", () => {
expect(policyActiveFor(new Date().toISOString())).toBe("Today");
});
it("returns 'Today' when there's no backing folder", () => {
expect(policyActiveFor(undefined)).toBe("Today");
});
it("reports whole-day duration since activation", () => {
const fiveDaysAgo = new Date(Date.now() - 5 * 86400000).toISOString();
expect(policyActiveFor(fiveDaysAgo)).toBe("5d");
});
});
@@ -0,0 +1,100 @@
/**
* Maps real backend policy runs (from policyRunStore) into the detail view's
* activity feed + summary stats. Runs are produced by the auto-run controller
* firing `/api/v1/policies/{id}/run` on every uploaded file, so the feed is the
* policy's actual enforcement history — not a cosmetic file listing.
*/
import type { PolicyActivityItem, PolicyStats } from "@app/types/policies";
import type { PolicyRunRecord } from "@app/components/policies/policyRunStore";
/** Relative "Nm/Nh ago" for an activity timestamp (epoch ms). */
function relativeTime(ts: number): string {
if (!ts) return "—";
const mins = Math.floor((Date.now() - ts) / 60000);
if (mins < 1) return "Just now";
if (mins < 60) return `${mins}m ago`;
const hrs = Math.floor(mins / 60);
if (hrs < 24) return `${hrs}h ago`;
const days = Math.floor(hrs / 24);
return days === 1 ? "Yesterday" : `${days}d ago`;
}
/** Duration since a timestamp, e.g. "18d" / "5h" (no "ago"). */
function durationSince(ts: number): string {
const ms = Date.now() - ts;
const days = Math.floor(ms / 86400000);
if (days >= 1) return `${days}d`;
const hrs = Math.floor(ms / 3600000);
return hrs >= 1 ? `${hrs}h` : "Today";
}
/**
* Human byte size, e.g. "2.1 MB". Intentionally NOT core `formatFileSize`:
* that one renders 2 decimals (`2.13 MB`), whereas the policy summary wants
* the quieter whole-number / single-decimal form used across this surface.
*/
function formatBytes(bytes: number): string {
if (!bytes) return "0 B";
const units = ["B", "KB", "MB", "GB", "TB"];
let v = bytes;
let i = 0;
while (v >= 1024 && i < units.length - 1) {
v /= 1024;
i += 1;
}
return `${i > 0 && v < 10 ? v.toFixed(1) : Math.round(v)} ${units[i]}`;
}
/** Map a run's lifecycle status to an activity row's display status. */
function activityStatus(run: PolicyRunRecord): PolicyActivityItem["status"] {
if (run.status === "COMPLETED") return "enforced";
if (run.status === "FAILED" || run.status === "CANCELLED") return "flagged";
return "processing";
}
function activityAction(run: PolicyRunRecord): string {
switch (activityStatus(run)) {
case "enforced":
return `${formatBytes(run.fileSize)} • enforced`;
case "flagged":
return run.error ?? "Enforcement failed";
default:
return "Enforcing…";
}
}
/** Build the detail view's activity feed from a category's runs (newest first). */
export function runsToActivity(runs: PolicyRunRecord[]): PolicyActivityItem[] {
return runs.map((run) => ({
doc: run.fileName,
action: activityAction(run),
time: relativeTime(run.startedAt),
status: activityStatus(run),
runId: run.runId,
fileId: run.fileId,
}));
}
/** Build the summary stats from a category's runs. */
export function runsToStats(
runs: PolicyRunRecord[],
folderCreatedAt: string | undefined,
): PolicyStats {
const enforced = runs.filter((r) => r.status === "COMPLETED");
const bytes = enforced.reduce((sum, r) => sum + (r.fileSize ?? 0), 0);
return {
enforced: enforced.length,
dataProcessed: formatBytes(bytes),
activeFor: policyActiveFor(folderCreatedAt),
};
}
/**
* How long a policy has been active — from its backing folder's creation time
* (when it was enabled), or "Today" when there's no folder yet.
*/
export function policyActiveFor(folderCreatedAt: string | undefined): string {
if (!folderCreatedAt) return "Today";
return durationSince(new Date(folderCreatedAt).getTime());
}
@@ -0,0 +1,157 @@
import { describe, it, expect } from "vitest";
import {
buildPipelineDefinition,
buildBackendPolicy,
fromBackendPolicy,
} from "@app/services/policyPipeline";
import type { ToolRegistry } from "@app/data/toolsTaxonomy";
// Minimal registry: a static-endpoint tool and a function-endpoint tool.
const registry = {
compress: { operationConfig: { endpoint: "/api/v1/misc/compress-pdf" } },
rotate: {
operationConfig: {
endpoint: (p: Record<string, unknown>) =>
`/api/v1/general/rotate-pdf?angle=${p.angle}`,
},
},
} as unknown as Partial<ToolRegistry>;
describe("buildPipelineDefinition", () => {
it("maps frontend operations to backend endpoint steps", () => {
const { definition, unresolved } = buildPipelineDefinition(
{
name: "Secure Ingestion",
operations: [
{ operation: "compress", parameters: {} },
{ operation: "rotate", parameters: { angle: 90 } },
],
},
registry,
);
expect(unresolved).toEqual([]);
expect(definition.name).toBe("Secure Ingestion");
expect(definition.output).toEqual({ type: "inline", options: {} });
expect(definition.steps).toEqual([
{ operation: "/api/v1/misc/compress-pdf", parameters: {} },
{
operation: "/api/v1/general/rotate-pdf?angle=90",
parameters: { angle: 90 },
},
]);
});
it("drops + reports operations with no resolvable endpoint", () => {
const { definition, unresolved } = buildPipelineDefinition(
{
name: "X",
operations: [
{ operation: "compress", parameters: {} },
{ operation: "notARealTool", parameters: {} },
],
},
registry,
);
expect(unresolved).toEqual(["notARealTool"]);
expect(definition.steps).toHaveLength(1);
});
it("runs each tool's buildFormData so stored params match its endpoint", () => {
// A redact-like tool whose UI param (wordsToRedact[]) is transformed into
// the endpoint's field (listOfText) by buildFormData — the "marry up".
const redactish = {
redact: {
operationConfig: {
endpoint: () => "/api/v1/security/auto-redact",
buildFormData: (
p: { wordsToRedact?: string[]; useRegex?: boolean },
file: File,
) => {
const fd = new FormData();
fd.append("fileInput", file);
fd.append("listOfText", (p.wordsToRedact ?? []).join("\n"));
fd.append("useRegex", String(p.useRegex ?? false));
return fd;
},
},
},
} as unknown as Partial<ToolRegistry>;
const { definition } = buildPipelineDefinition(
{
name: "Security",
operations: [
{
operation: "redact",
parameters: { wordsToRedact: ["SSN", "Account"], useRegex: true },
},
],
},
redactish,
);
// The document field is dropped; the UI param became the endpoint field.
expect(definition.steps[0]).toEqual({
operation: "/api/v1/security/auto-redact",
parameters: { listOfText: "SSN\nAccount", useRegex: "true" },
});
});
});
const samplePolicy = {
categoryId: "security",
name: "Security",
enabled: true,
automation: {
id: "auto-1",
name: "Security",
operations: [{ operation: "compress", parameters: {} }],
createdAt: "",
updatedAt: "",
},
pipelineSteps: [{ operation: "/api/v1/misc/compress-pdf", parameters: {} }],
sources: ["editor"],
scopeTypes: ["Contracts"],
reviewerEmail: "[email protected]",
fieldValues: { minConfidence: "80%" },
folder: {
outputMode: "new_version" as const,
outputName: "secured",
outputNamePosition: "suffix" as const,
maxRetries: 2,
retryDelayMinutes: 10,
},
};
describe("buildBackendPolicy", () => {
it("maps a frontend policy to the backend Policy shape", () => {
const policy = buildBackendPolicy(samplePolicy);
expect(policy.id).toBe(""); // blank → backend assigns
expect(policy.name).toBe("Security");
expect(policy.enabled).toBe(true);
expect(policy.steps).toEqual([
{ operation: "/api/v1/misc/compress-pdf", parameters: {} },
]);
// Extras ride in options.
expect(policy.trigger.options.categoryId).toBe("security");
expect(policy.trigger.options.reviewerEmail).toBe("[email protected]");
expect(policy.output.options.maxRetries).toBe(2);
});
it("round-trips losslessly through fromBackendPolicy", () => {
const policy = buildBackendPolicy(samplePolicy);
const decoded = fromBackendPolicy({ ...policy, id: "p1" });
expect(decoded.id).toBe("p1");
expect(decoded.categoryId).toBe("security");
expect(decoded.enabled).toBe(true);
expect(decoded.sources).toEqual(["editor"]);
expect(decoded.scopeTypes).toEqual(["Contracts"]);
expect(decoded.reviewerEmail).toBe("[email protected]");
expect(decoded.fieldValues).toEqual({ minConfidence: "80%" });
expect(decoded.folder).toEqual(samplePolicy.folder);
expect(decoded.automation?.operations).toEqual(
samplePolicy.automation.operations,
);
});
});
@@ -0,0 +1,291 @@
/**
* Bridge between the frontend automation model and the backend Policies engine.
* "Backend automation" = send the whole pipeline + files to
* `/api/v1/policies/run` and let the server orchestrate the steps, instead of
* the browser running them one-by-one via executeAutomationSequence.
*
* The backend `PipelineStep.operation` is a tool endpoint *path*
* (e.g. `/api/v1/misc/compress-pdf`) — exactly the `operationConfig.endpoint`
* the frontend tool registry already carries for client-side execution. This
* module maps a frontend AutomationConfig to the backend's PipelineDefinition
* using that registry.
*/
import type { AutomationConfig } from "@app/types/automation";
import type { ToolRegistry } from "@app/data/toolsTaxonomy";
import type { PolicyFolderSettings } from "@app/types/policies";
/** A single backend pipeline step: a tool endpoint path + its scalar params. */
export interface BackendPipelineStep {
operation: string;
parameters: Record<string, unknown>;
fileParameters?: Record<string, string>;
}
/** Where the run's outputs are delivered. "inline" = return for download. */
export interface BackendOutputSpec {
type: string;
options: Record<string, unknown>;
}
/** The engine-level pipeline the `/run` endpoint accepts (as JSON). */
export interface BackendPipelineDefinition {
name: string;
steps: BackendPipelineStep[];
output: BackendOutputSpec;
}
/** How a stored policy is triggered ("manual" | "folder" | "schedule" | "s3"). */
export interface BackendTriggerConfig {
type: string;
options: Record<string, unknown>;
}
/** A stored, owned policy on the backend (mirrors the Java `Policy` record). */
export interface BackendPolicy {
/** Blank on create — the backend assigns an id and returns it. */
id: string;
name: string;
owner: string;
/** Gates automatic triggering; an explicit run ignores it. */
enabled: boolean;
trigger: BackendTriggerConfig;
steps: BackendPipelineStep[];
output: BackendOutputSpec;
}
/** Lifecycle states of a backend run (mirrors PolicyRunStatus). */
export type PolicyRunStatus =
| "PENDING"
| "RUNNING"
| "WAITING_FOR_INPUT"
| "COMPLETED"
| "FAILED"
| "CANCELLED";
export interface BackendResultFile {
fileId: string;
fileName: string;
}
/** Read-only view returned by the run status endpoint (mirrors PolicyRunView). */
export interface PolicyRunView {
runId: string;
status: PolicyRunStatus;
currentStep: number;
stepCount: number;
error: string | null;
outputs: BackendResultFile[];
}
/** Resolve a frontend operation id to its backend tool endpoint path. */
function resolveEndpoint(
operation: string,
parameters: Record<string, unknown>,
toolRegistry: Partial<ToolRegistry>,
): string | null {
const config = toolRegistry[operation as keyof ToolRegistry]?.operationConfig;
const endpoint = config?.endpoint;
if (!endpoint) return null;
const resolved =
typeof endpoint === "function" ? endpoint(parameters) : endpoint;
return resolved ?? null;
}
/**
* Convert a tool's UI parameters into the exact scalar form-fields its backend
* endpoint expects, by running the same `buildFormData` the client-side runner
* uses (the one source of truth for the request shape) and keeping its non-file
* fields. This is what makes the stored steps "marry up" with the engine: e.g.
* redact's `wordsToRedact: string[]` becomes the `listOfText` string the
* /auto-redact endpoint reads. Falls back to the raw params if the tool has no
* transform (or it throws), so tools without one are unaffected.
*/
function toApiParameters(
config: ToolRegistry[keyof ToolRegistry]["operationConfig"] | undefined,
parameters: Record<string, unknown>,
): Record<string, unknown> {
const build = config?.buildFormData;
if (typeof build !== "function") return parameters;
const dummy = new File([], "input.pdf", { type: "application/pdf" });
// buildFormData takes a File (single-file tools) or File[] (multi) — try both.
for (const fileArg of [dummy, [dummy]]) {
try {
const formData = build(parameters, fileArg as never);
const out: Record<string, unknown> = {};
// Keep scalar fields; skip File entries (the document(s) the engine feeds
// separately, and any supporting-file blobs).
formData.forEach((value, key) => {
if (typeof value === "string") out[key] = value;
});
return out;
} catch {
// Wrong file-arg shape for this tool — try the other, then give up.
}
}
return parameters;
}
/**
* Map a frontend automation to the backend pipeline definition. Steps whose
* endpoint can't be resolved from the registry are dropped (and reported), so
* the backend never receives an unrunnable operation id.
*/
export function buildPipelineDefinition(
automation: Pick<AutomationConfig, "name" | "operations">,
toolRegistry: Partial<ToolRegistry>,
): { definition: BackendPipelineDefinition; unresolved: string[] } {
const unresolved: string[] = [];
const steps: BackendPipelineStep[] = [];
for (const op of automation.operations) {
const parameters = (op.parameters ?? {}) as Record<string, unknown>;
const endpoint = resolveEndpoint(op.operation, parameters, toolRegistry);
if (!endpoint) {
unresolved.push(op.operation);
continue;
}
const config =
toolRegistry[op.operation as keyof ToolRegistry]?.operationConfig;
steps.push({
operation: endpoint,
parameters: toApiParameters(config, parameters),
});
}
return {
definition: {
name: automation.name,
steps,
output: { type: "inline", options: {} },
},
unresolved,
};
}
/** A frontend policy ready to persist on the backend (the full settings set). */
export interface PolicyToStore {
/** Existing backend id (blank/omitted → create). */
id?: string;
/** The frontend catalog category this policy belongs to (1 policy per category). */
categoryId: string;
name: string;
/** Active (enabled) vs paused/off. */
enabled: boolean;
/** Full frontend automation, stashed for a lossless UI round-trip. */
automation: AutomationConfig;
/**
* The engine-runnable steps (endpoint paths), pre-built from `automation` via
* the tool registry by the caller that has it (the wizard). The store layer
* has no registry, so it receives these ready-made.
*/
pipelineSteps: BackendPipelineStep[];
sources: string[];
scopeTypes: string[];
reviewerEmail: string;
fieldValues: Record<string, boolean | string | string[]>;
folder: PolicyFolderSettings;
}
/** The decoded policy read back from the backend. */
export interface DecodedPolicy {
id: string;
/** The catalog category this policy maps to (from trigger.options.categoryId). */
categoryId: string;
name: string;
enabled: boolean;
/** Null if the stored policy carried no automation blob. */
automation: AutomationConfig | null;
sources: string[];
scopeTypes: string[];
reviewerEmail: string;
fieldValues: Record<string, boolean | string | string[]>;
folder: PolicyFolderSettings;
}
const DEFAULT_FOLDER: PolicyFolderSettings = {
outputMode: "new_file",
outputName: "",
outputNamePosition: "prefix",
maxRetries: 3,
retryDelayMinutes: 5,
};
/**
* Map a frontend policy to the backend {@link BackendPolicy} for persistence.
* The backend models only name/enabled/trigger/steps/output, so the policy-level
* extras (categoryId, sources, scope, reviewer, fields) ride in `trigger.options`
* and the output + retry settings 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 {
id: input.id ?? "",
name: input.name,
owner: "",
enabled: input.enabled,
trigger: {
type: "folder",
options: {
categoryId: input.categoryId,
sources: input.sources,
scopeTypes: input.scopeTypes,
reviewerEmail: input.reviewerEmail,
fieldValues: input.fieldValues,
},
},
steps: input.pipelineSteps,
output: {
type: "inline",
options: {
mode: input.folder.outputMode,
name: input.folder.outputName,
position: input.folder.outputNamePosition,
maxRetries: input.folder.maxRetries,
retryDelayMinutes: input.folder.retryDelayMinutes,
automation: input.automation,
},
},
};
}
/** Decode a stored backend policy back into the frontend settings. */
export function fromBackendPolicy(policy: BackendPolicy): DecodedPolicy {
const trigger = policy.trigger.options;
const output = policy.output.options;
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(trigger.categoryId),
name: policy.name,
enabled: policy.enabled,
automation: (output.automation as AutomationConfig | undefined) ?? null,
sources: Array.isArray(trigger.sources)
? (trigger.sources as string[])
: [],
scopeTypes: Array.isArray(trigger.scopeTypes)
? (trigger.scopeTypes as string[])
: [],
reviewerEmail: str(trigger.reviewerEmail),
fieldValues:
(trigger.fieldValues as DecodedPolicy["fieldValues"] | undefined) ?? {},
folder: {
outputMode: output.mode === "new_version" ? "new_version" : "new_file",
outputName: str(output.name),
outputNamePosition:
output.position === "suffix"
? "suffix"
: output.position === "auto-number"
? "auto-number"
: "prefix",
maxRetries: num(output.maxRetries, DEFAULT_FOLDER.maxRetries),
retryDelayMinutes: num(
output.retryDelayMinutes,
DEFAULT_FOLDER.retryDelayMinutes,
),
},
};
}
@@ -0,0 +1,72 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import {
loadPolicies,
updatePolicy,
resetPolicy,
onPoliciesChange,
} from "@app/services/policyStorage";
describe("policyStorage", () => {
beforeEach(() => {
localStorage.clear();
});
it("defaults every category to unconfigured (backend is the source of truth)", () => {
const p = loadPolicies();
expect(p.ingestion.configured).toBe(false);
expect(p.ingestion.status).toBe("default");
expect(p.security.configured).toBe(false);
expect(p.security.status).toBe("default");
expect(p.retention.configured).toBe(false);
});
it("persists an update and reflects it on reload", () => {
updatePolicy("security", { configured: true, status: "active" });
const reloaded = loadPolicies();
expect(reloaded.security.configured).toBe(true);
expect(reloaded.security.status).toBe("active");
// Other categories untouched.
expect(reloaded.retention.configured).toBe(false);
});
it("merges partial field-value updates without clobbering siblings", () => {
updatePolicy("security", { fieldValues: { detectPII: false } });
updatePolicy("security", { reviewerEmail: "[email protected]" });
const p = loadPolicies();
expect(p.security.fieldValues).toEqual({ detectPII: false });
expect(p.security.reviewerEmail).toBe("[email protected]");
});
it("resetPolicy reverts a category to unconfigured default", () => {
updatePolicy("compliance", {
configured: true,
status: "active",
reviewerEmail: "[email protected]",
});
resetPolicy("compliance");
const p = loadPolicies();
expect(p.compliance.configured).toBe(false);
expect(p.compliance.status).toBe("default");
});
it("heals missing categories from corrupt/partial storage", () => {
localStorage.setItem(
"stirling-policies-state",
JSON.stringify({ ingestion: { configured: true, status: "active" } }),
);
const p = loadPolicies();
// Missing category gets a default rather than being undefined.
expect(p.routing).toBeDefined();
expect(p.routing.configured).toBe(false);
});
it("fires a change event on update", () => {
const cb = vi.fn();
const off = onPoliciesChange(cb);
updatePolicy("routing", { status: "paused" });
expect(cb).toHaveBeenCalledTimes(1);
off();
updatePolicy("routing", { status: "active" });
expect(cb).toHaveBeenCalledTimes(1); // not called after unsubscribe
});
});
@@ -0,0 +1,111 @@
/**
* Local cache + offline fallback for Policies — the backend (/api/v1/policies)
* is the source of truth. Holds per-category state (configured/active/paused,
* sources, scope, reviewer, field overrides) and broadcasts changes so hooks
* re-render. Mirrors the change-event pattern of the automation/folder stores.
*/
import { loadPolicyCatalog } from "@app/services/policyCatalog";
import type { PoliciesByCategory, PolicyState } from "@app/types/policies";
const STORAGE_KEY = "stirling-policies-state";
export const POLICIES_CHANGE_EVENT = "stirling:policies-changed";
function defaultState(): PolicyState {
// Unconfigured by default. The backend is the source of truth for what's
// actually configured + active; this is just the empty local-cache shape.
return {
configured: false,
status: "default",
sources: ["editor"],
scopeTypes: [],
// Empty by default; the wizard defaults the reviewer to the signed-in user.
reviewerEmail: "",
fieldValues: {},
// Every catalog category is a shipped, built-in policy → default (not
// deletable). User-created policies (later) will set this false.
isDefault: true,
};
}
/** An obsolete reviewer email from earlier development, scrubbed from persisted
* state on read so it can re-default to the real signed-in user. */
const STALE_REVIEWER_EMAIL = "[email protected]";
/** Read the full policy state, seeding + healing any missing categories. */
export function loadPolicies(): PoliciesByCategory {
let parsed: Partial<PoliciesByCategory> = {};
try {
const raw =
typeof localStorage !== "undefined"
? localStorage.getItem(STORAGE_KEY)
: null;
if (raw) parsed = JSON.parse(raw) as Partial<PoliciesByCategory>;
} catch {
// Corrupt/unavailable storage — fall back to seed.
}
// Always reconcile against the current category list so a newly-added
// category gets a default rather than being undefined.
const out: PoliciesByCategory = {};
for (const cat of loadPolicyCatalog().categories) {
const merged = { ...defaultState(), ...(parsed[cat.id] ?? {}) };
// Migration: clear the obsolete persisted reviewer email so it re-defaults
// to the real signed-in user.
if (merged.reviewerEmail === STALE_REVIEWER_EMAIL)
merged.reviewerEmail = "";
out[cat.id] = merged;
}
return out;
}
function persist(state: PoliciesByCategory): void {
try {
if (typeof localStorage !== "undefined") {
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
}
} catch {
// Best-effort; ignore quota/availability failures.
}
if (typeof window !== "undefined") {
window.dispatchEvent(new CustomEvent(POLICIES_CHANGE_EVENT));
}
}
/** Merge a partial update into one category's state and persist. */
export function updatePolicy(
categoryId: string,
patch: Partial<PolicyState>,
): PoliciesByCategory {
const current = loadPolicies();
const next: PoliciesByCategory = {
...current,
// Fall back to defaults so a not-yet-seeded category id still yields a
// complete PolicyState rather than a partial.
[categoryId]: {
...defaultState(),
...current[categoryId],
...patch,
},
};
persist(next);
return next;
}
/** Reset a category to its unconfigured default (the "Delete policy" action). */
export function resetPolicy(categoryId: string): PoliciesByCategory {
return updatePolicy(categoryId, {
...defaultState(),
configured: false,
status: "default",
// Drop the backing-folder + backend links (the caller deletes those).
folderId: undefined,
backendId: undefined,
});
}
/** Subscribe to policy-state changes (same-tab). Returns an unsubscribe fn. */
export function onPoliciesChange(cb: () => void): () => void {
if (typeof window === "undefined") return () => {};
window.addEventListener(POLICIES_CHANGE_EVENT, cb);
return () => window.removeEventListener(POLICIES_CHANGE_EVENT, cb);
}