From faab73206890eb05780018e3d4ac771c87c4601d Mon Sep 17 00:00:00 2001
From: woywro
Date: Fri, 17 Apr 2026 08:23:57 +0200
Subject: [PATCH 1/2] feat(studio): show project/config mismatch error before
schema hash comparison
---
apps/server/src/lib/schema-api.test.ts | 53 +++++++++
apps/server/src/lib/schema-api.ts | 1 +
.../shared/src/lib/contracts/schema.test.ts | 17 +++
packages/shared/src/lib/contracts/schema.ts | 7 ++
.../lib/runtime-ui/app/admin/schema-page.tsx | 25 ++++-
.../pages/content-document-page.tsx | 75 +++++++++++--
packages/studio/src/lib/schema-state.test.ts | 101 ++++++++++++++++++
packages/studio/src/lib/schema-state.ts | 20 +++-
8 files changed, 291 insertions(+), 8 deletions(-)
diff --git a/apps/server/src/lib/schema-api.test.ts b/apps/server/src/lib/schema-api.test.ts
index 08de2345..5761a8fc 100644
--- a/apps/server/src/lib/schema-api.test.ts
+++ b/apps/server/src/lib/schema-api.test.ts
@@ -409,6 +409,59 @@ test("schema API requires explicit target routing", async () => {
}
});
+test("schema list response includes the resolved project slug", async () => {
+ const store: SchemaRegistryStore = {
+ async list() {
+ return [];
+ },
+ async getByType() {
+ return undefined;
+ },
+ async getCurrentSync() {
+ return undefined;
+ },
+ async sync() {
+ return {
+ schemaHash: "unused",
+ syncedAt: fixedNow.toISOString(),
+ affectedTypes: [],
+ };
+ },
+ };
+ const handler = createServerRequestHandler({
+ env: baseEnv,
+ logger,
+ now: () => fixedNow,
+ configureApp: (app) => {
+ mountSchemaApiRoutes(app, {
+ store,
+ authorize: async () => undefined,
+ requireCsrf: async () => undefined,
+ });
+ },
+ });
+
+ const response = await handler(
+ new Request("http://localhost/api/v1/schema", {
+ headers: {
+ "x-mdcms-project": "my-project",
+ "x-mdcms-environment": "production",
+ },
+ }),
+ );
+ const body = (await response.json()) as {
+ data: {
+ types: unknown[];
+ schemaHash: string | null;
+ syncedAt: string | null;
+ project: string;
+ };
+ };
+
+ assert.equal(response.status, 200);
+ assert.equal(body.data.project, "my-project");
+});
+
test("schema API rejects localized schema sync payloads without explicit supported locales", async () => {
const { handler, getSyncCallCount } = createValidationHandler();
const response = await handler(
diff --git a/apps/server/src/lib/schema-api.ts b/apps/server/src/lib/schema-api.ts
index 5b29455e..37bab946 100644
--- a/apps/server/src/lib/schema-api.ts
+++ b/apps/server/src/lib/schema-api.ts
@@ -831,6 +831,7 @@ export function mountSchemaApiRoutes(
types,
schemaHash: currentSync?.schemaHash ?? null,
syncedAt: currentSync?.syncedAt ?? null,
+ project: scope.project,
},
};
});
diff --git a/packages/shared/src/lib/contracts/schema.test.ts b/packages/shared/src/lib/contracts/schema.test.ts
index 8ef42e18..9e4d18f8 100644
--- a/packages/shared/src/lib/contracts/schema.test.ts
+++ b/packages/shared/src/lib/contracts/schema.test.ts
@@ -104,6 +104,23 @@ test("validateSchemaRegistryListResponse accepts null hash and syncedAt", () =>
);
});
+test("validateSchemaRegistryListResponse accepts payload with project field", () => {
+ const payload = {
+ types: [],
+ schemaHash: "a".repeat(64),
+ syncedAt: "2026-04-14T12:00:00.000Z",
+ project: "my-project",
+ };
+ const result = validateSchemaRegistryListResponse("test", payload);
+ assert.equal(result.project, "my-project");
+});
+
+test("validateSchemaRegistryListResponse accepts payload without project field", () => {
+ const payload = { types: [], schemaHash: null, syncedAt: null };
+ const result = validateSchemaRegistryListResponse("test", payload);
+ assert.equal(result.project, undefined);
+});
+
test("validateSchemaRegistryListResponse rejects non-null non-string hash", () => {
const payload = { types: [], schemaHash: 123, syncedAt: null };
expectInvalidInput(
diff --git a/packages/shared/src/lib/contracts/schema.ts b/packages/shared/src/lib/contracts/schema.ts
index a24b1322..d1e46e04 100644
--- a/packages/shared/src/lib/contracts/schema.ts
+++ b/packages/shared/src/lib/contracts/schema.ts
@@ -47,6 +47,7 @@ export type SchemaRegistryListResponse = {
types: SchemaRegistryEntry[];
schemaHash: string | null;
syncedAt: string | null;
+ project?: string;
};
export function validateSchemaRegistryListResponse(
@@ -72,6 +73,12 @@ export function validateSchemaRegistryListResponse(
syncedAt,
});
}
+ const project = payload.project;
+ if (project !== undefined && typeof project !== "string") {
+ invalidInput(`${context}.project`, "must be string or undefined.", {
+ project,
+ });
+ }
types.forEach((entry, index) => {
assertSchemaRegistryEntry(entry, `${context}.types[${index}]`);
});
diff --git a/packages/studio/src/lib/runtime-ui/app/admin/schema-page.tsx b/packages/studio/src/lib/runtime-ui/app/admin/schema-page.tsx
index 0eebee37..e98e577b 100644
--- a/packages/studio/src/lib/runtime-ui/app/admin/schema-page.tsx
+++ b/packages/studio/src/lib/runtime-ui/app/admin/schema-page.tsx
@@ -217,7 +217,9 @@ export function SchemaPageView({ state }: { state: StudioSchemaState }) {
const pageDescription =
state.status === "loading"
? state.message
- : `Read-only schema browser for ${state.project} / ${state.environment}.`;
+ : state.status === "project-mismatch"
+ ? `Project mismatch: configured "${state.configProject}" but server resolved "${state.serverProject}".`
+ : `Read-only schema browser for ${state.project} / ${state.environment}.`;
const sharedSyncSummary =
state.status === "ready" ? getSharedSchemaSyncSummary(state.entries) : null;
@@ -298,6 +300,27 @@ export function SchemaPageView({ state }: { state: StudioSchemaState }) {
{state.project} / {state.environment}
+ ) : state.status === "project-mismatch" ? (
+
+ Configuration mismatch
+
+ The local configuration is for project{" "}
+
+ {state.configProject}
+ {" "}
+ but the server resolved project{" "}
+
+ {state.serverProject}
+
+ .
+
+
+ {state.configProject} / {state.environment}
+
+
) : state.entries.length === 0 ? (
+
+
+ Studio configuration does not match the connected project
+
+
+ The local configuration is for project{" "}
+
+ {schemaState.configProject}
+ {" "}
+ but the server resolved project{" "}
+
+ {schemaState.serverProject}
+
+ .
+
+
+
To resolve:
+
+ -
+ Ensure Studio is embedded in the same directory as the{" "}
+
+ mdcms.config.ts
+ {" "}
+ for the target project
+
+ -
+ Verify that{" "}
+
serverUrl{" "}
+ points to the server hosting project{" "}
+
+ {schemaState.configProject}
+
+
+ -
+ Only run schema sync after confirming the project pairing is
+ correct
+
+
+
+
+
+ );
+}
+
function renderSchemaRecoveryBanner(input: {
state: ContentDocumentPageReadyState;
onSchemaSync?: () => void;
@@ -2655,12 +2716,14 @@ export function ContentDocumentPageView({
) : (
- {hasSchemaRecoveryMismatch(state.schemaState)
- ? renderSchemaRecoveryBanner({
- state,
- onSchemaSync,
- })
- : null}
+ {state.schemaState?.status === "project-mismatch"
+ ? renderProjectMismatchBanner(state.schemaState)
+ : hasSchemaRecoveryMismatch(state.schemaState)
+ ? renderSchemaRecoveryBanner({
+ state,
+ onSchemaSync,
+ })
+ : null}
{state.mutationError ? (
{
+ const api = createSchemaRouteApi(
+ async () =>
+ new Response(
+ JSON.stringify({
+ data: {
+ types: [],
+ schemaHash: "server-hash",
+ syncedAt: "2026-03-31T12:00:00.000Z",
+ project: "other-project",
+ },
+ }),
+ {
+ status: 200,
+ headers: { "content-type": "application/json" },
+ },
+ ),
+ );
+
+ const state = await loadStudioSchemaState({
+ config: createConfig(),
+ schemaApi: api,
+ capabilitiesApi: createCapabilitiesApi(),
+ });
+
+ assert.equal(state.status, "project-mismatch");
+ if (state.status !== "project-mismatch") {
+ throw new Error("Expected project-mismatch state.");
+ }
+ assert.equal(state.configProject, "marketing-site");
+ assert.equal(state.serverProject, "other-project");
+ assert.equal(state.environment, "staging");
+});
+
+test("loadStudioSchemaState detects hash mismatch when server project matches config", async () => {
+ const api = createSchemaRouteApi(
+ async () =>
+ new Response(
+ JSON.stringify({
+ data: {
+ types: [],
+ schemaHash: "server-hash",
+ syncedAt: "2026-03-31T12:00:00.000Z",
+ project: "marketing-site",
+ },
+ }),
+ {
+ status: 200,
+ headers: { "content-type": "application/json" },
+ },
+ ),
+ );
+
+ const state = await loadStudioSchemaState({
+ config: createConfig(),
+ schemaApi: api,
+ capabilitiesApi: createCapabilitiesApi({
+ schema: { read: true, write: true },
+ }),
+ });
+
+ assert.equal(state.status, "ready");
+ if (state.status !== "ready") {
+ throw new Error("Expected ready state.");
+ }
+ assert.equal(state.isMismatch, true);
+});
+
+test("loadStudioSchemaState falls through to hash comparison when server omits project", async () => {
+ const api = createSchemaRouteApi(
+ async () =>
+ new Response(
+ JSON.stringify({
+ data: {
+ types: [],
+ schemaHash: "server-hash",
+ syncedAt: "2026-03-31T12:00:00.000Z",
+ },
+ }),
+ {
+ status: 200,
+ headers: { "content-type": "application/json" },
+ },
+ ),
+ );
+
+ const state = await loadStudioSchemaState({
+ config: createConfig(),
+ schemaApi: api,
+ capabilitiesApi: createCapabilitiesApi({
+ schema: { read: true, write: true },
+ }),
+ });
+
+ assert.equal(state.status, "ready");
+ if (state.status !== "ready") {
+ throw new Error("Expected ready state.");
+ }
+ assert.equal(state.isMismatch, true);
+});
diff --git a/packages/studio/src/lib/schema-state.ts b/packages/studio/src/lib/schema-state.ts
index cc9df4b3..57f578eb 100644
--- a/packages/studio/src/lib/schema-state.ts
+++ b/packages/studio/src/lib/schema-state.ts
@@ -54,11 +54,19 @@ export type StudioSchemaErrorState = {
message: string;
};
+export type StudioSchemaProjectMismatchState = {
+ status: "project-mismatch";
+ configProject: string;
+ serverProject: string;
+ environment: string;
+};
+
export type StudioSchemaState =
| StudioSchemaLoadingState
| StudioSchemaReadyState
| StudioSchemaForbiddenState
- | StudioSchemaErrorState;
+ | StudioSchemaErrorState
+ | StudioSchemaProjectMismatchState;
export type LoadStudioSchemaStateInput = {
config: Pick
;
@@ -240,6 +248,16 @@ export async function loadStudioSchemaState(
try {
const listResult = await api.list();
+
+ if (listResult.project && listResult.project !== input.config.project) {
+ return {
+ status: "project-mismatch",
+ configProject: input.config.project,
+ serverProject: listResult.project,
+ environment: input.config.environment,
+ };
+ }
+
const entries = listResult.types;
const serverSchemaHash = normalizeServerSchemaHash(listResult.schemaHash);
let capabilities = createEmptyCurrentPrincipalCapabilities();
From 7e770b2d6fbc600b517b348c5970a0c1b3900b22 Mon Sep 17 00:00:00 2001
From: woywro
Date: Fri, 17 Apr 2026 10:09:57 +0200
Subject: [PATCH 2/2] fix: reject empty project strings in schema validation
and suppress duplicate mismatch banner
---
.claude/worktrees/agent-a53852dc | 1 +
.../shared/src/lib/contracts/schema.test.ts | 26 +++++++++++++++++++
packages/shared/src/lib/contracts/schema.ts | 15 ++++++++---
.../pages/content-document-page.tsx | 3 ++-
4 files changed, 40 insertions(+), 5 deletions(-)
create mode 160000 .claude/worktrees/agent-a53852dc
diff --git a/.claude/worktrees/agent-a53852dc b/.claude/worktrees/agent-a53852dc
new file mode 160000
index 00000000..7a818612
--- /dev/null
+++ b/.claude/worktrees/agent-a53852dc
@@ -0,0 +1 @@
+Subproject commit 7a818612c2d473807fde9a54333c02a101a710fe
diff --git a/packages/shared/src/lib/contracts/schema.test.ts b/packages/shared/src/lib/contracts/schema.test.ts
index 9e4d18f8..5cfdf3d7 100644
--- a/packages/shared/src/lib/contracts/schema.test.ts
+++ b/packages/shared/src/lib/contracts/schema.test.ts
@@ -121,6 +121,32 @@ test("validateSchemaRegistryListResponse accepts payload without project field",
assert.equal(result.project, undefined);
});
+test("validateSchemaRegistryListResponse rejects empty project string", () => {
+ const payload = {
+ types: [],
+ schemaHash: null,
+ syncedAt: null,
+ project: "",
+ };
+ expectInvalidInput(
+ () => validateSchemaRegistryListResponse("test", payload),
+ "test.project",
+ );
+});
+
+test("validateSchemaRegistryListResponse rejects blank project string", () => {
+ const payload = {
+ types: [],
+ schemaHash: null,
+ syncedAt: null,
+ project: " ",
+ };
+ expectInvalidInput(
+ () => validateSchemaRegistryListResponse("test", payload),
+ "test.project",
+ );
+});
+
test("validateSchemaRegistryListResponse rejects non-null non-string hash", () => {
const payload = { types: [], schemaHash: 123, syncedAt: null };
expectInvalidInput(
diff --git a/packages/shared/src/lib/contracts/schema.ts b/packages/shared/src/lib/contracts/schema.ts
index d1e46e04..3b0528b2 100644
--- a/packages/shared/src/lib/contracts/schema.ts
+++ b/packages/shared/src/lib/contracts/schema.ts
@@ -74,10 +74,17 @@ export function validateSchemaRegistryListResponse(
});
}
const project = payload.project;
- if (project !== undefined && typeof project !== "string") {
- invalidInput(`${context}.project`, "must be string or undefined.", {
- project,
- });
+ if (
+ project !== undefined &&
+ (typeof project !== "string" || project.trim() === "")
+ ) {
+ invalidInput(
+ `${context}.project`,
+ "must be a non-empty string or undefined.",
+ {
+ project,
+ },
+ );
}
types.forEach((entry, index) => {
assertSchemaRegistryEntry(entry, `${context}.types[${index}]`);
diff --git a/packages/studio/src/lib/runtime-ui/pages/content-document-page.tsx b/packages/studio/src/lib/runtime-ui/pages/content-document-page.tsx
index 6a431f71..7d1a7644 100644
--- a/packages/studio/src/lib/runtime-ui/pages/content-document-page.tsx
+++ b/packages/studio/src/lib/runtime-ui/pages/content-document-page.tsx
@@ -2736,7 +2736,8 @@ export function ContentDocumentPageView({
{!state.canWrite &&
state.writeMessage &&
- !hasSchemaRecoveryMismatch(state.schemaState) ? (
+ !hasSchemaRecoveryMismatch(state.schemaState) &&
+ state.schemaState?.status !== "project-mismatch" ? (
{state.writeMessage}