Files
webstudio/apps/builder/app/services/api-build.server.ts
Oleg Isonen d440e02990 feat: Add safer MCP authoring and draft-page verification (#5905)
- **Restore points for agent work**: Create named restore points before
major edits, review changes with a dry run, and safely revert project
content if needed.

- **Shared Slot authoring**: Agents can turn existing sections into
shared Slots and insert linked copies across pages, including valid
nested Slots.

- **DTCG and Figma token imports**: Import design tokens and CSS
variables through CLI, MCP, or direct paste, with aliases, modes,
composite values, and mappings supported.

- **Consistent token conflict handling**: Every paste workflow now
offers the same preserve, rename, or merge choices instead of silently
overwriting existing design tokens.

- **Draft-page previews**: Agents can generate and inspect previews of
draft pages without publishing them or exposing drafts in production
builds.

- **Safer MCP mutations**: Destructive operations require explicit
confirmation, invalid raw patches and invented record IDs are rejected,
and restore operations commit atomically.

- **More dependable agent contracts**: New tools have complete runtime
schemas, clearer validation errors, and consistent behavior across CLI,
MCP, Builder, and server APIs.
2026-07-17 23:16:39 +02:00

199 lines
5.0 KiB
TypeScript

import { nanoid } from "nanoid";
import { z } from "zod";
import { loadById, patchBuild } from "@webstudio-is/project/index.server";
import type { CompactBuild } from "@webstudio-is/project-build";
import {
loadBuildById,
loadDevBuildByProjectId,
} from "@webstudio-is/project-build/server";
import {
buildPatchTransaction,
publicBuildIncludes,
restorePointPatchTransaction,
} from "@webstudio-is/protocol/schema";
import type { AppContext } from "@webstudio-is/trpc-interface/index.server";
import { serializePages } from "@webstudio-is/project-migrations/pages";
import { assertApiProjectPermit } from "./api-permits.server";
import { throwApiError } from "./api-errors.server";
export const loadBuildByProjectVersion = async (
ctx: AppContext,
projectId: string,
version: number
) => {
const build = await ctx.postgrest.client
.from("Build")
.select("id")
.eq("projectId", projectId)
.eq("version", version)
.is("deployment", null)
.order("createdAt", { ascending: false })
.limit(1);
if (build.error) {
throw build.error;
}
const buildId = build.data.at(0)?.id;
if (buildId === undefined) {
return throwApiError("NOT_FOUND", "Build version not found for project");
}
return await loadBuildById(ctx, buildId);
};
export const loadReadableDevBuild = async (
ctx: AppContext,
projectId: string
) => {
await assertApiProjectPermit(ctx, projectId, "view");
return await loadDevBuildByProjectId(ctx, projectId);
};
const buildInclude = z.enum(publicBuildIncludes);
export type BuildInclude = z.infer<typeof buildInclude>;
export const buildGetInput = z.object({
projectId: z.string(),
include: z.array(buildInclude).optional(),
version: z.number().int().optional(),
});
const buildPatchInputBase = {
projectId: z.string(),
baseVersion: z.number().int(),
};
export const buildPatchInput = z.object({
...buildPatchInputBase,
transactions: z.array(buildPatchTransaction).min(1),
});
export const buildRestorePointInput = z.object({
...buildPatchInputBase,
transactions: z.array(restorePointPatchTransaction).length(1),
});
export const createBuildSnapshot = ({
build,
include,
projectId,
}: {
build: Awaited<ReturnType<typeof loadDevBuildByProjectId>>;
include: Set<BuildInclude>;
projectId: string;
}) => {
const snapshot: Record<string, unknown> = {
projectId,
buildId: build.id,
version: build.version,
};
const add = (name: BuildInclude, value: unknown) => {
if (include.has(name)) {
snapshot[name] = value;
}
};
if (include.has("pages") || include.has("folders")) {
const pages = serializePages(build.pages);
snapshot.homePageId = pages.homePageId;
snapshot.rootFolderId = pages.rootFolderId;
if (include.has("pages")) {
snapshot.redirects = pages.redirects;
snapshot.pageTemplates = pages.pageTemplates;
}
add("pages", pages.pages);
add("folders", pages.folders);
}
add("projectSettings", build.projectSettings);
add("instances", build.instances);
add("props", build.props);
add("styles", build.styles);
add("styleSources", build.styleSources);
add("styleSourceSelections", build.styleSourceSelections);
if (include.has("designTokens")) {
snapshot.designTokens = build.styleSources.filter(
(styleSource) => styleSource.type === "token"
);
}
add("resources", build.resources);
add("variables", build.dataSources);
add("breakpoints", build.breakpoints);
add("marketplaceProduct", build.marketplaceProduct);
return snapshot;
};
export const serializeProjectSummary = (
project: Awaited<ReturnType<typeof loadById>>
) => {
if (project === null) {
throwApiError("NOT_FOUND", "Project not found");
}
return {
id: project.id,
name: project.title,
domain: project.domain ?? undefined,
createdAt: project.createdAt,
updatedAt:
project.latestBuildVirtual?.createdAt ??
project.latestStaticBuild?.updatedAt ??
project.createdAt,
};
};
export const commitBuildTransactions = async ({
ctx,
projectId,
buildId,
clientVersion,
transactions,
}: {
ctx: AppContext;
projectId: string;
buildId: string;
clientVersion: number;
transactions: z.infer<typeof buildPatchTransaction>[];
}) => {
const result = await patchBuild(
{
buildId,
projectId,
clientVersion,
transactions,
},
ctx
);
if (result.status === "version_mismatched") {
throwApiError("CONFLICT", result.errors);
}
if (result.status === "error") {
throwApiError("BAD_REQUEST", result.errors);
}
if (result.status !== "ok") {
return throwApiError("BAD_REQUEST", "Unexpected patch result");
}
return { version: result.version };
};
export const commitBuildPatch = async ({
build,
ctx,
projectId,
payload,
}: {
build: CompactBuild;
ctx: AppContext;
projectId: string;
payload: z.infer<typeof buildPatchTransaction>["payload"];
}) => {
return commitBuildTransactions({
ctx,
projectId,
buildId: build.id,
clientVersion: build.version,
transactions: [{ id: nanoid(), payload }],
});
};