mirror of
https://github.com/webstudio-is/webstudio.git
synced 2026-10-30 00:27:43 +09:00
## Summary This improves how agents discover, author, inspect, and verify Webstudio projects while reducing unnecessary MCP context usage. ## What changed - Adds an **Agents** section to Project Settings with ready-to-run setup commands and client configuration for Claude Code, Codex, Cursor, and VS Code. - Makes large list and schema responses compact and paginated by default, with **verbose** available when complete records or diagnostics are needed. - Applies consistent compact output to assets, instances, styles, design tokens, CLI schemas, and project-session metadata. - Returns actionable expression warnings for props, resources, and Collection bindings, including unknown variables and risky render-time mutation resources. - Improves Collection expression guidance so agents can bind array items with the correct scoped variables. - Reports rendered audit outcomes explicitly as complete, partial, confirmation-required, or failed. - Expands runtime output schemas so MCP clients can validate responses reliably. - Strengthens the packaged CLI smoke workflow with a realistic seven-page stdio MCP scenario. ## Architecture - Consolidates `project-build` from 65 file-oriented entrypoints to 11 architecture-owned surfaces. - Separates the core model, contracts, runtime authoring, transfer, state, project sessions, MCP, visual tooling, persistence codecs, and authenticated server services. - Isolates Node-only JSX evaluation from browser-safe transfer utilities and authenticated server modules. - Keeps persistence codecs independent from authenticated database services, preventing unrelated module initialization. - Defines shared instance-path types once and reuses them across runtime modules. - Preserves Immer initialization through explicit package side-effect metadata. - Adds package tests that prevent a return to source-file entrypoints or generic exposure of Node-only surfaces. ## Validation - Full repository checks, tests, typechecks, generated-file checks, and fixtures - All 163 Builder test files and 2,141 tests - All 71 project-build test files and 1,794 tests - Lint and package-boundary validation - Full packed CLI release smoke test, including install, preview, agent setup, seven-page stdio MCP workflow, rendered audit, and structured commands
231 lines
5.9 KiB
TypeScript
231 lines
5.9 KiB
TypeScript
import {
|
|
AuthorizationError,
|
|
authorizeProject,
|
|
type AppContext,
|
|
type AuthPermit,
|
|
} from "@webstudio-is/trpc-interface/index.server";
|
|
import {
|
|
applyContentModeTransaction,
|
|
getContentModeCapabilities,
|
|
} from "@webstudio-is/project-build/runtime";
|
|
import {
|
|
type Breakpoint,
|
|
type Prop,
|
|
type StyleSource,
|
|
} from "@webstudio-is/sdk";
|
|
import {
|
|
parseData,
|
|
parseInstanceData,
|
|
parseStyleSourceSelections,
|
|
parseStyles,
|
|
} from "@webstudio-is/project-build/persistence";
|
|
import env from "~/env/env.server";
|
|
import { readAccessToken } from "~/services/token.server";
|
|
import { componentMetas } from "~/shared/component-metas.server";
|
|
import type {
|
|
NormalizedPatchRequest,
|
|
PatchEntry,
|
|
} from "./patch-normalize.server";
|
|
|
|
export type AuthorizedPatchEntry = {
|
|
entry: PatchEntry;
|
|
context: AppContext;
|
|
};
|
|
|
|
type RejectedPatchEntry = {
|
|
entry: PatchEntry;
|
|
errors: string;
|
|
};
|
|
|
|
type PatchEntryWithContext = {
|
|
entry: PatchEntry;
|
|
context: AppContext;
|
|
hasBuildPermit: boolean;
|
|
};
|
|
|
|
type GetInitialContentModeCapabilities = () =>
|
|
| ReturnType<typeof createContentModeCapabilities>
|
|
| Promise<ReturnType<typeof createContentModeCapabilities>>;
|
|
|
|
export const createWriterContext = async (
|
|
context: AppContext,
|
|
authToken: string
|
|
) => {
|
|
if (authToken.length === 0 || authToken === "session") {
|
|
throw new AuthorizationError("Collab writer token is not authorized");
|
|
}
|
|
try {
|
|
const accessToken = await readAccessToken(
|
|
authToken,
|
|
env.AUTH_WS_CLIENT_SECRET
|
|
);
|
|
if (accessToken !== undefined) {
|
|
return {
|
|
...context,
|
|
authorization: {
|
|
type: "user" as const,
|
|
userId: accessToken.userId,
|
|
sessionCreatedAt: 0,
|
|
isLoggedInToBuilder: async (projectId: string) =>
|
|
projectId === accessToken.projectId,
|
|
},
|
|
};
|
|
}
|
|
} catch {
|
|
// Not a signed collab token; try the existing shared-link token flow.
|
|
}
|
|
return context.createTokenContext(authToken);
|
|
};
|
|
|
|
const resolveEntryWriterContext = async (
|
|
context: AppContext,
|
|
entry: PatchEntry
|
|
) => {
|
|
if (entry.writer.type === "context") {
|
|
return entry.writer.context;
|
|
}
|
|
return createWriterContext(context, entry.writer.authToken);
|
|
};
|
|
|
|
export const assertProjectPermit = async ({
|
|
context,
|
|
permit,
|
|
projectId,
|
|
}: {
|
|
context: AppContext;
|
|
permit: AuthPermit;
|
|
projectId: string;
|
|
}) => {
|
|
const allowed = await authorizeProject.hasProjectPermit(
|
|
{ projectId, permit },
|
|
context
|
|
);
|
|
if (allowed === false) {
|
|
throw new AuthorizationError(
|
|
`You don't have permission to ${permit === "build" ? "build" : "edit"} this project.`
|
|
);
|
|
}
|
|
};
|
|
|
|
const hasProjectPermit = async ({
|
|
context,
|
|
permit,
|
|
projectId,
|
|
}: {
|
|
context: AppContext;
|
|
permit: AuthPermit;
|
|
projectId: string;
|
|
}) => {
|
|
return authorizeProject.hasProjectPermit({ projectId, permit }, context);
|
|
};
|
|
|
|
export const createContentModeCapabilities = (build: {
|
|
instances: string;
|
|
props: string;
|
|
styleSources: string;
|
|
styleSourceSelections: string;
|
|
styles: string;
|
|
breakpoints: string;
|
|
}) => {
|
|
return getContentModeCapabilities({
|
|
instances: parseInstanceData(build.instances),
|
|
metas: componentMetas,
|
|
props: parseData<Prop>(build.props),
|
|
styleSources: parseData<StyleSource>(build.styleSources),
|
|
styleSourceSelections: parseStyleSourceSelections(
|
|
build.styleSourceSelections
|
|
),
|
|
styles: parseStyles(build.styles),
|
|
breakpoints: parseData<Breakpoint>(build.breakpoints),
|
|
});
|
|
};
|
|
|
|
export const authorizePatchEntries = async (
|
|
context: AppContext,
|
|
patch: NormalizedPatchRequest,
|
|
getInitialContentModeCapabilities: GetInitialContentModeCapabilities
|
|
) => {
|
|
const entriesWithContext: PatchEntryWithContext[] = [];
|
|
const authorized: AuthorizedPatchEntry[] = [];
|
|
const rejected: RejectedPatchEntry[] = [];
|
|
|
|
for (const entry of patch.entries) {
|
|
try {
|
|
const writerContext = await resolveEntryWriterContext(context, entry);
|
|
const hasBuildPermit = await hasProjectPermit({
|
|
context: writerContext,
|
|
permit: "build",
|
|
projectId: patch.projectId,
|
|
});
|
|
entriesWithContext.push({
|
|
entry,
|
|
context: writerContext,
|
|
hasBuildPermit,
|
|
});
|
|
} catch (error) {
|
|
if (error instanceof AuthorizationError) {
|
|
rejected.push({ entry, errors: error.message });
|
|
continue;
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
if (entriesWithContext.every(({ hasBuildPermit }) => hasBuildPermit)) {
|
|
authorized.push(
|
|
...entriesWithContext.map(({ entry, context }) => ({ entry, context }))
|
|
);
|
|
if (authorized.length === 0 && rejected.length === 0) {
|
|
throw new Error("Transaction entries required");
|
|
}
|
|
return { authorized, rejected };
|
|
}
|
|
|
|
let capabilities = await getInitialContentModeCapabilities();
|
|
for (const {
|
|
entry,
|
|
context: writerContext,
|
|
hasBuildPermit,
|
|
} of entriesWithContext) {
|
|
try {
|
|
const contentModeResult = applyContentModeTransaction({
|
|
capabilities,
|
|
transaction: entry.transaction,
|
|
});
|
|
if (hasBuildPermit) {
|
|
if (contentModeResult?.success) {
|
|
capabilities = contentModeResult.capabilities;
|
|
}
|
|
authorized.push({ entry, context: writerContext });
|
|
continue;
|
|
}
|
|
if (contentModeResult?.success !== true) {
|
|
throw new AuthorizationError(
|
|
"You don't have permission to build this project."
|
|
);
|
|
}
|
|
await assertProjectPermit({
|
|
context: writerContext,
|
|
permit: "edit",
|
|
projectId: patch.projectId,
|
|
});
|
|
if (contentModeResult?.success) {
|
|
capabilities = contentModeResult.capabilities;
|
|
}
|
|
authorized.push({ entry, context: writerContext });
|
|
} catch (error) {
|
|
if (error instanceof AuthorizationError) {
|
|
rejected.push({ entry, errors: error.message });
|
|
continue;
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
if (authorized.length === 0 && rejected.length === 0) {
|
|
throw new Error("Transaction entries required");
|
|
}
|
|
|
|
return { authorized, rejected };
|
|
};
|