fix: preserve workspace evidence in browser drafts
This commit is contained in:
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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 } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user