Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .claude/worktrees/agent-a53852dc
Submodule agent-a53852dc added at 7a8186
53 changes: 53 additions & 0 deletions apps/server/src/lib/schema-api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/lib/schema-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -831,6 +831,7 @@ export function mountSchemaApiRoutes(
types,
schemaHash: currentSync?.schemaHash ?? null,
syncedAt: currentSync?.syncedAt ?? null,
project: scope.project,
Comment thread
woywro marked this conversation as resolved.
},
};
});
Expand Down
43 changes: 43 additions & 0 deletions packages/shared/src/lib/contracts/schema.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
14 changes: 14 additions & 0 deletions packages/shared/src/lib/contracts/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ export type SchemaRegistryListResponse = {
types: SchemaRegistryEntry[];
schemaHash: string | null;
syncedAt: string | null;
project?: string;
};

export function validateSchemaRegistryListResponse(
Expand All @@ -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,
},
);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
types.forEach((entry, index) => {
assertSchemaRegistryEntry(entry, `${context}.types[${index}]`);
});
Expand Down
25 changes: 24 additions & 1 deletion packages/studio/src/lib/runtime-ui/app/admin/schema-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -298,6 +300,27 @@ export function SchemaPageView({ state }: { state: StudioSchemaState }) {
{state.project} / {state.environment}
</p>
</section>
) : state.status === "project-mismatch" ? (
<section
data-mdcms-schema-page-state="project-mismatch"
className="space-y-3 rounded-lg border border-destructive/30 bg-destructive/5 p-6"
>
<Badge variant="destructive">Configuration mismatch</Badge>
<p className="text-sm text-muted-foreground">
The local configuration is for project{" "}
<code className="rounded bg-muted px-1 py-0.5 text-xs">
{state.configProject}
</code>{" "}
but the server resolved project{" "}
<code className="rounded bg-muted px-1 py-0.5 text-xs">
{state.serverProject}
</code>
.
</p>
<p className="text-xs text-muted-foreground">
{state.configProject} / {state.environment}
</p>
</section>
) : state.entries.length === 0 ? (
<section
data-mdcms-schema-page-state="empty"
Expand Down
78 changes: 71 additions & 7 deletions packages/studio/src/lib/runtime-ui/pages/content-document-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -720,6 +720,13 @@ function resolveContentDocumentWriteAccess(input: {
return routeWriteAccess;
}

if (schemaState.status === "project-mismatch") {
return {
canWrite: false,
writeMessage: `Studio is configured for project "${schemaState.configProject}" but the server resolved project "${schemaState.serverProject}".`,
};
}

if (schemaState.status !== "ready") {
return {
canWrite: false,
Expand Down Expand Up @@ -1772,6 +1779,60 @@ function ContentDocumentPageStatusView(props: {
);
}

function renderProjectMismatchBanner(schemaState: StudioSchemaState) {
if (schemaState.status !== "project-mismatch") {
return null;
}

return (
<section
data-mdcms-schema-recovery-state="project-mismatch"
className="rounded-md border border-destructive/30 bg-destructive/5 px-4 py-3 text-sm text-foreground"
>
<div className="space-y-2">
<p className="font-medium">
Studio configuration does not match the connected project
</p>
<p className="text-foreground-muted">
The local configuration is for project{" "}
<code className="rounded bg-muted px-1 py-0.5 text-xs">
{schemaState.configProject}
</code>{" "}
but the server resolved project{" "}
<code className="rounded bg-muted px-1 py-0.5 text-xs">
{schemaState.serverProject}
</code>
.
</p>
<div className="space-y-1 text-xs text-foreground-muted">
<p className="font-medium text-foreground">To resolve:</p>
<ul className="list-disc space-y-0.5 pl-4">
<li>
Ensure Studio is embedded in the same directory as the{" "}
<code className="rounded bg-muted px-1 py-0.5">
mdcms.config.ts
</code>{" "}
for the target project
</li>
<li>
Verify that{" "}
<code className="rounded bg-muted px-1 py-0.5">serverUrl</code>{" "}
points to the server hosting project{" "}
<code className="rounded bg-muted px-1 py-0.5">
{schemaState.configProject}
</code>
</li>
<li>
Only run schema sync after confirming the project pairing is
correct
</li>
</ul>
</div>
</div>
</section>
);
}

function renderSchemaRecoveryBanner(input: {
state: ContentDocumentPageReadyState;
onSchemaSync?: () => void;
Expand Down Expand Up @@ -2655,12 +2716,14 @@ export function ContentDocumentPageView({
</div>
) : (
<div className="space-y-4">
{hasSchemaRecoveryMismatch(state.schemaState)
? renderSchemaRecoveryBanner({
state,
onSchemaSync,
})
: null}
{state.schemaState?.status === "project-mismatch"
? renderProjectMismatchBanner(state.schemaState)
: hasSchemaRecoveryMismatch(state.schemaState)
? renderSchemaRecoveryBanner({
state,
onSchemaSync,
})
: null}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

{state.mutationError ? (
<div
Expand All @@ -2673,7 +2736,8 @@ export function ContentDocumentPageView({

{!state.canWrite &&
state.writeMessage &&
!hasSchemaRecoveryMismatch(state.schemaState) ? (
!hasSchemaRecoveryMismatch(state.schemaState) &&
state.schemaState?.status !== "project-mismatch" ? (
<div className="rounded-md border border-border bg-background-subtle px-4 py-3 text-sm text-foreground-muted">
{state.writeMessage}
</div>
Expand Down
101 changes: 101 additions & 0 deletions packages/studio/src/lib/schema-state.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -447,3 +447,104 @@ test("loadStudioSchemaState keeps ready-state data when a sync attempt fails", a
assert.equal(failedState.canSync, state.canSync);
assert.equal(failedState.syncError, "Forbidden.");
});

test("loadStudioSchemaState returns project-mismatch when server project differs from config", async () => {
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);
});
Loading
Loading