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/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..5cfdf3d7 100644 --- a/packages/shared/src/lib/contracts/schema.test.ts +++ b/packages/shared/src/lib/contracts/schema.test.ts @@ -104,6 +104,49 @@ 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 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 a24b1322..3b0528b2 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,19 @@ export function validateSchemaRegistryListResponse( syncedAt, }); } + const project = payload.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/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" ? ( +
+ The local configuration is for project{" "}
+
+ {state.configProject}
+ {" "}
+ but the server resolved project{" "}
+
+ {state.serverProject}
+
+ .
+
+ {state.configProject} / {state.environment} +
++ 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:
+
+ mdcms.config.ts
+ {" "}
+ for the target project
+ serverUrl{" "}
+ points to the server hosting project{" "}
+
+ {schemaState.configProject}
+
+