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}