feat: configure workspace runtime secrets through API

This commit is contained in:
2026-08-14 17:30:35 +02:00
parent 2114c94704
commit 87cefd120c
7 changed files with 325 additions and 41 deletions
+104 -12
View File
@@ -3,8 +3,12 @@ import { z } from "zod";
import type { WorkspaceRegistryConfig } from "../workspaces/types.js";
import { WorkspaceRegistryError } from "../workspaces/git-repository.js";
import type { WorkspaceRegistry } from "../workspaces/registry.js";
import { resolveRuntimeBindings } from "../workspaces/bindings.js";
import { buildInstallationContract } from "../workspaces/contracts.js";
import {
discoverWorkspaceSecretRequirements,
resolveRuntimeBindingsWithWorkspaceSecrets,
} from "../workspaces/secret-requirements.js";
import type { WorkspaceSecretStore } from "../workspaces/secret-store.js";
import {
validateOperationalWorkspace,
validateWorkspaceDescriptor,
@@ -24,10 +28,17 @@ interface WorkspaceRoutesDeps {
registry: WorkspaceRegistry;
config: WorkspaceRegistryConfig;
diagnose: WorkspaceDiagnoser;
secretStore: WorkspaceSecretStore;
}
const workspaceId = z.string().regex(/^[a-z][a-z0-9-]{2,62}$/);
const workspacePayload = z.object({ workspace: z.unknown() }).strict();
const secretRequirementId = z.string().regex(/^[a-z0-9][a-z0-9._-]{1,127}$/);
const secretValuesPayload = z.object({
values: z.record(secretRequirementId, z.string()).refine(
(values) => Object.keys(values).length > 0 && Object.keys(values).length <= 16,
),
}).strict();
const SAFE_MESSAGES = {
workspace_invalid: "Workspace request is invalid.",
@@ -57,6 +68,29 @@ function errorReply(reply: FastifyReply, error: unknown) {
}
export function workspaceRoutes(app: FastifyInstance, deps: WorkspaceRoutesDeps): void {
const runtimeConfiguration = async (id: string) => {
const { workspace, revision } = await deps.registry.read(id);
const operational = validateOperationalWorkspace(workspace);
const requirements = discoverWorkspaceSecretRequirements(operational, process.env)
.map((requirement) => ({
id: requirement.id,
connector: requirement.connector,
label: requirement.label,
description: requirement.description,
input: requirement.input,
required: requirement.required,
configured: deps.secretStore.has(id, requirement.id),
}));
return {
workspaceId: id,
revision,
configurationState: requirements.some(({ required, configured }) => required && !configured)
? "configuration_required" as const
: "ready" as const,
requirements,
};
};
app.get("/workspace-registry/status", async (_request, reply) => {
try {
return await deps.registry.bootstrap();
@@ -76,15 +110,18 @@ export function workspaceRoutes(app: FastifyInstance, deps: WorkspaceRoutesDeps)
app.get("/workspaces", async (_request, reply) => {
try {
const records = await deps.registry.listCatalog();
return records.map((record) => ({
id: record.id,
// Retain the metadata endpoint's selector field while adding catalog metadata.
name: record.id,
file: `${record.id}/workspace.yaml`,
displayName: record.name,
description: record.description,
configurationState: record.configurationState,
...(record.revision ? { revision: record.revision } : {}),
return await Promise.all(records.map(async (record) => {
const configuration = await runtimeConfiguration(record.id);
return {
id: record.id,
// Retain the metadata endpoint's selector field while adding catalog metadata.
name: record.id,
file: `${record.id}/workspace.yaml`,
displayName: record.name,
description: record.description,
configurationState: configuration.configurationState,
...(record.revision ? { revision: record.revision } : {}),
};
}));
} catch (error) {
return errorReply(reply, error);
@@ -110,6 +147,52 @@ export function workspaceRoutes(app: FastifyInstance, deps: WorkspaceRoutesDeps)
}
});
app.get("/workspaces/:id/runtime-configuration", async (request, reply) => {
try {
const { id } = z.object({ id: workspaceId }).parse(request.params);
return await runtimeConfiguration(id);
} catch (error) {
return errorReply(reply, error);
}
});
app.put("/workspaces/:id/secrets", async (request, reply) => {
try {
const { id } = z.object({ id: workspaceId }).parse(request.params);
const { values } = secretValuesPayload.parse(request.body);
const { workspace } = await deps.registry.read(id);
const declared = new Set(
discoverWorkspaceSecretRequirements(validateOperationalWorkspace(workspace), process.env)
.map(({ id: requirementId }) => requirementId),
);
if (Object.keys(values).some((requirementId) => !declared.has(requirementId))) {
throw new Error("undeclared workspace secret");
}
deps.secretStore.putMany(id, values);
return await runtimeConfiguration(id);
} catch (error) {
return errorReply(reply, error);
}
});
app.delete("/workspaces/:id/secrets/:requirementId", async (request, reply) => {
try {
const { id, requirementId } = z.object({
id: workspaceId,
requirementId: secretRequirementId,
}).parse(request.params);
const { workspace } = await deps.registry.read(id);
const declared = discoverWorkspaceSecretRequirements(
validateOperationalWorkspace(workspace), process.env,
).some(({ id: candidate }) => candidate === requirementId);
if (!declared) throw new Error("undeclared workspace secret");
deps.secretStore.forget(id, requirementId);
return await runtimeConfiguration(id);
} catch (error) {
return errorReply(reply, error);
}
});
app.post("/workspaces/:id/test", async (request, reply) => {
try {
const { id } = z.object({ id: workspaceId }).parse(request.params);
@@ -122,8 +205,17 @@ export function workspaceRoutes(app: FastifyInstance, deps: WorkspaceRoutesDeps)
"workspace_not_activatable", "Workspace requires explicit migration",
);
}
const bindings = resolveRuntimeBindings(operational, process.env, deps.config.secretRoots);
return await deps.diagnose(operational, bindings, { writeProbe: false });
const lease = resolveRuntimeBindingsWithWorkspaceSecrets(
operational,
process.env,
deps.config.secretRoots,
deps.secretStore,
);
try {
return await deps.diagnose(operational, lease.bindings, { writeProbe: false });
} finally {
lease.release();
}
} catch (error) {
return errorReply(reply, error);
}