fix: harden workspace schema contracts

This commit is contained in:
2026-08-03 21:40:58 +02:00
parent 92cb0545be
commit 5a654939a5
4 changed files with 152 additions and 16 deletions
+51 -13
View File
@@ -1,3 +1,4 @@
import { validateCanonicalWorkspace } from "./schema.js";
import type { CanonicalWorkspace, DwhTransport, VectorTransport } from "./schema.js"; import type { CanonicalWorkspace, DwhTransport, VectorTransport } from "./schema.js";
export type InstallationRole = "DWH" | "VECTOR" | "EMBEDDING"; export type InstallationRole = "DWH" | "VECTOR" | "EMBEDDING";
@@ -34,15 +35,24 @@ export interface InstallationContract {
variables: InstallationVariable[]; variables: InstallationVariable[];
} }
const CONNECTOR_SUFFIXES: readonly InstallationSuffix[] = [ const DIRECT_SUFFIXES: readonly InstallationSuffix[] = [
"TRANSPORT",
"HOST", "HOST",
"PORT", "PORT",
"BASE_URL",
"USER", "USER",
"PASSWORD_FILE", "PASSWORD_FILE",
"TLS_CA_FILE",
];
const REST_SUFFIXES: readonly InstallationSuffix[] = [
"BASE_URL",
"API_KEY_FILE", "API_KEY_FILE",
"TLS_CA_FILE", "TLS_CA_FILE",
];
const SSH_SUFFIXES: readonly InstallationSuffix[] = [
"USER",
"PASSWORD_FILE",
"TLS_CA_FILE",
"SSH_HOST", "SSH_HOST",
"SSH_PORT", "SSH_PORT",
"SSH_USER", "SSH_USER",
@@ -82,21 +92,46 @@ function connectorVariables(
role: "DWH" | "VECTOR", role: "DWH" | "VECTOR",
transports: readonly ConnectorTransport[], transports: readonly ConnectorTransport[],
): InstallationVariable[] { ): InstallationVariable[] {
return CONNECTOR_SUFFIXES.map((suffix) => createVariable(namespace, role, suffix, transports)); const suffixTransports = new Map<InstallationSuffix, ConnectorTransport[]>();
const add = (suffixes: readonly InstallationSuffix[], transport: ConnectorTransport) => {
for (const suffix of suffixes) {
const applicable = suffixTransports.get(suffix) ?? [];
applicable.push(transport);
suffixTransports.set(suffix, applicable);
}
};
for (const transport of transports) {
if (transport === "postgres_direct" || transport === "pgvector_direct") {
add(DIRECT_SUFFIXES, transport);
} else if (transport === "rest_api") {
add(REST_SUFFIXES, transport);
} else {
add(SSH_SUFFIXES, transport);
}
}
return [
createVariable(namespace, role, "TRANSPORT", transports),
...[...suffixTransports.entries()].map(([suffix, applicable]) => (
createVariable(namespace, role, suffix, applicable)
)),
];
} }
export function buildInstallationContract(workspace: CanonicalWorkspace): InstallationContract { export function buildInstallationContract(workspace: CanonicalWorkspace): InstallationContract {
const namespace = namespaceFor(workspace); const canonical = validateCanonicalWorkspace(workspace);
const namespace = namespaceFor(canonical);
return { return {
workspaceId: workspace.workspace.id, workspaceId: canonical.workspace.id,
namespace, namespace,
variables: [ variables: [
...connectorVariables(namespace, "DWH", workspace.dwh.supported_transports), ...connectorVariables(namespace, "DWH", canonical.dwh.supported_transports),
...connectorVariables( ...connectorVariables(
namespace, namespace,
"VECTOR", "VECTOR",
workspace.semantic_index.vector_store.supported_transports, canonical.semantic_index.vector_store.supported_transports,
), ),
...EMBEDDING_SUFFIXES.map((suffix) => createVariable(namespace, "EMBEDDING", suffix)), ...EMBEDDING_SUFFIXES.map((suffix) => createVariable(namespace, "EMBEDDING", suffix)),
], ],
@@ -110,7 +145,8 @@ function localizedIntroduction(workspace: CanonicalWorkspace): string {
} }
export function renderWorkspaceDocs(workspace: CanonicalWorkspace): { envExample: string; markdown: string } { export function renderWorkspaceDocs(workspace: CanonicalWorkspace): { envExample: string; markdown: string } {
const contract = buildInstallationContract(workspace); const canonical = validateCanonicalWorkspace(workspace);
const contract = buildInstallationContract(canonical);
const variablesByRole = new Map<InstallationRole, InstallationVariable[]>(); const variablesByRole = new Map<InstallationRole, InstallationVariable[]>();
for (const variable of contract.variables) { for (const variable of contract.variables) {
const variables = variablesByRole.get(variable.role) ?? []; const variables = variablesByRole.get(variable.role) ?? [];
@@ -119,7 +155,7 @@ export function renderWorkspaceDocs(workspace: CanonicalWorkspace): { envExample
} }
const envExample = [ const envExample = [
`# Generated installation bindings for ${workspace.workspace.id}`, `# Generated installation bindings for ${canonical.workspace.id}`,
"# Provide secret file paths only; never paste secret values here.", "# Provide secret file paths only; never paste secret values here.",
...contract.variables.map((variable) => `${variable.name}=`), ...contract.variables.map((variable) => `${variable.name}=`),
"", "",
@@ -128,16 +164,18 @@ export function renderWorkspaceDocs(workspace: CanonicalWorkspace): { envExample
const markdown = [ const markdown = [
"# Installation requirements", "# Installation requirements",
"", "",
`**Workspace:** ${workspace.workspace.name}`, `**Workspace:** ${canonical.workspace.name}`,
"", "",
localizedIntroduction(workspace), localizedIntroduction(canonical),
"", "",
"Use the following UI fields as installation bindings. Secret fields always contain file paths, never secret values.", "Use the following UI fields as installation bindings. Secret fields always contain file paths, never secret values.",
"", "",
...(["DWH", "VECTOR", "EMBEDDING"] as const).flatMap((role) => [ ...(["DWH", "VECTOR", "EMBEDDING"] as const).flatMap((role) => [
`## ${role === "DWH" ? "Data warehouse" : role === "VECTOR" ? "Vector store" : "Embedding service"}`, `## ${role === "DWH" ? "Data warehouse" : role === "VECTOR" ? "Vector store" : "Embedding service"}`,
"", "",
...(variablesByRole.get(role) ?? []).map((variable) => `- \`${variable.name}\``), ...(variablesByRole.get(role) ?? []).map((variable) => (
`- \`${variable.name}\`${variable.transports ? ` (for: ${variable.transports.join(", ")})` : ""}`
)),
"", "",
]), ]),
].join("\n"); ].join("\n");
+18 -2
View File
@@ -19,6 +19,8 @@ export interface CanonicalWorkspace {
engine: "postgres"; engine: "postgres";
database: string; database: string;
schema: string; schema: string;
port?: number;
timeout_ms?: number;
supported_transports: DwhTransport[]; supported_transports: DwhTransport[];
}; };
semantic_index: { semantic_index: {
@@ -27,12 +29,15 @@ export interface CanonicalWorkspace {
collection: string; collection: string;
dimensions: number; dimensions: number;
distance: "cosine" | "l2" | "inner_product"; distance: "cosine" | "l2" | "inner_product";
port?: number;
timeout_ms?: number;
supported_transports: VectorTransport[]; supported_transports: VectorTransport[];
}; };
embedding: { embedding: {
provider: "ollama_compatible" | "openai_compatible"; provider: "ollama_compatible" | "openai_compatible";
model: string; model: string;
dimensions: number; dimensions: number;
timeout_ms?: number;
}; };
}; };
llm_policy: { llm_policy: {
@@ -48,6 +53,8 @@ const identifier = z.string().regex(/^[A-Za-z_][A-Za-z0-9_]*$/, {
message: "database identifiers must start with a letter or underscore", message: "database identifiers must start with a letter or underscore",
}); });
const dimensions = z.number().int().positive().max(32_768); const dimensions = z.number().int().positive().max(32_768);
const port = z.number().int().min(1).max(65_535);
const timeoutMs = z.number().int().positive();
const modelReference = z.string().regex(/^[^/\s]+\/[^/\s]+$/, { const modelReference = z.string().regex(/^[^/\s]+\/[^/\s]+$/, {
message: "model must use provider/model syntax", message: "model must use provider/model syntax",
}); });
@@ -70,6 +77,8 @@ const WorkspaceSchema = z.object({
engine: z.literal("postgres"), engine: z.literal("postgres"),
database: identifier, database: identifier,
schema: identifier, schema: identifier,
port: port.optional(),
timeout_ms: timeoutMs.optional(),
supported_transports: z.array(z.enum(DWH_TRANSPORTS)).min(1), supported_transports: z.array(z.enum(DWH_TRANSPORTS)).min(1),
}).strict(), }).strict(),
semantic_index: z.object({ semantic_index: z.object({
@@ -78,12 +87,15 @@ const WorkspaceSchema = z.object({
collection: identifier, collection: identifier,
dimensions, dimensions,
distance: z.enum(["cosine", "l2", "inner_product"]), distance: z.enum(["cosine", "l2", "inner_product"]),
port: port.optional(),
timeout_ms: timeoutMs.optional(),
supported_transports: z.array(z.enum(VECTOR_TRANSPORTS)).min(1), supported_transports: z.array(z.enum(VECTOR_TRANSPORTS)).min(1),
}).strict(), }).strict(),
embedding: z.object({ embedding: z.object({
provider: z.enum(["ollama_compatible", "openai_compatible"]), provider: z.enum(["ollama_compatible", "openai_compatible"]),
model: z.string().trim().min(1), model: z.string().trim().min(1),
dimensions, dimensions,
timeout_ms: timeoutMs.optional(),
}).strict(), }).strict(),
}).strict(), }).strict(),
llm_policy: z.object({ llm_policy: z.object({
@@ -127,11 +139,15 @@ export function parseWorkspaceYaml(source: string): CanonicalWorkspace {
throw new Error(`Invalid workspace YAML: ${document.errors.map((error) => error.message).join("; ")}`); throw new Error(`Invalid workspace YAML: ${document.errors.map((error) => error.message).join("; ")}`);
} }
return WorkspaceSchema.parse(document.toJSON()) as CanonicalWorkspace; return validateCanonicalWorkspace(document.toJSON());
}
export function validateCanonicalWorkspace(workspace: unknown): CanonicalWorkspace {
return WorkspaceSchema.parse(workspace) as CanonicalWorkspace;
} }
export function serializeWorkspaceYaml(workspace: CanonicalWorkspace): string { export function serializeWorkspaceYaml(workspace: CanonicalWorkspace): string {
const canonical = WorkspaceSchema.parse(workspace) as CanonicalWorkspace; const canonical = validateCanonicalWorkspace(workspace);
return stringify(canonical, { lineWidth: 0, sortMapEntries: true }); return stringify(canonical, { lineWidth: 0, sortMapEntries: true });
} }
+68 -1
View File
@@ -1,6 +1,6 @@
import { expect, test } from "vitest"; import { expect, test } from "vitest";
import { buildInstallationContract, renderWorkspaceDocs } from "../src/workspaces/contracts.js"; import { buildInstallationContract, renderWorkspaceDocs } from "../src/workspaces/contracts.js";
import { parseWorkspaceYaml } from "../src/workspaces/schema.js"; import { type CanonicalWorkspace, parseWorkspaceYaml } from "../src/workspaces/schema.js";
const validWorkspace = parseWorkspaceYaml(`workspace: const validWorkspace = parseWorkspaceYaml(`workspace:
schema_version: 1 schema_version: 1
@@ -66,3 +66,70 @@ test("renders English UI headings and workspace-language Italian prose", () => {
expect(docs.markdown).toContain("Configurazione dell'installazione"); expect(docs.markdown).toContain("Configurazione dell'installazione");
expect(docs.envExample).toContain("THT_WS_PSD_CLINICAL_VECTOR_TRANSPORT="); expect(docs.envExample).toContain("THT_WS_PSD_CLINICAL_VECTOR_TRANSPORT=");
}); });
function withTransports(
dwhTransport: CanonicalWorkspace["dwh"]["supported_transports"][number],
vectorTransport: CanonicalWorkspace["semantic_index"]["vector_store"]["supported_transports"][number],
): CanonicalWorkspace {
return {
...validWorkspace,
dwh: { ...validWorkspace.dwh, supported_transports: [dwhTransport] },
semantic_index: {
...validWorkspace.semantic_index,
vector_store: {
...validWorkspace.semantic_index.vector_store,
supported_transports: [vectorTransport],
},
},
};
}
test("emits only direct connector bindings for direct transports", () => {
const variables = buildInstallationContract(withTransports("postgres_direct", "pgvector_direct")).variables;
for (const role of ["DWH", "VECTOR"] as const) {
const names = variables.filter((variable) => variable.role === role).map((variable) => variable.name);
expect(names).toContain(`THT_WS_PSD_CLINICAL_${role}_HOST`);
expect(names).toContain(`THT_WS_PSD_CLINICAL_${role}_PASSWORD_FILE`);
expect(names).not.toContain(`THT_WS_PSD_CLINICAL_${role}_BASE_URL`);
expect(names).not.toContain(`THT_WS_PSD_CLINICAL_${role}_API_KEY_FILE`);
expect(names.some((name) => name.includes("_SSH_"))).toBe(false);
}
});
test("emits only REST connector bindings for REST transports", () => {
const variables = buildInstallationContract(withTransports("rest_api", "rest_api")).variables;
for (const role of ["DWH", "VECTOR"] as const) {
const names = variables.filter((variable) => variable.role === role).map((variable) => variable.name);
expect(names).toContain(`THT_WS_PSD_CLINICAL_${role}_BASE_URL`);
expect(names).toContain(`THT_WS_PSD_CLINICAL_${role}_API_KEY_FILE`);
expect(names).not.toContain(`THT_WS_PSD_CLINICAL_${role}_HOST`);
expect(names).not.toContain(`THT_WS_PSD_CLINICAL_${role}_PASSWORD_FILE`);
expect(names.some((name) => name.includes("_SSH_"))).toBe(false);
}
});
test("emits SSH bindings only for SSH-tunnel transports", () => {
const variables = buildInstallationContract(withTransports("ssh_tunnel", "ssh_tunnel")).variables;
for (const role of ["DWH", "VECTOR"] as const) {
const roleVariables = variables.filter((variable) => variable.role === role);
expect(roleVariables.map((variable) => variable.name))
.toContain(`THT_WS_PSD_CLINICAL_${role}_SSH_PRIVATE_KEY_FILE`);
expect(roleVariables.find((variable) => variable.suffix === "SSH_HOST")?.transports)
.toEqual(["ssh_tunnel"]);
expect(roleVariables.map((variable) => variable.name))
.not.toContain(`THT_WS_PSD_CLINICAL_${role}_BASE_URL`);
}
});
test("validates public contract and documentation inputs at runtime", () => {
const unsafeWorkspace = {
...validWorkspace,
workspace: { ...validWorkspace.workspace, id: "psd\nclinical" },
} as CanonicalWorkspace;
expect(() => buildInstallationContract(unsafeWorkspace)).toThrow(/id/i);
expect(() => renderWorkspaceDocs(unsafeWorkspace)).toThrow(/id/i);
});
+15
View File
@@ -11,6 +11,8 @@ dwh:
engine: postgres engine: postgres
database: postgres database: postgres
schema: datawarehouse schema: datawarehouse
port: 5432
timeout_ms: 5000
supported_transports: supported_transports:
- postgres_direct - postgres_direct
- rest_api - rest_api
@@ -21,6 +23,8 @@ semantic_index:
collection: clinical_documents collection: clinical_documents
dimensions: 768 dimensions: 768
distance: cosine distance: cosine
port: 5432
timeout_ms: 5000
supported_transports: supported_transports:
- pgvector_direct - pgvector_direct
- rest_api - rest_api
@@ -29,6 +33,7 @@ semantic_index:
provider: ollama_compatible provider: ollama_compatible
model: nomic-embed-text-v2-moe model: nomic-embed-text-v2-moe
dimensions: 768 dimensions: 768
timeout_ms: 5000
llm_policy: llm_policy:
default: zai/glm-5.2 default: zai/glm-5.2
allowed: allowed:
@@ -53,6 +58,16 @@ test("rejects unknown keys and invalid immutable IDs", () => {
.toThrow(/id/i); .toThrow(/id/i);
}); });
test("accepts optional connection ports and timeouts but rejects unsafe values", () => {
expect(parseWorkspaceYaml(validYaml).dwh.port).toBe(5432);
expect(() => parseWorkspaceYaml(validYaml.replace("port: 5432", "port: 0")))
.toThrow(/port/i);
expect(() => parseWorkspaceYaml(validYaml.replace("port: 5432", "port: 65536")))
.toThrow(/port/i);
expect(() => parseWorkspaceYaml(validYaml.replace("timeout_ms: 5000", "timeout_ms: 0")))
.toThrow(/timeout/i);
});
test("serializes canonical YAML that parses back to the same workspace", () => { test("serializes canonical YAML that parses back to the same workspace", () => {
const workspace = parseWorkspaceYaml(validYaml); const workspace = parseWorkspaceYaml(validYaml);
const serialized = serializeWorkspaceYaml(workspace); const serialized = serializeWorkspaceYaml(workspace);