260 lines
10 KiB
TypeScript
260 lines
10 KiB
TypeScript
import type { FastifyInstance, FastifyReply } from "fastify";
|
|
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 { buildInstallationContract } from "../workspaces/contracts.js";
|
|
import {
|
|
discoverWorkspaceSecretRequirements,
|
|
resolveRuntimeBindingsWithWorkspaceSecrets,
|
|
} from "../workspaces/secret-requirements.js";
|
|
import type { WorkspaceSecretStore } from "../workspaces/secret-store.js";
|
|
import {
|
|
validateOperationalWorkspace,
|
|
validateWorkspaceDescriptor,
|
|
type CanonicalWorkspace,
|
|
type WorkspaceDescriptor,
|
|
} from "../workspaces/schema.js";
|
|
import type { RuntimeBindings } from "../workspaces/runtime-renderer.js";
|
|
import type { ConnectorDiagnostics } from "../workspaces/diagnostics.js";
|
|
import { isPrincipalContext, requirePermission } from "../auth/authorization.js";
|
|
import type { AuthDiagnoser } from "../auth/diagnostics.js";
|
|
import { decodeAuthDiagnostics, type AuthDiagnostics } from "../auth/group-catalog.js";
|
|
|
|
export type WorkspaceDiagnoser = (
|
|
workspace: WorkspaceDescriptor,
|
|
bindings: RuntimeBindings,
|
|
options: { writeProbe: boolean },
|
|
) => Promise<ConnectorDiagnostics>;
|
|
|
|
interface WorkspaceRoutesDeps {
|
|
registry: WorkspaceRegistry;
|
|
config: WorkspaceRegistryConfig;
|
|
diagnose: WorkspaceDiagnoser;
|
|
authDiagnoser: AuthDiagnoser;
|
|
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.",
|
|
binding_missing: "Installation binding is missing or invalid.",
|
|
workspace_not_activatable: "Workspace cannot be activated on this installation.",
|
|
workspace_stale: "Workspace repository state is stale.",
|
|
git_unavailable: "Workspace Git service is unavailable.",
|
|
git_auth_failed: "Workspace Git authentication failed.",
|
|
git_non_fast_forward: "Workspace Git branch has changed.",
|
|
connector_unavailable: "Workspace connector is unavailable.",
|
|
semantic_index_incompatible: "Semantic index is incompatible with this workspace.",
|
|
} as const;
|
|
|
|
function authenticationReport(value: unknown): AuthDiagnostics {
|
|
const report = decodeAuthDiagnostics(value);
|
|
if (!report) throw new Error("invalid authentication diagnostic report");
|
|
return report;
|
|
}
|
|
|
|
function workspaceErrorCode(error: unknown): keyof typeof SAFE_MESSAGES {
|
|
return error instanceof WorkspaceRegistryError ? error.code : "workspace_invalid";
|
|
}
|
|
|
|
function workspaceErrorStatus(code: keyof typeof SAFE_MESSAGES): number {
|
|
if (code === "workspace_stale" || code === "git_non_fast_forward") return 409;
|
|
if (code === "git_unavailable" || code === "git_auth_failed") return 503;
|
|
return 400;
|
|
}
|
|
|
|
function errorReply(reply: FastifyReply, error: unknown) {
|
|
const code = workspaceErrorCode(error);
|
|
return reply.code(workspaceErrorStatus(code)).send({ code, message: SAFE_MESSAGES[code] });
|
|
}
|
|
|
|
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) => {
|
|
if (!isPrincipalContext(requirePermission(request, reply, "workspace.manage"))) return reply;
|
|
try {
|
|
return await deps.registry.bootstrap();
|
|
} catch (error) {
|
|
return errorReply(reply, error);
|
|
}
|
|
});
|
|
|
|
app.post("/workspace-registry/pull", async (request, reply) => {
|
|
if (!isPrincipalContext(requirePermission(request, reply, "workspace.manage"))) return reply;
|
|
try {
|
|
return await deps.registry.pull();
|
|
} catch (error) {
|
|
return errorReply(reply, error);
|
|
}
|
|
});
|
|
|
|
app.get("/workspaces", async (request, reply) => {
|
|
if (!isPrincipalContext(requirePermission(request, reply, "session.use"))) return reply;
|
|
try {
|
|
const records = await deps.registry.listCatalog();
|
|
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);
|
|
}
|
|
});
|
|
|
|
app.get("/workspaces/:id", async (request, reply) => {
|
|
if (!isPrincipalContext(requirePermission(request, reply, "session.use"))) return reply;
|
|
try {
|
|
const { id } = z.object({ id: workspaceId }).parse(request.params);
|
|
return await deps.registry.read(id);
|
|
} catch (error) {
|
|
return errorReply(reply, error);
|
|
}
|
|
});
|
|
|
|
app.post("/workspaces/validate", async (request, reply) => {
|
|
if (!isPrincipalContext(requirePermission(request, reply, "workspace.manage"))) return reply;
|
|
try {
|
|
const { workspace } = workspacePayload.parse(request.body);
|
|
const canonical = validateWorkspaceDescriptor(workspace);
|
|
const authentication = authenticationReport(await deps.authDiagnoser.inspect({ live: false }));
|
|
return {
|
|
workspace: canonical,
|
|
contract: buildInstallationContract(canonical),
|
|
activatable: authentication.ready,
|
|
diagnostics: [],
|
|
authentication,
|
|
};
|
|
} catch (error) {
|
|
return errorReply(reply, error);
|
|
}
|
|
});
|
|
|
|
app.get("/workspaces/:id/runtime-configuration", async (request, reply) => {
|
|
if (!isPrincipalContext(requirePermission(request, reply, "session.use"))) return 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) => {
|
|
if (!isPrincipalContext(requirePermission(request, reply, "workspace.secrets.manage"))) return 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) => {
|
|
if (!isPrincipalContext(requirePermission(request, reply, "workspace.secrets.manage"))) return 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) => {
|
|
if (!isPrincipalContext(requirePermission(request, reply, "workspace.manage"))) return reply;
|
|
try {
|
|
const { id } = z.object({ id: workspaceId }).parse(request.params);
|
|
const { workspace } = await deps.registry.read(id);
|
|
let operational: CanonicalWorkspace;
|
|
try {
|
|
operational = validateOperationalWorkspace(workspace);
|
|
} catch {
|
|
throw new WorkspaceRegistryError(
|
|
"workspace_not_activatable", "Workspace requires explicit migration",
|
|
);
|
|
}
|
|
const lease = resolveRuntimeBindingsWithWorkspaceSecrets(
|
|
operational,
|
|
process.env,
|
|
deps.config.secretRoots,
|
|
deps.secretStore,
|
|
);
|
|
try {
|
|
const [workspaceDiagnostics, inspectedAuthentication] = await Promise.all([
|
|
deps.diagnose(operational, lease.bindings, { writeProbe: false }),
|
|
deps.authDiagnoser.inspect({ live: true }),
|
|
]);
|
|
const authentication = authenticationReport(inspectedAuthentication);
|
|
return {
|
|
...workspaceDiagnostics,
|
|
activatable: workspaceDiagnostics.activatable && authentication.ready,
|
|
authentication,
|
|
};
|
|
} finally {
|
|
lease.release();
|
|
}
|
|
} catch (error) {
|
|
return errorReply(reply, error);
|
|
}
|
|
});
|
|
|
|
}
|