fix: preserve workspace evidence in browser drafts

This commit is contained in:
2026-08-09 20:27:20 +02:00
parent ba7596c2dd
commit a580c4ca8a
6 changed files with 637 additions and 12 deletions
+138 -1
View File
@@ -1,7 +1,10 @@
import { expect, test } from "vitest"; import { expect, test } from "vitest";
import { http, HttpResponse } from "msw"; import { http, HttpResponse } from "msw";
import { server } from "../test/msw"; import { server } from "../test/msw";
import { asWorkspaceConflict, importWorkspace, publishWorkspace, type CanonicalWorkspace } from "./workspaces"; import {
asWorkspaceConflict, getWorkspace, importWorkspace, publishWorkspace, validateWorkspace,
type CanonicalWorkspace,
} from "./workspaces";
const workspace: CanonicalWorkspace = { const workspace: CanonicalWorkspace = {
workspace: { schema_version: 3, id: "psd-clinical", name: "PSD Clinical", language: "en" }, workspace: { schema_version: 3, id: "psd-clinical", name: "PSD Clinical", language: "en" },
@@ -13,6 +16,27 @@ const workspace: CanonicalWorkspace = {
llm_policy: { allowed: ["zai/glm-5.2"] }, llm_policy: { allowed: ["zai/glm-5.2"] },
}; };
const evidenceWorkspace = {
...workspace,
evidence: {
source: {
type: "filesystem",
uri: "workspace-content/psd-clinical/evidence",
patterns: ["**/*.md"],
max_bytes: 10 * 1024 * 1024,
},
policy: { max_chunk_chars: 4_000, retain_published_generations: 3 },
},
} satisfies CanonicalWorkspace;
const revision = {
id: "psd-clinical",
commit: "a".repeat(40),
blob: "b".repeat(40),
snapshotPath: "workspaces/psd-clinical.yaml",
state: "operational" as const,
};
test("uploads a workspace bundle without JSON content type", async () => { test("uploads a workspace bundle without JSON content type", async () => {
let contentType: string | null = null; let contentType: string | null = null;
server.use(http.post("/api/workspaces/import", ({ request }) => { server.use(http.post("/api/workspaces/import", ({ request }) => {
@@ -107,3 +131,116 @@ test("rejects a conflict payload that attempts to surface removed vector transpo
expect(asWorkspaceConflict(error)).toBeUndefined(); expect(asWorkspaceConflict(error)).toBeUndefined();
}); });
test("sanitizes read and validate responses while preserving Evidence", async () => {
server.use(
http.get("/api/workspaces/psd-clinical", () => HttpResponse.json({ workspace: evidenceWorkspace, revision })),
http.post("/api/workspaces/validate", () => HttpResponse.json({ workspace: evidenceWorkspace, contract: {} })),
);
const read = await getWorkspace("psd-clinical");
const validated = await validateWorkspace(evidenceWorkspace);
expect(read.workspace.evidence).toEqual(evidenceWorkspace.evidence);
expect(read.workspace).not.toBe(evidenceWorkspace);
expect(validated.workspace.evidence).toEqual(evidenceWorkspace.evidence);
});
test("rejects malformed workspace API responses instead of exposing unknown Evidence fields", async () => {
const malformed = {
...evidenceWorkspace,
evidence: { ...evidenceWorkspace.evidence, signed_urls_file: "/run/secrets/urls" },
};
server.use(
http.get("/api/workspaces/psd-clinical", () => HttpResponse.json({ workspace: malformed, revision })),
http.post("/api/workspaces/validate", () => HttpResponse.json({ workspace: malformed, contract: {} })),
);
await expect(getWorkspace("psd-clinical")).rejects.toThrow();
await expect(validateWorkspace(evidenceWorkspace)).rejects.toThrow();
});
test("publishes Evidence without mutating or dropping it from the request", async () => {
let sent: unknown;
server.use(http.post("/api/workspaces/publish", async ({ request }) => {
sent = await request.json();
return HttpResponse.json({ revision });
}));
await publishWorkspace({
action: "update", workspace: evidenceWorkspace,
baseCommit: revision.commit, baseBlob: revision.blob,
});
expect(sent).toMatchObject({ workspace: { evidence: evidenceWorkspace.evidence } });
expect(evidenceWorkspace.evidence.source.patterns).toEqual(["**/*.md"]);
});
const evidenceConflictFields = [
"evidence",
"evidence.source",
"evidence.source.type",
"evidence.source.uri",
"evidence.source.patterns",
"evidence.source.max_bytes",
"evidence.source.uris",
"evidence.source.authentication",
"evidence.source.connect_timeout_ms",
"evidence.source.read_timeout_ms",
"evidence.source.max_redirects",
"evidence.source.allow_private_hosts",
"evidence.source.max_cache_bytes",
"evidence.source.endpoint_url",
"evidence.source.region",
"evidence.source.credentials",
"evidence.source.trusted_endpoint",
"evidence.source.allow_private_endpoint",
"evidence.source.allow_insecure_endpoint",
"evidence.source.max_objects",
"evidence.source.max_pages",
"evidence.source.page_size",
"evidence.policy",
"evidence.policy.max_chunk_chars",
"evidence.policy.retain_published_generations",
] as const;
test.each(evidenceConflictFields)("accepts canonical Evidence conflict field %s", async (field) => {
server.use(http.post("/api/workspaces/publish", () => HttpResponse.json({
code: "workspace_conflict",
message: "Workspace changed in the registry.",
fields: [field],
expected: { commit: "a".repeat(40), blob: "b".repeat(40) },
actual: { commit: "c".repeat(40), blob: "d".repeat(40) },
base: evidenceWorkspace,
local: evidenceWorkspace,
remote: evidenceWorkspace,
}, { status: 409 })));
const error = await publishWorkspace({
action: "update", workspace: evidenceWorkspace,
baseCommit: "a".repeat(40), baseBlob: "b".repeat(40),
}).catch((cause: unknown) => cause);
expect(asWorkspaceConflict(error)).toMatchObject({ fields: [field] });
});
test("rejects unknown Evidence conflict paths", async () => {
server.use(http.post("/api/workspaces/publish", () => HttpResponse.json({
code: "workspace_conflict",
message: "Workspace changed in the registry.",
fields: ["evidence.source.signed_url"],
expected: { commit: "a".repeat(40), blob: "b".repeat(40) },
actual: { commit: "c".repeat(40), blob: "d".repeat(40) },
base: evidenceWorkspace,
local: evidenceWorkspace,
remote: evidenceWorkspace,
}, { status: 409 })));
const error = await publishWorkspace({
action: "update", workspace: evidenceWorkspace,
baseCommit: "a".repeat(40), baseBlob: "b".repeat(40),
}).catch((cause: unknown) => cause);
expect(asWorkspaceConflict(error)).toBeUndefined();
});
+84 -7
View File
@@ -17,6 +17,49 @@ export interface CanonicalDiagnostics {
dwh_rest?: RestDiagnosticRequest & { response: { database: string; schema: string } }; dwh_rest?: RestDiagnosticRequest & { response: { database: string; schema: string } };
} }
export interface EvidencePolicy {
max_chunk_chars: number;
retain_published_generations: number;
}
export type EvidenceSource =
| {
type: "filesystem";
uri: string;
patterns: string[];
max_bytes: number;
}
| {
type: "http";
uris: string[];
authentication: "none" | "signed_urls_file";
connect_timeout_ms: number;
read_timeout_ms: number;
max_bytes: number;
max_redirects: number;
allow_private_hosts: boolean;
max_cache_bytes: number;
}
| {
type: "s3";
uri: string;
endpoint_url?: string;
region?: string;
credentials: "ambient" | "static_files";
trusted_endpoint: boolean;
allow_private_endpoint: boolean;
allow_insecure_endpoint: boolean;
max_bytes: number;
max_objects: number;
max_pages: number;
page_size: number;
};
export interface WorkspaceEvidence {
source: EvidenceSource;
policy: EvidencePolicy;
}
export interface CanonicalWorkspace { export interface CanonicalWorkspace {
workspace: { workspace: {
schema_version: 3; schema_version: 3;
@@ -48,6 +91,7 @@ export interface CanonicalWorkspace {
}; };
llm_policy: { default?: `${string}/${string}`; allowed: `${string}/${string}`[] }; llm_policy: { default?: `${string}/${string}`; allowed: `${string}/${string}`[] };
diagnostics?: CanonicalDiagnostics; diagnostics?: CanonicalDiagnostics;
evidence?: WorkspaceEvidence;
} }
export interface WorkspaceRevision { export interface WorkspaceRevision {
@@ -132,6 +176,16 @@ const conflictFields = new Set([
"diagnostics", "diagnostics.dwh_rest", "diagnostics", "diagnostics.dwh_rest",
"diagnostics.dwh_rest.method", "diagnostics.dwh_rest.path", "diagnostics.dwh_rest.auth", "diagnostics.dwh_rest.method", "diagnostics.dwh_rest.path", "diagnostics.dwh_rest.auth",
"diagnostics.dwh_rest.response.database", "diagnostics.dwh_rest.response.schema", "diagnostics.dwh_rest.response.database", "diagnostics.dwh_rest.response.schema",
"evidence", "evidence.source", "evidence.source.type", "evidence.source.uri",
"evidence.source.patterns", "evidence.source.max_bytes", "evidence.source.uris",
"evidence.source.authentication", "evidence.source.connect_timeout_ms",
"evidence.source.read_timeout_ms", "evidence.source.max_redirects",
"evidence.source.allow_private_hosts", "evidence.source.max_cache_bytes",
"evidence.source.endpoint_url", "evidence.source.region", "evidence.source.credentials",
"evidence.source.trusted_endpoint", "evidence.source.allow_private_endpoint",
"evidence.source.allow_insecure_endpoint", "evidence.source.max_objects",
"evidence.source.max_pages", "evidence.source.page_size", "evidence.policy",
"evidence.policy.max_chunk_chars", "evidence.policy.retain_published_generations",
]); ]);
const workspaceErrorCodes = new Set<WorkspaceErrorCode>([ const workspaceErrorCodes = new Set<WorkspaceErrorCode>([
@@ -193,20 +247,43 @@ export function asWorkspaceConflict(error: unknown): WorkspaceConflict | undefin
}; };
} }
function requireCanonicalWorkspace(value: unknown): CanonicalWorkspace {
const workspace = sanitizeCanonicalWorkspace(value);
if (!workspace) throw new Error("Workspace API returned an invalid canonical workspace");
return workspace;
}
export const listWorkspaces = () => apiFetch<WorkspaceSummary[]>("/workspaces"); export const listWorkspaces = () => apiFetch<WorkspaceSummary[]>("/workspaces");
export const getWorkspace = (id: string) => apiFetch<WorkspaceRecord>(`/workspaces/${encodeURIComponent(id)}`); export const getWorkspace = async (id: string): Promise<WorkspaceRecord> => {
const response = await apiFetch<unknown>(`/workspaces/${encodeURIComponent(id)}`);
const source = object(response);
if (!source) throw new Error("Workspace API returned an invalid workspace record");
return {
workspace: requireCanonicalWorkspace(source.workspace),
revision: source.revision as WorkspaceRevision,
};
};
export const getWorkspaceRegistryStatus = () => apiFetch<WorkspaceRegistryStatus>("/workspace-registry/status"); export const getWorkspaceRegistryStatus = () => apiFetch<WorkspaceRegistryStatus>("/workspace-registry/status");
export const pullWorkspaceRegistry = () => apiFetch<WorkspaceRegistryStatus>("/workspace-registry/pull", { method: "POST" }); export const pullWorkspaceRegistry = () => apiFetch<WorkspaceRegistryStatus>("/workspace-registry/pull", { method: "POST" });
export const validateWorkspace = (workspace: CanonicalWorkspace) => export const validateWorkspace = async (workspace: CanonicalWorkspace) => {
apiFetch<{ workspace: CanonicalWorkspace; contract: unknown }>("/workspaces/validate", { const safe = requireCanonicalWorkspace(workspace);
method: "POST", body: JSON.stringify({ workspace }), const response = await apiFetch<unknown>("/workspaces/validate", {
method: "POST", body: JSON.stringify({ workspace: safe }),
}); });
const source = object(response);
if (!source) throw new Error("Workspace API returned an invalid validation result");
return { workspace: requireCanonicalWorkspace(source.workspace), contract: source.contract };
};
export const testWorkspace = (id: string) => export const testWorkspace = (id: string) =>
apiFetch<WorkspaceDiagnostics>(`/workspaces/${encodeURIComponent(id)}/test`, { method: "POST" }); apiFetch<WorkspaceDiagnostics>(`/workspaces/${encodeURIComponent(id)}/test`, { method: "POST" });
export const publishWorkspace = (request: PublishWorkspaceRequest) => export const publishWorkspace = (request: PublishWorkspaceRequest) => {
apiFetch<{ revision: WorkspaceRevision } | undefined>("/workspaces/publish", { const safeRequest: PublishWorkspaceRequest = request.action === "delete"
method: "POST", body: JSON.stringify(request), ? request
: { ...request, workspace: requireCanonicalWorkspace(request.workspace) };
return apiFetch<{ revision: WorkspaceRevision } | undefined>("/workspaces/publish", {
method: "POST", body: JSON.stringify(safeRequest),
}); });
};
export const exportWorkspace = (id: string) => export const exportWorkspace = (id: string) =>
apiFetchBlob(`/workspaces/${encodeURIComponent(id)}/export`); apiFetchBlob(`/workspaces/${encodeURIComponent(id)}/export`);
export const importWorkspace = (bundle: File) => { export const importWorkspace = (bundle: File) => {
@@ -20,6 +20,19 @@ const workspace: CanonicalWorkspace = {
llm_policy: { default: "zai/glm-5.2", allowed: ["zai/glm-5.2"] }, llm_policy: { default: "zai/glm-5.2", allowed: ["zai/glm-5.2"] },
}; };
const evidenceWorkspace: CanonicalWorkspace = {
...workspace,
evidence: {
source: {
type: "filesystem",
uri: "workspace-content/psd-clinical/evidence",
patterns: ["documents/**/*.pdf"],
max_bytes: 12_000_000,
},
policy: { max_chunk_chars: 8_000, retain_published_generations: 5 },
},
};
const draft: WorkspaceDraft = { const draft: WorkspaceDraft = {
workspaceId: "psd-clinical", workspaceId: "psd-clinical",
baseCommit: "a".repeat(40), baseCommit: "a".repeat(40),
@@ -99,3 +112,50 @@ test("rejects a non-positive DWH timeout without saving a draft", async () => {
expect(screen.getByLabelText("DWH timeout (ms)")).toHaveAttribute("aria-invalid", "true"); expect(screen.getByLabelText("DWH timeout (ms)")).toHaveAttribute("aria-invalid", "true");
expect(onSaveDraft).not.toHaveBeenCalled(); expect(onSaveDraft).not.toHaveBeenCalled();
}); });
test("shows a safe read-only Evidence summary without authoring or secret binding controls", () => {
render(<WorkspaceEditor draft={{ ...draft, workspace: evidenceWorkspace }} onSaveDraft={vi.fn()} onPublish={vi.fn()} />);
const summary = screen.getByRole("region", { name: "Evidence" });
expect(summary).toHaveTextContent("filesystem");
expect(summary).toHaveTextContent("workspace-content/psd-clinical/evidence");
expect(summary).toHaveTextContent("8,000");
expect(summary).toHaveTextContent("5");
expect(summary).toHaveTextContent("Evidence is managed by the registry descriptor in P1.");
expect(summary).not.toHaveTextContent(/signed_urls_file|static_files|secret|binding/i);
expect(screen.queryByLabelText(/evidence.*(source|uri|pattern|credential)/i)).not.toBeInTheDocument();
});
test("publishes an edited DWH and LLM field without dropping or mutating Evidence", async () => {
const user = userEvent.setup();
const onPublish = vi.fn().mockResolvedValue(undefined);
render(<WorkspaceEditor draft={{ ...draft, workspace: evidenceWorkspace }} onSaveDraft={vi.fn()} onPublish={onPublish} />);
await user.clear(screen.getByLabelText("DWH database"));
await user.type(screen.getByLabelText("DWH database"), "research");
await user.clear(screen.getByLabelText("Allowed models"));
await user.type(screen.getByLabelText("Allowed models"), "openai/gpt-5");
await user.click(screen.getByRole("button", { name: "Publish draft" }));
expect(onPublish).toHaveBeenCalledWith(expect.objectContaining({
action: "update",
workspace: expect.objectContaining({
dwh: expect.objectContaining({ database: "research" }),
llm_policy: { allowed: ["openai/gpt-5"] },
evidence: evidenceWorkspace.evidence,
}),
}));
expect(evidenceWorkspace.evidence?.source).toEqual({
type: "filesystem",
uri: "workspace-content/psd-clinical/evidence",
patterns: ["documents/**/*.pdf"],
max_bytes: 12_000_000,
});
});
test("does not show an Evidence summary for a workspace without Evidence", () => {
render(<WorkspaceEditor draft={draft} onSaveDraft={vi.fn()} onPublish={vi.fn()} />);
expect(screen.queryByRole("region", { name: "Evidence" })).not.toBeInTheDocument();
});
+19
View File
@@ -108,6 +108,23 @@ function Section({ title, children }: { title: string; children: React.ReactNode
); );
} }
function EvidenceSummary({ evidence }: { evidence: NonNullable<CanonicalWorkspace["evidence"]> }) {
const sourceIdentity = evidence.source.type === "http"
? `${evidence.source.uris.length} canonical URI${evidence.source.uris.length === 1 ? "" : "s"}`
: evidence.source.uri;
return (
<Section title="Evidence">
<dl className="grid gap-2 text-sm sm:col-span-2 sm:grid-cols-2">
<div><dt className="font-medium text-foreground">Source type</dt><dd className="text-muted-foreground">{evidence.source.type}</dd></div>
<div><dt className="font-medium text-foreground">Source</dt><dd className="break-all text-muted-foreground">{sourceIdentity}</dd></div>
<div><dt className="font-medium text-foreground">Chunk size</dt><dd className="text-muted-foreground">{evidence.policy.max_chunk_chars.toLocaleString("en-US")} characters</dd></div>
<div><dt className="font-medium text-foreground">Retention</dt><dd className="text-muted-foreground">{evidence.policy.retain_published_generations.toLocaleString("en-US")} published generations</dd></div>
</dl>
<p className="sm:col-span-2 text-sm text-muted-foreground">Evidence is managed by the registry descriptor in P1.</p>
</Section>
);
}
const fieldClass = "h-9 w-full rounded-md border border-input bg-background px-2.5 text-sm shadow-xs outline-none focus-visible:ring-3 focus-visible:ring-ring/25 aria-invalid:border-destructive"; const fieldClass = "h-9 w-full rounded-md border border-input bg-background px-2.5 text-sm shadow-xs outline-none focus-visible:ring-3 focus-visible:ring-ring/25 aria-invalid:border-destructive";
export function WorkspaceEditor({ draft, onSaveDraft, onPublish, idLocked = Boolean(draft?.baseBlob) }: WorkspaceEditorProps) { export function WorkspaceEditor({ draft, onSaveDraft, onPublish, idLocked = Boolean(draft?.baseBlob) }: WorkspaceEditorProps) {
@@ -220,6 +237,8 @@ export function WorkspaceEditor({ draft, onSaveDraft, onPublish, idLocked = Bool
</Field> </Field>
</Section> </Section>
{workspace.evidence && <EvidenceSummary evidence={workspace.evidence} />}
<Section title="Installation requirements"> <Section title="Installation requirements">
<p className="sm:col-span-2 text-sm text-muted-foreground">Hosts, users, secret-file paths, and credentials are installation bindings. They are intentionally not editable or stored in workspace drafts.</p> <p className="sm:col-span-2 text-sm text-muted-foreground">Hosts, users, secret-file paths, and credentials are installation bindings. They are intentionally not editable or stored in workspace drafts.</p>
</Section> </Section>
+136 -1
View File
@@ -1,6 +1,8 @@
import { beforeEach, expect, test } from "vitest"; import { beforeEach, expect, test } from "vitest";
import type { CanonicalWorkspace } from "../api/workspaces"; import type { CanonicalWorkspace } from "../api/workspaces";
import { workspaceDeletionDrafts, workspaceDrafts, workspacePreferences } from "./drafts"; import {
sanitizeCanonicalWorkspace, workspaceDeletionDrafts, workspaceDrafts, workspacePreferences,
} from "./drafts";
const workspace: CanonicalWorkspace = { const workspace: CanonicalWorkspace = {
workspace: { schema_version: 3, id: "psd-clinical", name: "PSD Clinical", language: "en" }, workspace: { schema_version: 3, id: "psd-clinical", name: "PSD Clinical", language: "en" },
@@ -17,6 +19,69 @@ const workspace: CanonicalWorkspace = {
llm_policy: { allowed: ["zai/glm-5.2"] }, llm_policy: { allowed: ["zai/glm-5.2"] },
}; };
const policy = { max_chunk_chars: 8_000, retain_published_generations: 5 };
const evidenceWorkspaces = [
{
name: "filesystem",
workspace: {
...workspace,
evidence: {
source: {
type: "filesystem",
uri: "workspace-content/psd-clinical/evidence",
patterns: ["documents/**/*.pdf", "notes/*.md"],
max_bytes: 12_000_000,
},
policy,
},
} satisfies CanonicalWorkspace,
},
{
name: "http",
workspace: {
...workspace,
evidence: {
source: {
type: "http",
uris: ["https://evidence.example/manifest.json", "http://evidence.example/files/list.txt"],
authentication: "signed_urls_file",
connect_timeout_ms: 2_000,
read_timeout_ms: 20_000,
max_bytes: 12_000_000,
max_redirects: 2,
allow_private_hosts: false,
max_cache_bytes: 24_000_000,
},
policy,
},
} satisfies CanonicalWorkspace,
},
{
name: "s3",
workspace: {
...workspace,
evidence: {
source: {
type: "s3",
uri: "s3://clinical-evidence/published/",
endpoint_url: "https://objects.example",
region: "eu-west-1",
credentials: "static_files",
trusted_endpoint: true,
allow_private_endpoint: false,
allow_insecure_endpoint: false,
max_bytes: 12_000_000,
max_objects: 2_000,
max_pages: 20,
page_size: 100,
},
policy,
},
} satisfies CanonicalWorkspace,
},
] as const;
test("keeps an anonymous user's model selection in browser storage", () => { test("keeps an anonymous user's model selection in browser storage", () => {
workspacePreferences.save({ workspacePreferences.save({
workspaceId: "psd-clinical", provider: "zai", model: "glm-5.2", thinking: "medium", workspaceId: "psd-clinical", provider: "zai", model: "glm-5.2", thinking: "medium",
@@ -146,3 +211,73 @@ test("rejects a draft that tries to persist removed external semantic configurat
expect(workspaceDrafts.load("psd-clinical")).toBeUndefined(); expect(workspaceDrafts.load("psd-clinical")).toBeUndefined();
expect(localStorage.getItem("thothii.workspace-registry.v1.draft.psd-clinical")).toBeNull(); expect(localStorage.getItem("thothii.workspace-registry.v1.draft.psd-clinical")).toBeNull();
}); });
test.each(evidenceWorkspaces)("deep-sanitizes canonical $name Evidence", ({ workspace: configured }) => {
const sanitized = sanitizeCanonicalWorkspace(configured);
expect(sanitized).toEqual(configured);
expect(sanitized).not.toBe(configured);
expect(sanitized?.evidence).not.toBe(configured.evidence);
expect(sanitized?.evidence?.source).not.toBe(configured.evidence.source);
expect(sanitized?.evidence?.policy).not.toBe(configured.evidence.policy);
});
test.each(evidenceWorkspaces)("saves and reloads canonical $name Evidence", ({ workspace: configured }) => {
workspaceDrafts.save({
workspaceId: "psd-clinical",
baseCommit: "a".repeat(40),
workspace: configured,
updatedAt: "2026-08-04T10:00:00.000Z",
});
expect(workspaceDrafts.load("psd-clinical")?.workspace.evidence).toEqual(configured.evidence);
});
test.each([
["an unknown Evidence key", { ...evidenceWorkspaces[0].workspace.evidence, extra: "unexpected" }],
["a secret-shaped source key", {
...evidenceWorkspaces[1].workspace.evidence,
source: { ...evidenceWorkspaces[1].workspace.evidence.source, signed_urls_file: "/run/secrets/urls" },
}],
["an HTTP URI with credentials", {
...evidenceWorkspaces[1].workspace.evidence,
source: { ...evidenceWorkspaces[1].workspace.evidence.source, uris: ["https://user:secret@evidence.example/file"] },
}],
["an HTTP URI with a signed query", {
...evidenceWorkspaces[1].workspace.evidence,
source: { ...evidenceWorkspaces[1].workspace.evidence.source, uris: ["https://evidence.example/file?token=secret"] },
}],
["an unsafe S3 URI", {
...evidenceWorkspaces[2].workspace.evidence,
source: { ...evidenceWorkspaces[2].workspace.evidence.source, uri: "s3://user:secret@clinical-evidence/published/" },
}],
["an invalid zero policy value", {
...evidenceWorkspaces[0].workspace.evidence,
policy: { ...policy, max_chunk_chars: 0 },
}],
["an unsafe integer policy value", {
...evidenceWorkspaces[0].workspace.evidence,
policy: { ...policy, retain_published_generations: Number.MAX_SAFE_INTEGER + 1 },
}],
["a malformed source union", {
...evidenceWorkspaces[0].workspace.evidence,
source: { ...evidenceWorkspaces[0].workspace.evidence.source, uris: ["https://evidence.example/file"] },
}],
])("rejects %s instead of putting it in browser state", (_reason, evidence) => {
const invalid = { ...workspace, evidence };
expect(sanitizeCanonicalWorkspace(invalid)).toBeUndefined();
workspaceDrafts.save({
workspaceId: "psd-clinical",
baseCommit: "a".repeat(40),
workspace: invalid as CanonicalWorkspace,
updatedAt: "2026-08-04T10:00:00.000Z",
});
expect(workspaceDrafts.load("psd-clinical")).toBeUndefined();
});
test("continues to sanitize workspaces without Evidence", () => {
expect(sanitizeCanonicalWorkspace(workspace)).toEqual(workspace);
expect(sanitizeCanonicalWorkspace(workspace)).not.toHaveProperty("evidence");
});
+200 -3
View File
@@ -1,4 +1,7 @@
import type { CanonicalDiagnostics, CanonicalWorkspace, RestDiagnosticRequest } from "../api/workspaces"; import type {
CanonicalDiagnostics, CanonicalWorkspace, EvidencePolicy, EvidenceSource,
RestDiagnosticRequest, WorkspaceEvidence,
} from "../api/workspaces";
export { workspacePreferences, type WorkspacePreference } from "./preferences"; export { workspacePreferences, type WorkspacePreference } from "./preferences";
export interface WorkspaceDraft { export interface WorkspaceDraft {
@@ -156,7 +159,196 @@ function modelReference(value: unknown): `${string}/${string}` | undefined {
} }
function positiveInteger(value: unknown, max = Number.MAX_SAFE_INTEGER): number | undefined { function positiveInteger(value: unknown, max = Number.MAX_SAFE_INTEGER): number | undefined {
return typeof value === "number" && Number.isInteger(value) && value > 0 && value <= max ? value : undefined; return typeof value === "number" && Number.isSafeInteger(value) && value > 0 && value <= max ? value : undefined;
}
function nonnegativeInteger(value: unknown): number | undefined {
return typeof value === "number" && Number.isSafeInteger(value) && value >= 0 ? value : undefined;
}
function isSafeEvidencePattern(value: string): boolean {
const parts = value.split("/");
return value.length > 0
&& !value.startsWith("/")
&& !value.includes("\\")
&& !/[\u0000-\u001f\u007f]/u.test(value)
&& parts.every((part) => part !== "" && part !== "." && part !== "..");
}
function parsePublicHttpUri(value: string): URL | undefined {
if (value.trim() !== value || /[\u0000-\u001f\u007f\\]/u.test(value)) return undefined;
try {
const parsed = new URL(value);
if (
!["http:", "https:"].includes(parsed.protocol)
|| parsed.hostname.length === 0
|| parsed.username !== ""
|| parsed.password !== ""
|| parsed.search !== ""
|| parsed.hash !== ""
) return undefined;
return parsed;
} catch {
return undefined;
}
}
function isSafeS3Uri(value: string): boolean {
if (value.trim() !== value || /[\u0000-\u001f\u007f\\]/u.test(value)) return false;
try {
const parsed = new URL(value);
const bucket = parsed.hostname;
const validBucket = /^(?=.{3,63}$)(?!-)(?!.*\.\.)(?!.*\.-)(?!.*-\.)[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$/.test(bucket)
&& !/^\d{1,3}(?:\.\d{1,3}){3}$/.test(bucket);
return parsed.protocol === "s3:"
&& validBucket
&& parsed.port === ""
&& parsed.username === ""
&& parsed.password === ""
&& parsed.search === ""
&& parsed.hash === ""
&& parsed.href === value;
} catch {
return false;
}
}
function isSafeS3Endpoint(value: string): boolean {
const parsed = parsePublicHttpUri(value);
return parsed !== undefined && (parsed.pathname === "/" || parsed.pathname === "");
}
function copyEvidencePolicy(value: unknown): EvidencePolicy | undefined {
const source = exactRecord(value, ["max_chunk_chars", "retain_published_generations"]);
const maxChunkChars = positiveInteger(source?.max_chunk_chars);
const retainedGenerations = positiveInteger(source?.retain_published_generations);
return source && maxChunkChars && retainedGenerations
? { max_chunk_chars: maxChunkChars, retain_published_generations: retainedGenerations }
: undefined;
}
function copyFilesystemEvidence(value: unknown, id: string): EvidenceSource | undefined {
const source = exactRecord(value, ["type", "uri", "patterns", "max_bytes"]);
const uri = typeof source?.uri === "string" ? source.uri : undefined;
const patterns = source?.patterns;
const maxBytes = positiveInteger(source?.max_bytes);
if (
source?.type !== "filesystem"
|| uri !== `workspace-content/${id}/evidence`
|| !Array.isArray(patterns)
|| patterns.length === 0
|| !patterns.every((pattern) => typeof pattern === "string" && isSafeEvidencePattern(pattern))
|| new Set(patterns).size !== patterns.length
|| !maxBytes
) return undefined;
return { type: "filesystem", uri, patterns: [...patterns] as string[], max_bytes: maxBytes };
}
function copyHttpEvidence(value: unknown): EvidenceSource | undefined {
const source = exactRecord(value, [
"type", "uris", "authentication", "connect_timeout_ms", "read_timeout_ms", "max_bytes",
"max_redirects", "allow_private_hosts", "max_cache_bytes",
]);
const uris = source?.uris;
const authentication = oneOf(source?.authentication, ["none", "signed_urls_file"] as const);
const connectTimeout = positiveInteger(source?.connect_timeout_ms);
const readTimeout = positiveInteger(source?.read_timeout_ms);
const maxBytes = positiveInteger(source?.max_bytes);
const maxRedirects = nonnegativeInteger(source?.max_redirects);
const maxCacheBytes = positiveInteger(source?.max_cache_bytes);
if (
source?.type !== "http"
|| !Array.isArray(uris)
|| uris.length === 0
|| !uris.every((uri) => typeof uri === "string" && parsePublicHttpUri(uri) !== undefined)
|| new Set(uris.map((uri) => parsePublicHttpUri(uri as string)?.href)).size !== uris.length
|| !authentication
|| !connectTimeout
|| !readTimeout
|| !maxBytes
|| maxRedirects === undefined
|| typeof source.allow_private_hosts !== "boolean"
|| !maxCacheBytes
) return undefined;
return {
type: "http",
uris: [...uris] as string[],
authentication,
connect_timeout_ms: connectTimeout,
read_timeout_ms: readTimeout,
max_bytes: maxBytes,
max_redirects: maxRedirects,
allow_private_hosts: source.allow_private_hosts,
max_cache_bytes: maxCacheBytes,
};
}
function copyS3Evidence(value: unknown): EvidenceSource | undefined {
const source = exactRecord(value, [
"type", "uri", "endpoint_url", "region", "credentials", "trusted_endpoint",
"allow_private_endpoint", "allow_insecure_endpoint", "max_bytes", "max_objects",
"max_pages", "page_size",
]);
const uri = typeof source?.uri === "string" && isSafeS3Uri(source.uri) ? source.uri : undefined;
const endpoint = source?.endpoint_url === undefined
? undefined
: typeof source.endpoint_url === "string" && isSafeS3Endpoint(source.endpoint_url)
? source.endpoint_url
: null;
const region = source?.region === undefined ? undefined : text(source.region);
const credentials = oneOf(source?.credentials, ["ambient", "static_files"] as const);
const maxBytes = positiveInteger(source?.max_bytes);
const maxObjects = positiveInteger(source?.max_objects);
const maxPages = positiveInteger(source?.max_pages);
const pageSize = positiveInteger(source?.page_size, 1_000);
if (
source?.type !== "s3"
|| !uri
|| endpoint === null
|| (source.region !== undefined && !region)
|| !credentials
|| typeof source.trusted_endpoint !== "boolean"
|| typeof source.allow_private_endpoint !== "boolean"
|| typeof source.allow_insecure_endpoint !== "boolean"
|| !maxBytes
|| !maxObjects
|| !maxPages
|| !pageSize
|| (endpoint === undefined && (
source.trusted_endpoint || source.allow_private_endpoint || source.allow_insecure_endpoint
))
|| (endpoint !== undefined && !source.trusted_endpoint)
|| (endpoint !== undefined && parsePublicHttpUri(endpoint)?.protocol === "http:" && !source.allow_insecure_endpoint)
) return undefined;
return {
type: "s3",
uri,
...(endpoint === undefined ? {} : { endpoint_url: endpoint }),
...(region === undefined ? {} : { region }),
credentials,
trusted_endpoint: source.trusted_endpoint,
allow_private_endpoint: source.allow_private_endpoint,
allow_insecure_endpoint: source.allow_insecure_endpoint,
max_bytes: maxBytes,
max_objects: maxObjects,
max_pages: maxPages,
page_size: pageSize,
};
}
function copyEvidence(value: unknown, id: string): WorkspaceEvidence | undefined {
const source = exactRecord(value, ["source", "policy"]);
if (!source) return undefined;
const type = record(source.source)?.type;
const evidenceSource = type === "filesystem"
? copyFilesystemEvidence(source.source, id)
: type === "http"
? copyHttpEvidence(source.source)
: type === "s3"
? copyS3Evidence(source.source)
: undefined;
const policy = copyEvidencePolicy(source.policy);
return evidenceSource && policy ? { source: evidenceSource, policy } : undefined;
} }
function oneOf<T extends string>(value: unknown, choices: readonly T[]): T | undefined { function oneOf<T extends string>(value: unknown, choices: readonly T[]): T | undefined {
@@ -217,7 +409,9 @@ function copyDiagnostics(value: unknown): CanonicalDiagnostics | undefined {
/** Drops unknown fields before a server response can become a browser draft or conflict view. */ /** Drops unknown fields before a server response can become a browser draft or conflict view. */
export function sanitizeCanonicalWorkspace(value: unknown): CanonicalWorkspace | undefined { export function sanitizeCanonicalWorkspace(value: unknown): CanonicalWorkspace | undefined {
const source = exactRecord(value, ["workspace", "dwh", "semantic_index", "llm_policy", "diagnostics"]); const source = exactRecord(value, [
"workspace", "dwh", "semantic_index", "llm_policy", "diagnostics", "evidence",
]);
const metadata = exactRecord(source?.workspace, ["schema_version", "id", "name", "description", "language"]); const metadata = exactRecord(source?.workspace, ["schema_version", "id", "name", "description", "language"]);
const dwh = exactRecord(source?.dwh, ["engine", "database", "schema", "port", "timeout_ms", "supported_transports"]); const dwh = exactRecord(source?.dwh, ["engine", "database", "schema", "port", "timeout_ms", "supported_transports"]);
const semanticIndex = exactRecord(source?.semantic_index, ["vector_store", "embedding"]); const semanticIndex = exactRecord(source?.semantic_index, ["vector_store", "embedding"]);
@@ -227,6 +421,7 @@ export function sanitizeCanonicalWorkspace(value: unknown): CanonicalWorkspace |
const diagnostics = source?.diagnostics === undefined ? undefined : copyDiagnostics(source.diagnostics); const diagnostics = source?.diagnostics === undefined ? undefined : copyDiagnostics(source.diagnostics);
if (!metadata || !dwh || !semanticIndex || !vectorStore || !embedding || !policy) return undefined; if (!metadata || !dwh || !semanticIndex || !vectorStore || !embedding || !policy) return undefined;
const id = workspaceId(metadata.id); const id = workspaceId(metadata.id);
const evidence = id && source?.evidence !== undefined ? copyEvidence(source.evidence, id) : undefined;
const name = text(metadata.name); const name = text(metadata.name);
const language = oneOf(metadata.language, ["en", "it"] as const); const language = oneOf(metadata.language, ["en", "it"] as const);
const description = metadata.description === undefined ? undefined : text(metadata.description); const description = metadata.description === undefined ? undefined : text(metadata.description);
@@ -253,6 +448,7 @@ export function sanitizeCanonicalWorkspace(value: unknown): CanonicalWorkspace |
|| embeddingModel !== "qwen3-embedding:0.6b" || embeddingModel !== "qwen3-embedding:0.6b"
) return undefined; ) return undefined;
if (source?.diagnostics !== undefined && !diagnostics) return undefined; if (source?.diagnostics !== undefined && !diagnostics) return undefined;
if (source?.evidence !== undefined && !evidence) return undefined;
if (diagnostics?.dwh_rest && !dwhTransports.includes("rest_api")) return undefined; if (diagnostics?.dwh_rest && !dwhTransports.includes("rest_api")) return undefined;
return { return {
workspace: { workspace: {
@@ -280,6 +476,7 @@ export function sanitizeCanonicalWorkspace(value: unknown): CanonicalWorkspace |
...(defaultModel ? { default: defaultModel } : {}), allowed: allowedModels, ...(defaultModel ? { default: defaultModel } : {}), allowed: allowedModels,
}, },
...(diagnostics ? { diagnostics } : {}), ...(diagnostics ? { diagnostics } : {}),
...(evidence ? { evidence } : {}),
}; };
} }