feat: add read-only workspace and secret management UI
This commit is contained in:
@@ -3,205 +3,98 @@ import { http, HttpResponse } from "msw";
|
||||
import { server } from "../test/msw";
|
||||
import { canonicalWorkspaceFixture, workspaceRevisionFixture, workspaceSummaryFixture } from "../test/workspace-fixtures";
|
||||
import {
|
||||
asWorkspaceApiError,
|
||||
forgetWorkspaceSecret,
|
||||
getWorkspace,
|
||||
importWorkspace,
|
||||
getWorkspaceRuntimeConfiguration,
|
||||
listWorkspaces,
|
||||
publishWorkspace,
|
||||
validateWorkspace,
|
||||
type CanonicalWorkspace,
|
||||
saveWorkspaceSecrets,
|
||||
} from "./workspaces";
|
||||
|
||||
const workspace = canonicalWorkspaceFixture("psd-clinical");
|
||||
const revision = workspaceRevisionFixture("psd-clinical");
|
||||
|
||||
const readySummary = workspaceSummaryFixture("psd-clinical", {
|
||||
displayName: "PSD Clinical",
|
||||
description: "Clinical workspace",
|
||||
const runtimeConfiguration = {
|
||||
workspaceId: "psd-clinical",
|
||||
revision,
|
||||
});
|
||||
|
||||
const evidenceWorkspace = {
|
||||
...workspace,
|
||||
evidence: {
|
||||
source: {
|
||||
type: "filesystem",
|
||||
uri: "psd-clinical/evidence",
|
||||
patterns: ["**/*.md"],
|
||||
max_bytes: 10 * 1024 * 1024,
|
||||
},
|
||||
policy: { max_chunk_chars: 4_000, retain_published_generations: 3 },
|
||||
},
|
||||
} satisfies CanonicalWorkspace;
|
||||
|
||||
test("decodes catalog-driven workspace summaries with exact root descriptor paths", async () => {
|
||||
server.use(http.get("/api/workspaces", () => HttpResponse.json([
|
||||
readySummary,
|
||||
workspaceSummaryFixture("bootstrap-slot", {
|
||||
displayName: "Bootstrap slot",
|
||||
description: "Needs configuration",
|
||||
configurationState: "configuration_required",
|
||||
}),
|
||||
])));
|
||||
|
||||
await expect(listWorkspaces()).resolves.toEqual([
|
||||
readySummary,
|
||||
workspaceSummaryFixture("bootstrap-slot", {
|
||||
displayName: "Bootstrap slot",
|
||||
description: "Needs configuration",
|
||||
configurationState: "configuration_required",
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
test.each([
|
||||
["a summary with the removed language field", { ...readySummary, language: "en" }],
|
||||
["a non-canonical descriptor path", { ...readySummary, file: "psd-clinical.yaml" }],
|
||||
["a ready summary without a revision", { ...readySummary, revision: undefined }],
|
||||
["a configuration_required summary with a revision", {
|
||||
...workspaceSummaryFixture("bootstrap-slot", {
|
||||
displayName: "Bootstrap slot",
|
||||
configurationState: "configuration_required",
|
||||
}),
|
||||
revision,
|
||||
configurationState: "configuration_required",
|
||||
requirements: [{
|
||||
id: "dwh.password",
|
||||
connector: "dwh",
|
||||
label: "Data warehouse password",
|
||||
description: "Password used by the selected data warehouse connection.",
|
||||
input: "password",
|
||||
required: true,
|
||||
configured: false,
|
||||
}],
|
||||
])("rejects %s", async (_case, malformedSummary) => {
|
||||
server.use(http.get("/api/workspaces", () => HttpResponse.json([malformedSummary])));
|
||||
} as const;
|
||||
|
||||
await expect(listWorkspaces()).rejects.toThrow("invalid workspace summary");
|
||||
test("decodes read-only workspace summaries with a revision in every readiness state", async () => {
|
||||
const ready = workspaceSummaryFixture("psd-clinical", {
|
||||
displayName: "PSD Clinical",
|
||||
revision,
|
||||
});
|
||||
const needsSecrets = {
|
||||
...ready,
|
||||
configurationState: "configuration_required" as const,
|
||||
};
|
||||
server.use(http.get("/api/workspaces", () => HttpResponse.json([ready, needsSecrets])));
|
||||
|
||||
await expect(listWorkspaces()).resolves.toEqual([ready, needsSecrets]);
|
||||
});
|
||||
|
||||
test("uploads a workspace bundle without JSON content type", async () => {
|
||||
let contentType: string | null = null;
|
||||
server.use(http.post("/api/workspaces/import", ({ request }) => {
|
||||
contentType = request.headers.get("content-type");
|
||||
return HttpResponse.json({ draft: { workspace } });
|
||||
}));
|
||||
|
||||
await importWorkspace(new File(["zip"], "clinical.thoth-workspace.zip", { type: "application/zip" }));
|
||||
|
||||
expect(contentType ?? "").not.toMatch(/application\/json/i);
|
||||
});
|
||||
|
||||
test("sanitizes imported filesystem Evidence only when it uses the workspace directory root", async () => {
|
||||
server.use(http.post("/api/workspaces/import", () => HttpResponse.json({
|
||||
draft: { workspace: evidenceWorkspace, contract: { variables: [] } },
|
||||
})));
|
||||
|
||||
const result = await importWorkspace(new File(["zip"], "clinical.thoth-workspace.zip"));
|
||||
|
||||
expect(result.draft.workspace.evidence).toEqual(evidenceWorkspace.evidence);
|
||||
expect(result.draft.workspace).not.toBe(evidenceWorkspace);
|
||||
});
|
||||
|
||||
test("rejects imported filesystem Evidence that still points at workspace-content", async () => {
|
||||
server.use(http.post("/api/workspaces/import", () => HttpResponse.json({
|
||||
draft: {
|
||||
workspace: {
|
||||
...evidenceWorkspace,
|
||||
evidence: {
|
||||
...evidenceWorkspace.evidence,
|
||||
source: {
|
||||
...evidenceWorkspace.evidence.source,
|
||||
uri: "workspace-content/psd-clinical/evidence",
|
||||
},
|
||||
},
|
||||
},
|
||||
contract: { variables: [] },
|
||||
},
|
||||
})));
|
||||
|
||||
await expect(importWorkspace(new File(["zip"], "clinical.thoth-workspace.zip")))
|
||||
.rejects.toThrow("invalid imported workspace draft");
|
||||
});
|
||||
|
||||
test("accepts the atomic schema-v3 workspace revision contract without historical state", async () => {
|
||||
test("accepts the immutable workspace revision contract", async () => {
|
||||
server.use(http.get("/api/workspaces/psd-clinical", () => HttpResponse.json({ workspace, revision })));
|
||||
|
||||
await expect(getWorkspace("psd-clinical")).resolves.toEqual({ workspace, revision });
|
||||
});
|
||||
|
||||
test("accepts a Qdrant collection using the canonical hyphenated workspace name", async () => {
|
||||
const hyphenatedCollection = {
|
||||
...workspace,
|
||||
semantic_index: {
|
||||
...workspace.semantic_index,
|
||||
vector_store: { ...workspace.semantic_index.vector_store, collection: "psd-clinical" },
|
||||
},
|
||||
} satisfies CanonicalWorkspace;
|
||||
server.use(http.get("/api/workspaces/psd-clinical", () => HttpResponse.json({
|
||||
workspace: hyphenatedCollection,
|
||||
revision,
|
||||
})));
|
||||
test("decodes runtime requirements but rejects any secret value returned by the server", async () => {
|
||||
server.use(http.get(
|
||||
"/api/workspaces/psd-clinical/runtime-configuration",
|
||||
() => HttpResponse.json(runtimeConfiguration),
|
||||
));
|
||||
await expect(getWorkspaceRuntimeConfiguration("psd-clinical"))
|
||||
.resolves.toEqual(runtimeConfiguration);
|
||||
|
||||
await expect(getWorkspace("psd-clinical")).resolves.toEqual({
|
||||
workspace: hyphenatedCollection,
|
||||
revision,
|
||||
});
|
||||
server.use(http.get(
|
||||
"/api/workspaces/psd-clinical/runtime-configuration",
|
||||
() => HttpResponse.json({
|
||||
...runtimeConfiguration,
|
||||
requirements: [{ ...runtimeConfiguration.requirements[0], value: "leaked-secret" }],
|
||||
}),
|
||||
));
|
||||
await expect(getWorkspaceRuntimeConfiguration("psd-clinical"))
|
||||
.rejects.toThrow("invalid runtime configuration");
|
||||
});
|
||||
|
||||
test("sanitizes read and validate responses while preserving directory-based 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("publishes only bootstrap create requests", async () => {
|
||||
let sent: unknown;
|
||||
server.use(http.post("/api/workspaces/publish", async ({ request }) => {
|
||||
sent = await request.json();
|
||||
return HttpResponse.json({ revision });
|
||||
test("blind secret replacement sends values once and returns status only", async () => {
|
||||
let requestBody: unknown;
|
||||
server.use(http.put("/api/workspaces/psd-clinical/secrets", async ({ request }) => {
|
||||
requestBody = await request.json();
|
||||
return HttpResponse.json({
|
||||
...runtimeConfiguration,
|
||||
configurationState: "ready",
|
||||
requirements: [{ ...runtimeConfiguration.requirements[0], configured: true }],
|
||||
});
|
||||
}));
|
||||
|
||||
await expect(publishWorkspace({
|
||||
action: "create",
|
||||
workspace: evidenceWorkspace,
|
||||
baseCommit: revision.commit,
|
||||
})).resolves.toEqual({ revision });
|
||||
|
||||
expect(sent).toEqual({
|
||||
action: "create",
|
||||
workspace: { ...evidenceWorkspace },
|
||||
baseCommit: revision.commit,
|
||||
const response = await saveWorkspaceSecrets("psd-clinical", {
|
||||
"dwh.password": "one-time-value",
|
||||
});
|
||||
expect(sent).not.toHaveProperty("baseBlob");
|
||||
|
||||
expect(requestBody).toEqual({ values: { "dwh.password": "one-time-value" } });
|
||||
expect(response.configurationState).toBe("ready");
|
||||
expect(JSON.stringify(response)).not.toContain("one-time-value");
|
||||
});
|
||||
|
||||
test("rejects a publish response with a malformed revision", async () => {
|
||||
server.use(http.post("/api/workspaces/publish", () => HttpResponse.json({
|
||||
revision: { ...revision, commit: "not-a-commit" },
|
||||
})));
|
||||
test("forget targets one declared requirement", async () => {
|
||||
let called = false;
|
||||
server.use(http.delete("/api/workspaces/psd-clinical/secrets/dwh.password", () => {
|
||||
called = true;
|
||||
return HttpResponse.json(runtimeConfiguration);
|
||||
}));
|
||||
|
||||
await expect(publishWorkspace({
|
||||
action: "create",
|
||||
workspace: evidenceWorkspace,
|
||||
baseCommit: revision.commit,
|
||||
})).rejects.toThrow("invalid workspace revision");
|
||||
});
|
||||
|
||||
test("decodes workspace_curator_owned safely without conflict fields", async () => {
|
||||
server.use(http.post("/api/workspaces/publish", () => HttpResponse.json({
|
||||
code: "workspace_curator_owned",
|
||||
message: "Existing descriptors are curator-owned.",
|
||||
}, { status: 409 })));
|
||||
|
||||
const error = await publishWorkspace({
|
||||
action: "create",
|
||||
workspace,
|
||||
baseCommit: revision.commit,
|
||||
}).catch((cause: unknown) => cause);
|
||||
|
||||
expect(asWorkspaceApiError(error)).toEqual({
|
||||
status: 409,
|
||||
code: "workspace_curator_owned",
|
||||
message: "Existing descriptors are curator-owned.",
|
||||
});
|
||||
await expect(forgetWorkspaceSecret("psd-clinical", "dwh.password"))
|
||||
.resolves.toEqual(runtimeConfiguration);
|
||||
expect(called).toBe(true);
|
||||
});
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { ApiError, apiFetch, apiFetchBlob } from "./client";
|
||||
import { ApiError, apiFetch } from "./client";
|
||||
import { sanitizeCanonicalWorkspace } from "../workspaces/drafts";
|
||||
|
||||
export type WorkspaceErrorCode =
|
||||
| "workspace_invalid" | "binding_missing" | "workspace_not_activatable"
|
||||
| "workspace_stale" | "workspace_conflict" | "workspace_curator_owned" | "git_unavailable"
|
||||
| "git_auth_failed" | "git_non_fast_forward" | "git_push_rejected"
|
||||
| "workspace_stale" | "git_unavailable"
|
||||
| "git_auth_failed" | "git_non_fast_forward"
|
||||
| "connector_unavailable" | "semantic_index_incompatible";
|
||||
|
||||
export interface RestDiagnosticRequest {
|
||||
@@ -124,6 +124,11 @@ export interface WorkspaceRegistryStatus {
|
||||
behind: number;
|
||||
degraded: boolean;
|
||||
lastError?: WorkspaceErrorCode;
|
||||
repository?: {
|
||||
host: string;
|
||||
repository: string;
|
||||
transport: "https" | "ssh" | "local";
|
||||
};
|
||||
}
|
||||
|
||||
export interface WorkspaceDiagnostic {
|
||||
@@ -138,10 +143,21 @@ export interface WorkspaceDiagnostics {
|
||||
diagnostics: WorkspaceDiagnostic[];
|
||||
}
|
||||
|
||||
export interface PublishWorkspaceRequest {
|
||||
action: "create";
|
||||
workspace: CanonicalWorkspace;
|
||||
baseCommit: string;
|
||||
export interface WorkspaceSecretRequirement {
|
||||
id: string;
|
||||
connector: "dwh" | "evidence";
|
||||
label: string;
|
||||
description: string;
|
||||
input: "password" | "textarea";
|
||||
required: boolean;
|
||||
configured: boolean;
|
||||
}
|
||||
|
||||
export interface WorkspaceRuntimeConfiguration {
|
||||
workspaceId: string;
|
||||
revision: WorkspaceRevision;
|
||||
configurationState: "ready" | "configuration_required";
|
||||
requirements: WorkspaceSecretRequirement[];
|
||||
}
|
||||
|
||||
export interface WorkspaceApiError {
|
||||
@@ -153,8 +169,7 @@ export interface WorkspaceApiError {
|
||||
|
||||
const workspaceErrorCodes = new Set<WorkspaceErrorCode>([
|
||||
"workspace_invalid", "binding_missing", "workspace_not_activatable", "workspace_stale",
|
||||
"workspace_conflict", "workspace_curator_owned", "git_unavailable", "git_auth_failed",
|
||||
"git_non_fast_forward", "git_push_rejected", "connector_unavailable",
|
||||
"git_unavailable", "git_auth_failed", "git_non_fast_forward", "connector_unavailable",
|
||||
"semantic_index_incompatible",
|
||||
]);
|
||||
|
||||
@@ -204,9 +219,7 @@ function workspaceSummary(value: unknown): WorkspaceSummary | undefined {
|
||||
? undefined
|
||||
: workspaceRevision(source.revision, id);
|
||||
if (
|
||||
(configurationState === "ready" && !revision)
|
||||
|| (configurationState === "configuration_required" && source.revision !== undefined)
|
||||
|| (source.revision !== undefined && !revision)
|
||||
!revision
|
||||
) return undefined;
|
||||
return {
|
||||
id,
|
||||
@@ -215,7 +228,7 @@ function workspaceSummary(value: unknown): WorkspaceSummary | undefined {
|
||||
displayName: displayName as string,
|
||||
...(description === undefined ? {} : { description: description as string }),
|
||||
configurationState,
|
||||
...(revision ? { revision } : {}),
|
||||
revision,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -246,38 +259,6 @@ function requireCanonicalWorkspace(value: unknown): CanonicalWorkspace {
|
||||
return workspace;
|
||||
}
|
||||
|
||||
function requireImportedWorkspace(value: unknown): CanonicalWorkspace {
|
||||
const direct = sanitizeCanonicalWorkspace(value);
|
||||
if (direct) return direct;
|
||||
const source = object(value);
|
||||
const metadata = object(source?.workspace);
|
||||
const evidence = object(source?.evidence);
|
||||
const evidenceSource = object(evidence?.source);
|
||||
const workspaceId = typeof metadata?.id === "string" ? metadata.id : undefined;
|
||||
const uri = typeof evidenceSource?.uri === "string" ? evidenceSource.uri : undefined;
|
||||
if (
|
||||
workspaceId
|
||||
&& /^[a-z][a-z0-9-]{2,62}$/.test(workspaceId)
|
||||
&& evidenceSource?.type === "filesystem"
|
||||
&& typeof uri === "string"
|
||||
&& /^[a-z][a-z0-9-]{2,62}\/evidence$/.test(uri)
|
||||
) {
|
||||
const repaired = {
|
||||
...source,
|
||||
evidence: {
|
||||
...evidence,
|
||||
source: {
|
||||
...evidenceSource,
|
||||
uri: `${workspaceId}/evidence`,
|
||||
},
|
||||
},
|
||||
};
|
||||
const sanitized = sanitizeCanonicalWorkspace(repaired);
|
||||
if (sanitized) return sanitized;
|
||||
}
|
||||
throw new Error("Workspace API returned an invalid imported workspace draft");
|
||||
}
|
||||
|
||||
export const listWorkspaces = async (): Promise<WorkspaceSummary[]> => {
|
||||
const response = await apiFetch<unknown>("/workspaces");
|
||||
if (!Array.isArray(response)) throw new Error("Workspace API returned an invalid workspace summary");
|
||||
@@ -309,43 +290,61 @@ export const validateWorkspace = async (workspace: CanonicalWorkspace) => {
|
||||
});
|
||||
const source = object(response);
|
||||
if (!source) throw new Error("Workspace API returned an invalid validation result");
|
||||
return { workspace: requireImportedWorkspace(source.workspace), contract: source.contract };
|
||||
return { workspace: requireCanonicalWorkspace(source.workspace), contract: source.contract };
|
||||
};
|
||||
|
||||
export const testWorkspace = (id: string) =>
|
||||
apiFetch<WorkspaceDiagnostics>(`/workspaces/${encodeURIComponent(id)}/test`, { method: "POST" });
|
||||
|
||||
export const publishWorkspace = async (request: PublishWorkspaceRequest) => {
|
||||
const safeRequest: PublishWorkspaceRequest = { ...request, workspace: requireCanonicalWorkspace(request.workspace) };
|
||||
const response = await apiFetch<unknown>("/workspaces/publish", {
|
||||
method: "POST", body: JSON.stringify(safeRequest),
|
||||
function runtimeConfiguration(value: unknown, expectedId: string): WorkspaceRuntimeConfiguration {
|
||||
const source = exactObject(value, [
|
||||
"workspaceId", "revision", "configurationState", "requirements",
|
||||
]);
|
||||
if (
|
||||
!source
|
||||
|| source.workspaceId !== expectedId
|
||||
|| (source.configurationState !== "ready" && source.configurationState !== "configuration_required")
|
||||
|| !Array.isArray(source.requirements)
|
||||
) throw new Error("Workspace API returned an invalid runtime configuration");
|
||||
const requirements = source.requirements.map((value) => {
|
||||
const requirement = exactObject(value, [
|
||||
"id", "connector", "label", "description", "input", "required", "configured",
|
||||
]);
|
||||
if (
|
||||
!requirement
|
||||
|| typeof requirement.id !== "string" || !/^[a-z0-9][a-z0-9._-]{1,127}$/.test(requirement.id)
|
||||
|| (requirement.connector !== "dwh" && requirement.connector !== "evidence")
|
||||
|| typeof requirement.label !== "string" || requirement.label.length === 0
|
||||
|| typeof requirement.description !== "string" || requirement.description.length === 0
|
||||
|| (requirement.input !== "password" && requirement.input !== "textarea")
|
||||
|| typeof requirement.required !== "boolean"
|
||||
|| typeof requirement.configured !== "boolean"
|
||||
) throw new Error("Workspace API returned an invalid runtime configuration");
|
||||
return requirement as unknown as WorkspaceSecretRequirement;
|
||||
});
|
||||
if (response === undefined) return undefined;
|
||||
const source = exactObject(response, ["revision"]);
|
||||
if (!source) throw new Error("Workspace API returned an invalid publish result");
|
||||
return { revision: requireWorkspaceRevision(source.revision, safeRequest.workspace.workspace.id) };
|
||||
};
|
||||
|
||||
export const exportWorkspace = (id: string) =>
|
||||
apiFetchBlob(`/workspaces/${encodeURIComponent(id)}/export`);
|
||||
|
||||
export const importWorkspace = async (bundle: File) => {
|
||||
const body = new FormData();
|
||||
body.set("bundle", bundle);
|
||||
const response = await apiFetch<unknown>("/workspaces/import", { method: "POST", body });
|
||||
const source = exactObject(response, ["draft"]);
|
||||
const draft = exactObject(source?.draft, ["workspace", "contract"]);
|
||||
if (!source || !draft) throw new Error("Workspace API returned an invalid imported workspace draft");
|
||||
let workspace: CanonicalWorkspace;
|
||||
try {
|
||||
workspace = requireImportedWorkspace(draft.workspace);
|
||||
} catch {
|
||||
throw new Error("Workspace API returned an invalid imported workspace draft");
|
||||
}
|
||||
return {
|
||||
draft: {
|
||||
workspace,
|
||||
...(draft.contract === undefined ? {} : { contract: draft.contract }),
|
||||
},
|
||||
workspaceId: expectedId,
|
||||
revision: requireWorkspaceRevision(source.revision, expectedId),
|
||||
configurationState: source.configurationState,
|
||||
requirements,
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export const getWorkspaceRuntimeConfiguration = async (id: string) => runtimeConfiguration(
|
||||
await apiFetch<unknown>(`/workspaces/${encodeURIComponent(id)}/runtime-configuration`),
|
||||
id,
|
||||
);
|
||||
|
||||
export const saveWorkspaceSecrets = async (id: string, values: Readonly<Record<string, string>>) => (
|
||||
runtimeConfiguration(await apiFetch<unknown>(`/workspaces/${encodeURIComponent(id)}/secrets`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ values }),
|
||||
}), id)
|
||||
);
|
||||
|
||||
export const forgetWorkspaceSecret = async (id: string, requirementId: string) => (
|
||||
runtimeConfiguration(await apiFetch<unknown>(
|
||||
`/workspaces/${encodeURIComponent(id)}/secrets/${encodeURIComponent(requirementId)}`,
|
||||
{ method: "DELETE" },
|
||||
), id)
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user