mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-17 11:45:05 +02:00
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:
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user