feat: add read-only workspace and secret management UI

This commit is contained in:
2026-08-14 17:38:24 +02:00
parent e902f758b1
commit 3978008aed
8 changed files with 586 additions and 1181 deletions
+76 -77
View File
@@ -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)
);