Files
webstudio/apps/builder/app/shared/sync/patch/patch-auth.server.ts
Oleg Isonen dde30ffb54 feat: Improve MCP authoring, compact output, and agent onboarding (#5883)
## 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
2026-07-15 18:11:28 +02:00

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 };
};