mirror of
https://github.com/webstudio-is/webstudio.git
synced 2026-10-26 21:17:40 +09:00
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.
1324 lines
38 KiB
TypeScript
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,
|
|
};
|