Files
webstudio/apps/builder/app/services/api-router.server.ts
Oleg Isonen 3d0f19ffe0 fix: complete and speed up publish validation (#6478)
## Summary

Complete Validate for large projects with linked MDX content, using the
same validation API in the dialog and MCP. Publishing continues
independently of diagnostics.

- Load MDX sources omitted from the embedded artifact and report storage
failures with typed diagnostic reasons.
- Reuse parsed MDX and template scope indexes; cache source reads in the
shared asset loader.
- Exclude conservative non-MDX query candidates while retaining explicit
invalid references for diagnostics.
- Give validation requests a 60-second server execution budget.
- Support both server bundles in E2E tests and make OAuth logging
initialization repeatable.
- Raise the project bundle import limit from 20 to 32 MiB to reproduce
the customer project.

## TODO / acceptance checklist

- [x] Import and inspect the customer project locally and on staging.
- [x] Reproduce missing embedded MDX sources and slow template
materialization.
- [x] Load omitted MDX sources and discover nested template
dependencies.
- [x] Report source-read failures using typed reasons rather than
matching error messages.
- [x] Reuse template scope indexes and remove duplicate source caching.
- [x] Route dialog Validate through the same API as MCP, after flushing
editor content.
- [x] Preserve publishing in parallel with diagnostics, including failed
or unfinished checks.
- [x] Fix JSON collection files being treated as MDX query candidates.
- [x] Preserve explicit invalid file references so they still produce
diagnostics.
- [x] Fix E2E routing across both generated server bundles and repeated
OAuth logging initialization.
- [x] Demonstrate the new review regressions fail before the fixes and
pass after them.
- [x] Run targeted tests, relevant typechecks, lint, formatting, package
boundaries, and generated API checks.
- [x] Run the full fixture pipeline and production builder build; no
generated fixture changes.
- [x] Build the exact staging project locally after the final review
changes.
- [x] Validate the exact staging project through MCP/CLI after the final
deployment.
- [x] Complete an actual staging publish after the final deployment.
- [x] Address PR comments: simplify the stream mock, explain every
diagnostic reason, and retain the existing diagnostic code contract.
- [x] Review documentation impact; these fixes restore expected behavior
and require no user instruction changes.
- [x] Finish CI on the final commit, including all six Builder E2E
shards.

## Verification

Local checks passed: 179 project-build tests, 78 CLI prebuild tests, 8
asset publication tests, 42 server tests, and 9 publish UI/helper tests.
The imported staging project also builds successfully.

The new explicit-reference regression and repeated-initialization
regression both failed before their fixes and passed afterward. A
production build serves the login route and dispatches tRPC requests to
its separate server bundle. Validation on staging project
`b9d23e54-5dbf-45c4-b6d8-bc4cf842a468` completed in 25.3 seconds after
deployment and 23.5 seconds after publishing: 92 documents included, 0
omitted, and 0 MDX errors. The [original staging publish job
rerun](https://github.com/webstudio-is/webstudio-saas/actions/runs/36647770120/job/109819143695)
succeeded end to end with the final artifact, including CLI/Remix
builds, worker deployment, and database updates. [CI on commit
`01a9d772dc`](https://github.com/webstudio-is/webstudio/actions/runs/36696154831)
is green, including all six Builder E2E shards and Windows/Ubuntu
fixture checks.

The comment follow-ups simplify the publication test mock and document
all diagnostic reasons. Production behavior is unchanged. The 8
publication tests, 6 schema tests, package typecheck, formatter, and
lint pass. Disabling the source cache makes the simplified regression
fail as expected. Final staging validation passed in 27.1 seconds with
92 documents and no omissions or MDX errors. One additional publish
attempt hit a database statement timeout during project sync, before the
build. A [retry with no code changes succeeded end to
end](https://github.com/webstudio-is/webstudio-saas/actions/runs/36647770120/job/109826767589).
CI is green on `01a9d772dc`.

Ref #3550
2026-09-30 11:40:15 +02:00

1250 lines
36 KiB
TypeScript

import { z } from "zod";
import {
AuthorizationError,
authorizeProject,
procedure,
router,
type AppContext,
type AuthPermit,
} from "@webstudio-is/trpc-interface/index.server";
import {
loadById,
setMarketplaceApprovalStatus,
} from "@webstudio-is/project/index.server";
import { loadDevBuildByProjectId } from "@webstudio-is/project-build/server";
import {
createProjectDomain,
createUnpublishJobId,
deleteProjectDomain,
getDefaultPublishDomains,
getVerifiedPublishDomains,
getProjectPublishJob,
listProjectDomains,
listProjectPublishes,
publishProject,
updateProjectDomain,
unpublishProjectDomains,
verifyProjectDomain,
} from "@webstudio-is/domain/index.server";
import {
getBuilderRuntimeOperationInputSchema,
getBuildIntegrityIssues,
formatBuildIntegrityError,
paginateOutput,
paginatedOutputInputSchema,
} from "@webstudio-is/project-build/runtime";
import {
getDetailedAssetResourceQueryError,
assetQueryDiagnosticIssue,
assetQueryRequest,
assetQuerySetupIssue,
getAssetQueryWhereMetrics,
validateAssetQueryAgainstCatalog,
type AssetResourceQueryError,
} from "@webstudio-is/content-engine";
import {
loadBuilderAssetFieldCatalog,
loadAssetDataByProject,
loadAssetFoldersByProject,
PostgresAssetRepository,
} from "@webstudio-is/asset-uploader/server";
import { createAssetContentSession } from "@webstudio-is/content-engine/asset-content-session";
import { buildPatchTransaction } from "@webstudio-is/protocol/schema";
import {
publicApiContractVersion,
publicApiOperationRequiresServerSupport,
publicApiOperations,
issueReportInput,
} from "@webstudio-is/protocol";
import {
assertApiProjectPermit,
getTokenPermits,
loadApiToken,
} from "./api-permits.server";
import { componentMetas } from "~/shared/component-metas.server";
import type { Asset, AssetFolder } from "@webstudio-is/sdk";
import {
applyContentModeTransaction,
createContentBlockApplication,
getContentModeCapabilities,
type BuilderRuntimeContext,
} from "@webstudio-is/project-build/runtime";
import type { CompactBuild } from "@webstudio-is/project-build";
import {
runtimeOperationContracts,
type RuntimeOperationId,
} from "@webstudio-is/project-build/contracts";
import { builderNamespaces } from "@webstudio-is/project-build/contracts";
import type { BuilderNamespace } from "@webstudio-is/project-build/contracts";
import type { BuilderApiCapability } from "@webstudio-is/project-build/contracts";
import {
buildGetInput,
buildPatchInput,
commitBuildPatch,
commitBuildTransactions,
createBuildSnapshot,
loadBuildByProjectVersion,
loadReadableDevBuild,
serializeProjectSummary,
} from "./api-build.server";
import { throwApiError } from "./api-errors.server";
import {
createBuilderRuntimeState,
executeApiRuntimeMutation,
executeApiRuntimeOperation,
} from "./api-runtime.server";
import { createAssetClient } from "../shared/asset-client";
import { previewProjectAssetQuery } from "./asset-query-preview.server";
import { publishConfiguredIssueReport } from "./github-issue-report.server";
import { loadContentDatabasePublishDiagnostics } from "./content-database-publish-diagnostics.server";
const assertApiPublishDomains = ({
auth,
domains,
project,
}: {
auth: Awaited<ReturnType<typeof assertApiProjectPermit>>;
domains: string[];
project: { domain: string };
}) => {
if (auth.type === "user") {
return;
}
const { token } = auth;
if (token.canPublish === true) {
return;
}
if (
token.relation === "builders" &&
domains.length > 0 &&
domains.every((domain) => domain === project.domain)
) {
return;
}
throw new AuthorizationError(
"Authorization token does not have publish permission"
);
};
const projectIdInput = z.object({ projectId: z.string() });
const assetQueryInput = projectIdInput.extend(assetQueryRequest.shape);
const assetQueryValidationInput = projectIdInput.extend({
query: assetQueryRequest.shape.query,
});
const asRecord = (value: unknown): Record<string, unknown> | undefined =>
typeof value === "object" && value !== null && Array.isArray(value) === false
? (value as Record<string, unknown>)
: undefined;
const getAssetQueryApiIssues = (error: AssetResourceQueryError) => {
const details = asRecord(error.details);
const sourceDiagnostics = Array.isArray(details?.diagnostics)
? details.diagnostics
: details?.path === undefined
? []
: [details];
const sourceIssues = sourceDiagnostics.flatMap((value) => {
const diagnostic = asRecord(value);
if (diagnostic === undefined || typeof diagnostic.path !== "string") {
return [];
}
const code =
typeof diagnostic.code === "string" ? diagnostic.code : error.code;
const message =
typeof diagnostic.message === "string"
? diagnostic.message
: error.message;
const detail = [
typeof diagnostic.line === "number"
? `Line ${diagnostic.line}${
typeof diagnostic.column === "number"
? `, column ${diagnostic.column}`
: ""
}`
: undefined,
typeof diagnostic.scope === "string"
? `Scope: ${diagnostic.scope}`
: undefined,
typeof diagnostic.severity === "string"
? `Severity: ${diagnostic.severity}`
: undefined,
typeof diagnostic.assetId === "string"
? `Asset: ${diagnostic.assetId}`
: undefined,
]
.filter((item) => item !== undefined)
.join(". ");
const parsedSourceRange =
assetQueryDiagnosticIssue.shape.sourceRange.safeParse(
diagnostic.sourceRange
);
const sourceRange = parsedSourceRange.success
? parsedSourceRange.data
: undefined;
const severity = diagnostic.severity === "warning" ? "warning" : "error";
const scope = diagnostic.scope === "database" ? "database" : "query";
const phase =
diagnostic.phase === "metadata" || diagnostic.phase === "reference"
? diagnostic.phase
: "source";
return [
{
code,
path: [diagnostic.path],
message,
constraint: code,
...(detail === "" ? {} : { detail }),
severity,
scope,
phase,
file: diagnostic.path,
...(typeof diagnostic.assetId === "string"
? { assetId: diagnostic.assetId }
: {}),
...(typeof diagnostic.line === "number"
? { line: diagnostic.line }
: {}),
...(typeof diagnostic.column === "number"
? { column: diagnostic.column }
: {}),
...(typeof diagnostic.reference === "string"
? { reference: diagnostic.reference }
: {}),
...(typeof diagnostic.nodeType === "string"
? { nodeType: diagnostic.nodeType }
: {}),
...(typeof diagnostic.reason === "string"
? { reason: diagnostic.reason }
: {}),
...(sourceRange === undefined ? {} : { sourceRange }),
},
];
});
const queryIssues = Array.isArray(details?.issues)
? details.issues.flatMap((value) => {
const parsedIssue = assetQuerySetupIssue.safeParse(value);
if (parsedIssue.success === false) {
return [];
}
const issue = parsedIssue.data;
return [
{
code: issue.code,
path: issue.path,
message: issue.message,
constraint: issue.code,
severity: issue.severity,
...(issue.severity === "warning"
? { detail: "Severity: warning" }
: {}),
},
];
})
: [];
return [...sourceIssues, ...queryIssues];
};
const throwAssetQueryApiError = (error: unknown): never => {
const queryError = getDetailedAssetResourceQueryError(error);
if (queryError !== undefined) {
const issues = getAssetQueryApiIssues(queryError);
return throwApiError(
queryError.status === 409 ? "CONFLICT" : "BAD_REQUEST",
queryError.message,
{
webstudioCode: queryError.code,
...(issues.length === 0 ? {} : { issues }),
}
);
}
throw error;
};
const paginatedProjectInput = projectIdInput.extend(
paginatedOutputInputSchema.shape
);
const paginateProjectItems = <Item>(
items: readonly Item[],
input: z.infer<typeof paginatedProjectInput>
) =>
paginateOutput({
items,
cursor: input.cursor,
limit: input.limit,
verbose: input.verbose,
filters: {},
});
const withProjectId = <Schema extends z.ZodTypeAny>(
input: Schema
): z.ZodType<z.infer<Schema> & { projectId: string }> =>
z.intersection(projectIdInput, input) as z.ZodType<
z.infer<Schema> & { projectId: string }
>;
const runtimeProjectInput = (id: RuntimeOperationId) =>
withProjectId(getBuilderRuntimeOperationInputSchema(id));
type ProjectApiCapability = Extract<AuthPermit, BuilderApiCapability>;
type ServerOnlyPublicApiOperationMeta = {
command: string;
client: string;
invalidatesNamespaces?: readonly BuilderNamespace[];
};
const publicApiMeta = (
permit: BuilderApiCapability,
operation?: ServerOnlyPublicApiOperationMeta
) => ({
publicApiPermit: permit,
publicApiOperation: operation,
});
const applyBuildPayload = async <Result extends Record<string, unknown> = {}>(
ctx: AppContext,
build: Awaited<ReturnType<typeof loadDevBuildByProjectId>>,
projectId: string,
payload: z.infer<typeof buildPatchTransaction>["payload"],
result?: Result
) => ({
...(await commitBuildPatch({ build, ctx, projectId, payload })),
...(result ?? {}),
});
const projectQuery = <Schema extends z.ZodType<{ projectId: string }>, Result>(
input: Schema,
permit: ProjectApiCapability,
handler: (args: {
ctx: AppContext;
input: z.infer<Schema>;
auth: Awaited<ReturnType<typeof assertApiProjectPermit>>;
}) => Promise<Result>,
operation?: ServerOnlyPublicApiOperationMeta
) =>
procedure
.meta(publicApiMeta(permit, operation))
.input(input)
.query(async ({ ctx, input }) => {
const parsedInput = input as z.infer<Schema>;
return await handler({
ctx,
input: parsedInput,
auth: await assertApiProjectPermit(ctx, parsedInput.projectId, permit),
});
});
const projectMutation = <
Schema extends z.ZodType<{ projectId: string }>,
Result,
>(
input: Schema,
permit: ProjectApiCapability,
handler: (args: {
ctx: AppContext;
input: z.infer<Schema>;
auth: Awaited<ReturnType<typeof assertApiProjectPermit>>;
}) => Promise<Result>,
operation?: ServerOnlyPublicApiOperationMeta
) =>
procedure
.meta(publicApiMeta(permit, operation))
.input(input)
.mutation(async ({ ctx, input }) => {
const parsedInput = input as z.infer<Schema>;
return await handler({
ctx,
input: parsedInput,
auth: await assertApiProjectPermit(ctx, parsedInput.projectId, permit),
});
});
const buildQuery = <Schema extends z.ZodType<{ projectId: string }>, Result>(
input: Schema,
handler: (args: {
ctx: AppContext;
input: z.infer<Schema>;
build: Awaited<ReturnType<typeof loadDevBuildByProjectId>>;
}) => Promise<Result>
) =>
procedure
.meta(publicApiMeta("view"))
.input(input)
.query(async ({ ctx, input }) => {
const parsedInput = input as z.infer<Schema>;
return await handler({
ctx,
input: parsedInput,
build: await loadReadableDevBuild(ctx, parsedInput.projectId),
});
});
const runtimeBuildQuery = <Result = unknown>(id: RuntimeOperationId) =>
buildQuery(runtimeProjectInput(id), async ({ input, build }) =>
executeApiRuntimeOperation<Result>({
id,
build,
input,
})
);
const withConfirm = <Schema extends z.ZodTypeAny>(input: Schema) =>
z.intersection(input, z.object({ confirm: z.literal(true) })) as z.ZodType<
z.infer<Schema> & { confirm: true }
>;
const runtimeMutationInput = (
id: RuntimeOperationId,
requiresConfirm: boolean
) => {
const input = runtimeProjectInput(id);
return requiresConfirm ? withConfirm(input) : input;
};
const buildMutation = <Schema extends z.ZodType<{ projectId: string }>, Result>(
input: Schema,
handler: (args: {
ctx: AppContext;
input: z.infer<Schema>;
build: Awaited<ReturnType<typeof loadDevBuildByProjectId>>;
commit: BuildCommit;
}) => Promise<Result>
) =>
procedure
.meta(publicApiMeta("build"))
.input(input)
.mutation(async ({ ctx, input }) => {
const parsedInput = input as z.infer<Schema>;
await assertApiProjectPermit(ctx, parsedInput.projectId, "build");
const build = await loadDevBuildByProjectId(ctx, parsedInput.projectId);
return await handler({
ctx,
input: parsedInput,
build,
commit: async <CommitResult extends Record<string, unknown> = {}>(
payload: z.infer<typeof buildPatchTransaction>["payload"],
result?: CommitResult
) =>
(await applyBuildPayload(
ctx,
build,
parsedInput.projectId,
payload,
result
)) as { version: number } & CommitResult,
});
});
const runtimeBuildMutation = <Result extends Record<string, unknown> = {}>(
id: RuntimeOperationId,
requiresConfirm: boolean
) =>
buildMutation(
runtimeMutationInput(id, requiresConfirm),
async ({ input, build, commit }) =>
commitRuntimeMutation<Result>({
id,
build,
input,
commit,
})
);
type BuildCommit = <CommitResult extends Record<string, unknown> = {}>(
payload: z.infer<typeof buildPatchTransaction>["payload"],
result?: CommitResult
) => Promise<{ version: number } & CommitResult>;
const commitRuntimeMutation = async <
Result extends Record<string, unknown> = Record<string, unknown>,
>({
id,
build,
assets,
assetFolders,
input,
commit,
context,
}: {
id: RuntimeOperationId;
build: Awaited<ReturnType<typeof loadDevBuildByProjectId>>;
assets?: Asset[];
assetFolders?: AssetFolder[];
input: unknown;
commit: BuildCommit;
context?: Partial<BuilderRuntimeContext>;
}) => {
const mutation = await executeApiRuntimeMutation<Result>({
id,
build,
assets,
assetFolders,
input,
context,
});
if (mutation.noop || mutation.payload.length === 0) {
return {
version: build.version,
...mutation.result,
};
}
return await commit(mutation.payload, mutation.result);
};
const createContentModeCapabilitiesFromBuild = (build: CompactBuild) => {
const state = createBuilderRuntimeState(build);
return getContentModeCapabilities({
instances: state.instances!,
metas: componentMetas,
props: state.props!,
styleSources: state.styleSources!,
styleSourceSelections: state.styleSourceSelections,
styles: state.styles,
breakpoints: state.breakpoints,
});
};
const assertContentOrBuildPayload = ({
auth,
build,
payload,
}: {
auth: Awaited<ReturnType<typeof assertApiProjectPermit>>;
build: CompactBuild;
payload: z.infer<typeof buildPatchTransaction>["payload"];
}) => {
if (auth.permits.includes("build")) {
return;
}
const result = applyContentModeTransaction({
capabilities: createContentModeCapabilitiesFromBuild(build),
transaction: { id: "api-content-mode", payload },
});
if (result.success === false) {
throw new AuthorizationError(result.error);
}
};
const contentOrBuildMutation = <
Schema extends z.ZodType<{ projectId: string }>,
Result,
>(
input: Schema,
handler: (args: {
ctx: AppContext;
input: z.infer<Schema>;
build: Awaited<ReturnType<typeof loadDevBuildByProjectId>>;
commit: BuildCommit;
}) => Promise<Result>
) =>
procedure
.meta(publicApiMeta("edit"))
.input(input)
.mutation(async ({ ctx, input }) => {
const parsedInput = input as z.infer<Schema>;
const auth = await assertApiProjectPermit(
ctx,
parsedInput.projectId,
"edit"
);
const build = await loadDevBuildByProjectId(ctx, parsedInput.projectId);
return await handler({
ctx,
input: parsedInput,
build,
commit: async <CommitResult extends Record<string, unknown> = {}>(
payload: z.infer<typeof buildPatchTransaction>["payload"],
result?: CommitResult
) => {
assertContentOrBuildPayload({ auth, build, payload });
return (await applyBuildPayload(
ctx,
build,
parsedInput.projectId,
payload,
result
)) as { version: number } & CommitResult;
},
});
});
const runtimeContentOrBuildMutation = <
Result extends Record<string, unknown> = {},
>(
id: RuntimeOperationId,
requiresConfirm: boolean
) =>
contentOrBuildMutation(
runtimeMutationInput(id, requiresConfirm),
async ({ input, build, commit }) =>
commitRuntimeMutation<Result>({
id,
build,
input,
commit,
})
);
const loadRuntimeAssetData = async (ctx: AppContext, projectId: string) => {
return loadAssetDataByProject(projectId, ctx, {
skipPermissionsCheck: true,
});
};
const createServerContentBlockApplication = ({
ctx,
projectId,
build,
assets,
assetFolders,
}: {
ctx: AppContext;
projectId: string;
build: CompactBuild;
assets: Asset[];
assetFolders: AssetFolder[];
}) => {
const state = createBuilderRuntimeState(build, assets, assetFolders);
const repository = new PostgresAssetRepository({
projectId,
context: ctx,
assetStore: createAssetClient(),
});
const session = createAssetContentSession({
repository,
authorize: ({ assetId }) => state.assets?.has(assetId) === true,
});
return createContentBlockApplication({
projectId,
session,
metas: componentMetas,
});
};
const runtimeAssetsQuery = <Result = unknown>(id: RuntimeOperationId) =>
projectQuery(runtimeProjectInput(id), "view", async ({ ctx, input }) => {
const [{ assets, assetFolders }, build] = await Promise.all([
loadRuntimeAssetData(ctx, input.projectId),
loadDevBuildByProjectId(ctx, input.projectId),
]);
const contentBlockApplication = id.startsWith("contentBlocks.")
? createServerContentBlockApplication({
ctx,
projectId: input.projectId,
build,
assets,
assetFolders,
})
: undefined;
return executeApiRuntimeOperation<Result>({
id,
build,
assets,
assetFolders,
input,
context: { contentBlockApplication },
});
});
const runtimeAssetsMutation = <Result extends Record<string, unknown> = {}>(
id: RuntimeOperationId
) =>
buildMutation(
runtimeMutationInput(id, true),
async ({ ctx, input, build, commit }) => {
const { assets, assetFolders } = await loadRuntimeAssetData(
ctx,
input.projectId
);
return await commitRuntimeMutation<Result>({
id,
build,
assets,
assetFolders,
input,
commit,
});
}
);
const runtimeContentBlockMutation = <
Result extends Record<string, unknown> = {},
>(
id: RuntimeOperationId
) =>
contentOrBuildMutation(
runtimeMutationInput(id, false),
async ({ ctx, input, build, commit }) => {
const { assets, assetFolders } = await loadRuntimeAssetData(
ctx,
input.projectId
);
return await commitRuntimeMutation<Result>({
id,
build,
assets,
assetFolders,
input,
commit,
context: {
contentBlockApplication: createServerContentBlockApplication({
ctx,
projectId: input.projectId,
build,
assets,
assetFolders,
}),
},
});
}
);
type RuntimeOperationRouteTree = Record<string, unknown>;
type RouterRecord = Parameters<typeof router>[0];
type RuntimeOperation = (typeof runtimeOperationContracts)[number];
const addRuntimeOperationRoute = (
tree: RuntimeOperationRouteTree,
operation: RuntimeOperation
) => {
const path = operation.id.split(".");
const routeName = path.at(-1);
if (routeName === undefined || path.length < 2) {
throw new Error(`Runtime operation "${operation.id}" must be namespaced.`);
}
let namespace = tree;
for (const segment of path.slice(0, -1)) {
const existing = namespace[segment];
if (existing === undefined) {
namespace[segment] = {};
}
namespace = namespace[segment] as RuntimeOperationRouteTree;
}
if (namespace[routeName] !== undefined) {
throw new Error(`Duplicate runtime operation route "${operation.id}".`);
}
namespace[routeName] = createRuntimeOperationProcedure(operation);
};
const createRuntimeOperationProcedure = (operation: RuntimeOperation) => {
const { id } = operation;
if (operation.requiresAssets) {
return operation.kind === "read"
? runtimeAssetsQuery(id)
: id.startsWith("contentBlocks.")
? runtimeContentBlockMutation(id)
: runtimeAssetsMutation(id);
}
if (operation.kind === "read") {
return runtimeBuildQuery(id);
}
if (operation.permit === "edit") {
return runtimeContentOrBuildMutation(id, operation.requiresConfirm);
}
return runtimeBuildMutation(id, operation.requiresConfirm);
};
const createRuntimeOperationRouters = () => {
const tree: RuntimeOperationRouteTree = {};
for (const operation of runtimeOperationContracts) {
addRuntimeOperationRoute(tree, operation);
}
const createRouters = (routes: RuntimeOperationRouteTree): RouterRecord =>
Object.fromEntries(
Object.entries(routes).map(([name, value]) => {
if (
typeof value === "object" &&
value !== null &&
"_def" in value === false
) {
return [
name,
router(createRouters(value as RuntimeOperationRouteTree)),
];
}
return [name, value];
})
) as RouterRecord;
return createRouters(tree);
};
// Runtime routes are generated from contracts; api-router tests verify their
// concrete paths against the public operation catalog.
const runtimeOperationRouters = createRuntimeOperationRouters();
export const apiRouter = router({
auth: router({
me: procedure
.meta(
publicApiMeta("view", {
command: "whoami",
client: "getApiTokenInfo",
})
)
.query(async ({ ctx }) => {
const token = await loadApiToken(ctx);
const permits = getTokenPermits(token, ctx);
return {
actor: { type: "token" as const, tokenId: token.token },
projectId: token.projectId,
relation: token.relation,
permits,
};
}),
}),
projects: router({
permissions: procedure
.meta(
publicApiMeta("view", {
command: "permissions",
client: "getProjectPermissions",
})
)
.input(projectIdInput)
.query(async ({ ctx, input }) => {
const token = await loadApiToken(ctx);
if (token.projectId !== input.projectId) {
throw new AuthorizationError(
"Authorization token is not valid for project"
);
}
const permits = getTokenPermits(token, ctx);
const canUseProject = await authorizeProject.hasProjectPermit(
{ projectId: input.projectId, permit: "view" },
ctx
);
if (canUseProject === false) {
throw new AuthorizationError("You don't have access to this project");
}
return {
relation: token.relation,
permits,
canView: permits.includes("view"),
canEdit: permits.includes("edit"),
canBuild: permits.includes("build"),
canAdmin: permits.includes("admin"),
canOwn: false,
canUseApi: permits.includes("api"),
canPublish: token.canPublish === true,
canPublishProjectDomain:
token.canPublish === true || token.relation === "builders",
canPublishCustomDomains: token.canPublish === true,
apiContract: {
version: publicApiContractVersion,
operationIds: publicApiOperations.flatMap((operation) =>
publicApiOperationRequiresServerSupport(operation)
? [operation.id]
: []
),
},
};
}),
get: projectQuery(
projectIdInput,
"view",
async ({ ctx, input }) => {
const project = await loadById(input.projectId, ctx);
const build = await loadDevBuildByProjectId(ctx, input.projectId);
return {
...serializeProjectSummary(project),
buildId: build.id,
version: build.version,
homePageId: build.pages.homePageId,
features: ctx.planFeatures,
};
},
{ command: "inspect", client: "getProjectInfo" }
),
submitMarketplaceProduct: projectMutation(
projectIdInput.extend({
acknowledgePublicSubmission: z
.literal(true)
.describe(
"Acknowledge that submission starts public marketplace review"
),
}),
"edit",
async ({ ctx, input }) => {
const build = await loadDevBuildByProjectId(ctx, input.projectId);
if (build.marketplaceProduct === undefined) {
return throwApiError(
"BAD_REQUEST",
"Complete the marketplace product metadata before submitting it for review"
);
}
const project = await setMarketplaceApprovalStatus(
{
projectId: input.projectId,
marketplaceApprovalStatus: "PENDING",
},
ctx
);
return {
marketplaceApprovalStatus: project.marketplaceApprovalStatus,
};
},
{
command: "submit-marketplace-product",
client: "submitMarketplaceProduct",
}
),
}),
reports: router({
issue: projectMutation(
withProjectId(issueReportInput),
"view",
async ({ ctx, input }) => {
const { projectId: _projectId, ...report } = input;
return await publishConfiguredIssueReport({
...report,
...(report.runtime === undefined
? {}
: {
runtime: {
...report.runtime,
...(ctx.apiClient.version === undefined
? {}
: { cliVersion: ctx.apiClient.version }),
apiContractVersion: publicApiContractVersion,
},
}),
});
},
{ command: "report-issue", client: "reportIssue" }
),
}),
build: router({
get: projectQuery(
buildGetInput,
"view",
async ({ ctx, input }) => {
const build =
input.version === undefined
? await loadDevBuildByProjectId(ctx, input.projectId)
: await loadBuildByProjectVersion(
ctx,
input.projectId,
input.version
);
if (build.projectId !== input.projectId) {
throwApiError("NOT_FOUND", "Build not found for project");
}
const include = new Set(input.include ?? []);
const snapshot = createBuildSnapshot({
build,
include,
projectId: input.projectId,
});
if (include.has("assets")) {
const { assets, assetFolders } = await loadAssetDataByProject(
input.projectId,
ctx
);
snapshot.assets = assets;
if (include.has("assetFolders")) {
snapshot.assetFolders = assetFolders;
}
} else if (include.has("assetFolders")) {
snapshot.assetFolders = await loadAssetFoldersByProject(
input.projectId,
ctx
);
}
return snapshot;
},
{ command: "snapshot", client: "getBuildSnapshot" }
),
patch: projectMutation(
buildPatchInput,
"build",
async ({ ctx, input }) => {
const build = await loadDevBuildByProjectId(ctx, input.projectId);
return commitBuildTransactions({
ctx,
projectId: input.projectId,
buildId: build.id,
clientVersion: input.baseVersion,
transactions: input.transactions,
});
},
{
command: "apply-patch",
client: "applyBuildPatch",
invalidatesNamespaces: builderNamespaces,
}
),
}),
...runtimeOperationRouters,
assetQueries: router({
validate: projectQuery(
assetQueryValidationInput,
"view",
async ({ ctx, input }) => {
try {
const catalog = await loadBuilderAssetFieldCatalog({
projectId: input.projectId,
context: ctx,
assetClient: createAssetClient(),
});
const validated = validateAssetQueryAgainstCatalog({
query: input.query,
catalog,
});
return {
valid: true as const,
referencedFieldPaths: validated.referencedFieldPaths,
filterCount: getAssetQueryWhereMetrics(validated.query.where)
.filters,
sortCount: validated.query.sort.length,
warnings: validated.warnings,
issues: validated.warningIssues,
};
} catch (error) {
return throwAssetQueryApiError(error);
}
},
{
command: "validate-asset-query",
client: "validateAssetQuery",
}
),
preview: projectQuery(
assetQueryInput,
"view",
async ({ ctx, input }) => {
try {
const { projectId, ...request } = input;
return await previewProjectAssetQuery({
projectId,
request,
context: ctx,
});
} catch (error) {
return throwAssetQueryApiError(error);
}
},
{
command: "preview-asset-query",
client: "previewAssetQuery",
}
),
fieldCatalog: projectQuery(
projectIdInput,
"view",
async ({ ctx, input }) =>
await loadBuilderAssetFieldCatalog({
projectId: input.projectId,
context: ctx,
assetClient: createAssetClient(),
}),
{
command: "get-asset-field-catalog",
client: "getAssetFieldCatalog",
}
),
}),
publish: router({
validate: projectQuery(
projectIdInput.extend({
target: z.enum(["staging", "production"]),
domains: z.array(z.string()).optional(),
}),
"edit",
async ({ auth, ctx, input }) => {
const project = await loadById(input.projectId, ctx);
const domains =
input.domains ?? getDefaultPublishDomains(project, input.target);
assertApiPublishDomains({ auth, domains, project });
if (domains.length === 0) {
throwApiError(
"BAD_REQUEST",
"Select at least one domain to publish."
);
}
const verifiedDomains = getVerifiedPublishDomains(project, domains);
const invalidDomains = domains.filter(
(domain) => verifiedDomains.includes(domain) === false
);
if (invalidDomains.length > 0) {
throwApiError(
"BAD_REQUEST",
`Publish domains must belong to this project and be active and verified: ${invalidDomains.join(", ")}`
);
}
const build = await loadDevBuildByProjectId(ctx, input.projectId);
const issues = getBuildIntegrityIssues(build);
if (issues[0] !== undefined) {
throwApiError(
"BAD_REQUEST",
formatBuildIntegrityError(issues[0], "Cannot publish"),
{ issues }
);
}
const diagnostics = await loadContentDatabasePublishDiagnostics(
input.projectId,
ctx,
{ build }
);
return {
valid: true,
target: input.target,
domains,
diagnostics,
};
},
{ command: "validate-publish", client: "validatePublish" }
),
list: projectQuery(
paginatedProjectInput,
"view",
async ({ ctx, input }) => {
const { publishes } = await listProjectPublishes(input.projectId, ctx);
const { items, ...pagination } = paginateProjectItems(publishes, input);
return { publishes: items, ...pagination };
},
{ command: "list-publishes", client: "listPublishes" }
),
create: projectMutation(
projectIdInput.extend({
target: z.enum(["staging", "production"]),
domains: z.array(z.string()).optional(),
message: z.string().optional(),
idempotencyKey: z.string().optional(),
}),
"edit",
async ({ auth, ctx, input }) => {
const project = await loadById(input.projectId, ctx);
const domains =
input.domains ?? getDefaultPublishDomains(project, input.target);
assertApiPublishDomains({ auth, domains, project });
const { build, deploymentNotImplemented } = await publishProject(
{ project, domains, target: input.target },
ctx
);
return {
jobId: build.id,
warning: deploymentNotImplemented
? "Publish was recorded locally, but the deployment service is not available in this environment."
: undefined,
};
},
{ command: "publish", client: "publish" }
),
getJob: projectQuery(
projectIdInput.extend({ jobId: z.string() }),
"view",
async ({ ctx, input }) => {
const publishJob = await getProjectPublishJob(input, ctx);
if (publishJob === undefined) {
return throwApiError("NOT_FOUND", "Publish job not found");
}
return publishJob;
},
{ command: "get-publish-job", client: "getPublishJob" }
),
unpublish: projectMutation(
projectIdInput.extend({
target: z.enum(["staging", "production"]),
domains: z.array(z.string()).optional(),
message: z.string().optional(),
idempotencyKey: z.string().optional(),
confirm: z.literal(true),
}),
"edit",
async ({ auth, ctx, input }) => {
const project = await loadById(input.projectId, ctx);
const domains =
input.domains ?? getDefaultPublishDomains(project, input.target);
assertApiPublishDomains({ auth, domains, project });
await unpublishProjectDomains(
{ projectId: input.projectId, domains },
ctx
);
return { jobId: input.idempotencyKey ?? createUnpublishJobId() };
},
{ command: "unpublish", client: "unpublish" }
),
}),
domains: router({
list: projectQuery(
paginatedProjectInput,
"view",
async ({ ctx, input }) => {
const { items, ...pagination } = paginateProjectItems(
await listProjectDomains(input.projectId, ctx),
input
);
return { domains: items, ...pagination };
},
{ command: "list-domains", client: "listDomains" }
),
create: projectMutation(
projectIdInput.extend({ domain: z.string() }),
"admin",
async ({ ctx, input }) => {
return await createProjectDomain(input, ctx);
},
{ command: "create-domain", client: "createDomain" }
),
update: projectMutation(
projectIdInput.extend({
domainId: z.string(),
updates: z.object({ domain: z.string().optional() }),
}),
"admin",
async ({ ctx, input }) => {
return await updateProjectDomain(input, ctx);
},
{ command: "update-domain", client: "updateDomain" }
),
delete: projectMutation(
projectIdInput.extend({
domainId: z.string(),
confirm: z.literal(true),
}),
"admin",
async ({ ctx, input }) => {
return await deleteProjectDomain(input, ctx);
},
{ command: "delete-domain", client: "deleteDomain" }
),
verify: projectMutation(
projectIdInput.extend({ domainId: z.string() }),
"admin",
async ({ ctx, input }) => {
return await verifyProjectDomain(input, ctx);
},
{ command: "verify-domain", client: "verifyDomain" }
),
}),
});
export const __testing__ = {
assertContentOrBuildPayload,
assertApiPublishDomains,
};