Files
Oleg Isonen dd99d27e5e fix: Stop returning unqueryable unpublish jobs (#6588)
Unpublish finishes in the request, but it returned a job ID that
`get-publish-job` could never find. It now returns the domains it
unpublished. A missing publish job also stays a normal not-found error
instead of suggesting a CLI update.

Closes #6578

## Changes
- Keep filename-based E2E shard discovery. Rebalance the files across
ten shards using measured CI times; each shard keeps its own test
environment and the same 12-minute safety timeout.
- Remove the fabricated unpublish job ID. `get-publish-job` remains for
publish jobs.
- Remove publish-only flags from the unpublish CLI and clarify CLI/MCP
guidance.
- Keep legacy optional server inputs for compatibility. The API input
contract does not change; clients that read the old `jobId` result need
to use `unpublishedDomains` instead.
- No database migration. Existing domain-removal behavior is unchanged,
including retention of other published domains.

## Checklist
- [x] Reproduced both reported failures with tests before the fix.
- [x] Reviewed the change for compatibility, redundant code, and
retained-domain behavior.
- [x] Updated the authoritative MCP docs and regenerated the CLI/MCP
docs.
- [x] Ran focused Builder, CLI, domain, and build tests; workspace
typecheck, lint, package-boundary, generated API, and generated-doc
checks.
- [x] Restore filename-based shard discovery and rebalance the files
across ten shards.
- [x] Verify every Chromium workflow test runs in exactly one shard and
update the E2E contributor docs. All 141 tests are assigned once across
the ten shards.
- [x] Verify parallel-safe long E2E files and the two-submission legacy
form test in CI.
- [x] Let independent slow E2E files run their tests concurrently; all
ten shard jobs passed.
- [ ] Confirm all E2E jobs finish in under five minutes. Eight did;
shard 1 took 5:10 and shard 4 took 6:32. Move the long Content-mode page
and shared-project bridge files to separate shards, then remeasure.
- [x] Required CI checks pass.

No live unpublish was run because it would change a published site.
Agent evaluations were not run; they require separate approval.
2026-10-23 18:59:44 +02:00

1324 lines
38 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 { singlePlayerVersionMismatchError } from "@webstudio-is/project/build-patch-core.server";
import {
createProjectDomain,
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 {
createContentBlockApplication,
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 { assertContentOrBuildTransactions } from "./api-content-permissions.server";
import { validateProjectSettingsUpdate } from "./project-settings-mutation.server";
import {
createBuilderRuntimeState,
executeApiRuntimeMutation,
executeApiRuntimeOperation,
} from "./api-runtime.server";
import { createAssetClient } from "../shared/asset-client";
import { previewProjectAssetQuery } from "./asset-query-preview.server";
import { simulateFormSubmission } from "./simulate-form.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;
auth: Awaited<ReturnType<typeof assertApiProjectPermit>>;
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>;
const auth = await assertApiProjectPermit(
ctx,
parsedInput.projectId,
"build"
);
const build = await loadDevBuildByProjectId(ctx, parsedInput.projectId);
return await handler({
ctx,
auth,
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 ({ ctx, input, build, commit }) => {
const error = await runtimeMutationValidators[id]?.({
input,
build,
context: ctx,
});
if (error !== undefined) {
return throwApiError("BAD_REQUEST", error);
}
return commitRuntimeMutation<Result>({
id,
build,
input,
commit,
});
}
);
const runtimeMutationValidators: Partial<
Record<
RuntimeOperationId,
(args: {
input: unknown;
build: CompactBuild;
context: AppContext;
}) => Promise<string | undefined>
>
> = {
"projectSettings.update": validateProjectSettingsUpdate,
};
type BuildCommit = <CommitResult extends Record<string, unknown> = {}>(
payload: z.infer<typeof buildPatchTransaction>["payload"],
result?: CommitResult,
assetFolders?: readonly AssetFolder[]
) => 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 contentOrBuildMutation = <
Schema extends z.ZodType<{ projectId: string }>,
Result,
>(
input: Schema,
handler: (args: {
ctx: AppContext;
auth: Awaited<ReturnType<typeof assertApiProjectPermit>>;
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,
auth,
input: parsedInput,
build,
commit: async <CommitResult extends Record<string, unknown> = {}>(
payload: z.infer<typeof buildPatchTransaction>["payload"],
result?: CommitResult,
assetFolders?: readonly AssetFolder[]
) => {
assertContentOrBuildTransactions({
auth,
build,
transactions: [{ id: "api-content-mode", payload }],
assetFolders,
});
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> = {}>(
operation: RuntimeOperation
) => {
const { id, permit, requiresConfirm } = operation;
const mutation = permit === "edit" ? contentOrBuildMutation : buildMutation;
return mutation(
runtimeMutationInput(id, permit === "edit" ? requiresConfirm : true),
async ({ ctx, auth, input, build, commit }) => {
const { assets, assetFolders } = await loadRuntimeAssetData(
ctx,
input.projectId
);
return await commitRuntimeMutation<Result>({
id,
build,
assets,
assetFolders,
input:
id === "pageTemplates.createPage" &&
auth.permits.includes("build") === false
? { ...input, contentMode: true }
: input,
commit:
id === "assetFolders.create"
? (payload, result) => commit(payload, result, assetFolders)
: 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(operation);
}
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,
};
}),
}),
forms: router({
simulateSubmission: projectMutation(
projectIdInput.extend({
formId: z.string().min(1),
path: z.string().min(1).max(2048),
siteOrigin: z.string().url().optional(),
values: z
.array(
z.object({
name: z.string().min(1).max(256),
value: z.string().max(10_000),
})
)
.max(100),
arrayNames: z.array(z.string().min(1).max(256)).max(100).default([]),
}),
"build",
async ({ ctx, input }) => {
if (
new TextEncoder().encode(JSON.stringify(input.values)).byteLength >
100_000
) {
throw new Error("Form simulation values are too large");
}
const result = await simulateFormSubmission({
context: ctx,
...input,
});
return {
success: result.success,
status: result.status,
results: result.results.map(({ status, resourceId }) => ({
status,
resourceId,
})),
errors: result.errors.map(
({ status, message, code, resourceId }) => ({
status,
message,
...(resourceId === undefined ? {} : { resourceId }),
...(code === undefined ? {} : { code }),
})
),
};
},
{ command: "simulate-form-submission", client: "simulateFormSubmission" }
),
}),
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 }),
serverApiContractVersion: 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,
"edit",
async ({ ctx, input, auth }) => {
const build = await loadDevBuildByProjectId(ctx, input.projectId);
if (input.baseVersion > build.version) {
throwApiError("CONFLICT", singlePlayerVersionMismatchError);
}
// Older versions cannot write; let persistence handle retries and conflicts.
if (input.baseVersion === build.version) {
assertContentOrBuildTransactions({
auth,
build,
transactions: input.transactions,
scope: "text-and-props",
});
}
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 { unpublishedDomains: domains };
},
{ 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__ = {
assertApiPublishDomains,
};