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
+136 -1
View File
@@ -1,6 +1,8 @@
import { beforeEach, expect, test } from "vitest";
import type { CanonicalWorkspace } from "../api/workspaces";
import { workspaceDeletionDrafts, workspaceDrafts, workspacePreferences } from "./drafts";
import {
sanitizeCanonicalWorkspace, workspaceDeletionDrafts, workspaceDrafts, workspacePreferences,
} from "./drafts";
const workspace: CanonicalWorkspace = {
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"] },
};
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", () => {
workspacePreferences.save({
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(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 interface WorkspaceDraft {
@@ -156,7 +159,196 @@ function modelReference(value: unknown): `${string}/${string}` | 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 {
@@ -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. */
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 dwh = exactRecord(source?.dwh, ["engine", "database", "schema", "port", "timeout_ms", "supported_transports"]);
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);
if (!metadata || !dwh || !semanticIndex || !vectorStore || !embedding || !policy) return undefined;
const id = workspaceId(metadata.id);
const evidence = id && source?.evidence !== undefined ? copyEvidence(source.evidence, id) : undefined;
const name = text(metadata.name);
const language = oneOf(metadata.language, ["en", "it"] as const);
const description = metadata.description === undefined ? undefined : text(metadata.description);
@@ -253,6 +448,7 @@ export function sanitizeCanonicalWorkspace(value: unknown): CanonicalWorkspace |
|| embeddingModel !== "qwen3-embedding:0.6b"
) 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;
return {
workspace: {
@@ -280,6 +476,7 @@ export function sanitizeCanonicalWorkspace(value: unknown): CanonicalWorkspace |
...(defaultModel ? { default: defaultModel } : {}), allowed: allowedModels,
},
...(diagnostics ? { diagnostics } : {}),
...(evidence ? { evidence } : {}),
};
}