Files
webstudio/apps/builder/app/shared/context.server.ts
Oleg Isonen a4c3b37fc2 fix: Unblock reported MCP workflows and improve issue diagnostics (#6438)
## What this fixes

MCP authors can reuse an existing design token in JSX fragments, create
valid border shorthands with CSS variables, and bypass a font that never
settles during responsive screenshots. Common fixed page titles with
spaced separators are also accepted. Issue reports now include the
project ID and safe, bounded IDs from the failed operation, plus clearer
response and browser-startup diagnostics.

## Scope and tradeoffs

- `token("name")` attaches an existing design token; an unknown name
returns a not-found error. Empty token definitions from Builder/UI
remain definitions and follow the normal conflict rules.
- Border shorthands map a single unresolved `var()` to the remaining
width, style, or color field when the other fields are explicit.
- `waitForFonts: false` skips font readiness but still waits for stable
layout frames. The default remains unchanged.
- Fixed titles such as `Pricing - Plans` stay literal. Addition such as
`first + second` remains an expression. Wrap ambiguous subtraction in
parentheses, for example `(first - second)`. Other ambiguous title forms
may still need an exact report.
- Only recognized operation-level IDs are attached to a public GitHub
issue. Authored values, raw arguments, URLs, and credentials are
excluded. IDs from later successful diagnostic reads are not captured.
- This does not fix the underlying response-transform failure; it
improves future reports for that case.

Closes #6424
Closes #6425
Closes #6434
Closes #6439
Ref #6435
Ref #6430
Ref #6417

## Verification

- [x] Reproduced the privacy, page-expression, empty-token, and
content-mode token-reference regressions with failing tests, then
confirmed the fixes pass. A regression test also covers parenthesized
subtraction.
- [x] Full protocol, template, vision, project-build, and CLI test
suites pass; focused Builder issue-report and fragment tests pass. The
protocol, CLI, and project-build suites were rerun after the CI fix.
- [x] Relevant package typechecks, lint, package-boundary check, fixture
pipeline, generated API check, and generated docs check pass. Fixture
output has no diff.
- [x] Reviewed the authoritative MCP documentation and regenerated the
API metadata and MCP docs.
- [x] CSS parser regressions cover border, each side shorthand, and
unresolved variables in width, style, and color positions; css-data
tests, typecheck, and lint pass.
- [x] Fixed the local evaluation runner to pass the task and Webstudio
MCP tools to Luna/low. Each of the six fixtures passed at least once;
focused MCP/evaluator tests, CLI and project-build typechecks, lint, and
formatting pass.
- [x] The complete Luna/low suite passed all six fixtures in two
consecutive sequential runs. Baselines remain incompatible with this
model/effort override and were not updated.

## Follow-up

- If another fixed-title form still fails, use the new project-level
report context and the exact title shape to narrow the parser case.
2026-09-25 21:01:43 +02:00

289 lines
7.7 KiB
TypeScript

import { type AppContext } from "@webstudio-is/trpc-interface/index.server";
import env from "~/env/env.server";
import { authenticator } from "~/services/auth.server";
import { trpcSharedClient } from "~/services/trpc.server";
import { entryApi } from "./entri/entri-api.server";
import { defaultPlanFeatures } from "@webstudio-is/plans";
import {
getPlanInfo,
getAuthorizationOwnerId,
} from "@webstudio-is/plans/index.server";
import { staticEnv } from "~/env/env.static.server";
import { createClient } from "@webstudio-is/postgrest/index.server";
import { builderAuthenticator } from "~/services/builder-auth.server";
import { readLoginSessionBloomFilter } from "~/services/session.server";
import type { BloomFilter } from "~/services/bloom-filter.server";
import { isBuilder, isCanvas } from "./router-utils";
import { parseBuilderUrl } from "@webstudio-is/protocol";
import {
apiClient,
apiClientHeader,
apiClientContractVersionHeader,
apiClientVersionHeader,
} from "@webstudio-is/trpc-interface/api-compatibility";
export const extractAuthFromRequest = async (request: Request) => {
if (isCanvas(request)) {
throw new Error("Canvas requests can't have authorization context");
}
const url = new URL(request.url);
const authToken =
url.searchParams.get("authToken") ??
request.headers.get("x-auth-token") ??
undefined;
const isServiceCall = isServiceAuthorization(
request.headers.get("Authorization")
);
// Explicit credentials always win. In particular, an invalid supplied token
// must fail during context creation instead of falling back to a potentially
// more privileged browser session.
const sessionData =
authToken === undefined && isServiceCall === false
? isBuilder(request)
? await builderAuthenticator.isAuthenticated(request)
: await authenticator.isAuthenticated(request)
: undefined;
return {
authToken,
isServiceCall,
sessionData,
};
};
export const isServiceAuthorization = (authorizationHeader: string | null) => {
return (
authorizationHeader != null &&
env.TRPC_SERVER_API_TOKEN !== undefined &&
env.TRPC_SERVER_API_TOKEN.length > 0 &&
authorizationHeader === env.TRPC_SERVER_API_TOKEN
);
};
const createTokenAuthorizationContext = async (
authToken: string,
postgrest: AppContext["postgrest"]
) => {
const projectOwnerIdByToken = await postgrest.client
.from("AuthorizationToken")
.select("project:Project(id, userId)")
.eq("token", authToken)
.single();
if (projectOwnerIdByToken.error) {
throw new Error(`Project owner can't be found for token ${authToken}`);
}
const ownerId = projectOwnerIdByToken.data.project?.userId ?? null;
if (ownerId === null) {
throw new Error(
`Project ${projectOwnerIdByToken.data.project?.id} has null userId`
);
}
return {
type: "token" as const,
authToken,
ownerId,
};
};
const createAuthorizationContext = async (
request: Request,
postgrest: AppContext["postgrest"]
): Promise<AppContext["authorization"]> => {
const { authToken, isServiceCall, sessionData } =
await extractAuthFromRequest(request);
if (isServiceCall) {
return {
type: "service",
isServiceCall: true,
};
}
if (authToken != null) {
return await createTokenAuthorizationContext(authToken, postgrest);
}
if (sessionData?.userId != null) {
const userId = sessionData.userId;
let loginBloomFilter: BloomFilter | undefined = undefined;
let isLoggedInToBuilder = async (projectId: string) => {
if (loginBloomFilter === undefined) {
loginBloomFilter = await readLoginSessionBloomFilter(request);
}
return loginBloomFilter.has(projectId);
};
if (isBuilder(request)) {
isLoggedInToBuilder = async (projectId: string) => {
const parsedUrl = parseBuilderUrl(request.url);
return parsedUrl.projectId === projectId;
};
}
return {
type: "user",
userId,
sessionCreatedAt: sessionData.createdAt,
isLoggedInToBuilder,
};
}
return { type: "anonymous" };
};
const createDomainContext = () => {
const context: AppContext["domain"] = {
domainTrpc: trpcSharedClient.domain,
};
return context;
};
const getRequestOrigin = (urlStr: string) => {
const url = new URL(urlStr);
return url.origin;
};
const createDeploymentContext = (builderOrigin: string) => {
const context: AppContext["deployment"] = {
deploymentTrpc: trpcSharedClient.deployment,
env: {
BUILDER_ORIGIN: getRequestOrigin(builderOrigin),
GITHUB_REF_NAME: staticEnv.GITHUB_REF_NAME ?? "undefined",
GITHUB_SHA: staticEnv.GITHUB_SHA ?? undefined,
PUBLISHER_HOST: env.PUBLISHER_HOST,
},
};
return context;
};
const createEntriContext = () => {
return {
entryApi,
};
};
const createTrpcCache = () => {
const proceduresMaxAge = new Map<string, number>();
const setMaxAge = (path: string, value: number) => {
proceduresMaxAge.set(
path,
Math.min(proceduresMaxAge.get(path) ?? Number.MAX_SAFE_INTEGER, value)
);
};
const getMaxAge = (path: string) => proceduresMaxAge.get(path);
return {
setMaxAge,
getMaxAge,
};
};
const createApiClientContext = (request: Request): AppContext["apiClient"] => {
const client = apiClient.safeParse(request.headers.get(apiClientHeader));
if (client.success === false) {
return {
type: "unknown",
version: undefined,
contractVersion: undefined,
};
}
return {
type: client.data,
version: request.headers.get(apiClientVersionHeader) ?? undefined,
contractVersion:
request.headers.get(apiClientContractVersionHeader) ?? undefined,
};
};
export const createPostgrestContext = () => {
return { client: createClient(env.POSTGREST_URL, env.POSTGREST_API_KEY) };
};
/**
* argument buildEnv==="prod" only if we are loading project with production build
*/
export const createContext = async (request: Request): Promise<AppContext> => {
const postgrest = createPostgrestContext();
const authorization = await createAuthorizationContext(request, postgrest);
const resolvePlanInfo = async (auth: AppContext["authorization"]) => {
const ownerId = getAuthorizationOwnerId(auth);
if (ownerId === undefined) {
return {
planFeatures: defaultPlanFeatures,
purchases: [] as AppContext["purchases"],
};
}
return (
(await getPlanInfo([ownerId], { postgrest })).get(ownerId) ?? {
planFeatures: defaultPlanFeatures,
purchases: [] as AppContext["purchases"],
}
);
};
const domain = createDomainContext();
const deployment = createDeploymentContext(getRequestOrigin(request.url));
const entri = createEntriContext();
const { planFeatures, purchases } = await resolvePlanInfo(authorization);
const trpcCache = createTrpcCache();
const apiClient = createApiClientContext(request);
const getOwnerPlanFeatures = async (userId: string) => {
const results = await getPlanInfo([userId], { postgrest });
return results.get(userId)?.planFeatures ?? defaultPlanFeatures;
};
const createTokenContext = async (authToken: string) => {
const authorization = await createTokenAuthorizationContext(
authToken,
postgrest
);
const { planFeatures, purchases } = await resolvePlanInfo(authorization);
return {
authorization,
domain,
deployment,
entri,
planFeatures,
purchases,
apiClient,
trpcCache,
postgrest,
createTokenContext,
getOwnerPlanFeatures,
};
};
return {
authorization,
domain,
deployment,
entri,
planFeatures,
purchases,
apiClient,
trpcCache,
postgrest,
createTokenContext,
getOwnerPlanFeatures,
};
};